diff --git a/README.md b/README.md index 8ab9335..81fa5b3 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,10 @@ public void ConfigureServices(IServiceCollection services) Implementation for `IRepositoryFactory` will be generated at runtime. In fact, each factory method can take one parameter of an arbitrary type - string, enum, custom class, whatever. -In this case, a _named binding_ should be specified. +In this case, a _named binding_ should be specified. Then you may resolve required services passing the name of +the binding into factory methods. If you want to provide a default implementation then you may configure _default +binding_. Default binding is such a binding used in the absence of a named one. A user should set default binding +explicitly to be able to resolve services for unregistered names. ```csharp public interface IRepositoryFactory @@ -236,6 +239,11 @@ public class RandomRepository : IRepository ... } +public class DefaultRepository : IRepository +{ +... +} + public void ConfigureServices(IServiceCollection services) { services.AddTransient() @@ -245,7 +253,8 @@ public void ConfigureServices(IServiceCollection services) .For() .Named("demo") .Named("prod") - .Named("rnd"); + .Named("rnd") + .Default(); } public class Person @@ -262,20 +271,24 @@ public class SomeClassWithDependency _factory = factory; } + private bool SomeInterestingCondition => ... + public void DoSomething(Person person) { if (person.Name == "demoUser") - _factory.GetPersonsRepo("demo").Save(person); + _factory.GetPersonsRepo("demo").Save(person); // DemoRepository else if (person.Name.StartsWith("tester")) - _factory.GetPersonsRepo("rnd").Save(person); + _factory.GetPersonsRepo("rnd").Save(person); // RandomRepository + else if (SomeInterestingCondition) + _factory.GetPersonsRepo("prod").Save(person); // ProductionRepository else - _factory.GetPersonsRepo("prod").Save(person); + _factory.GetPersonsRepo(person.Name).Save(person); // DefaultRepository } } ``` In the example above, the `GetPersonsRepo` method will return the corresponding implementation of the `IRepository` -interface, configured for the provided name. +interface, configured for the provided name. For all unregistered names (including null) it will return `DefaultRepository`. ## How it works? diff --git a/src/SteroidsDI.Tests/Cases.Approval/ApiApprovalTests.cs b/src/SteroidsDI.Tests/Cases.Approval/ApiApprovalTests.cs index 2d837dc..7c676e5 100644 --- a/src/SteroidsDI.Tests/Cases.Approval/ApiApprovalTests.cs +++ b/src/SteroidsDI.Tests/Cases.Approval/ApiApprovalTests.cs @@ -17,13 +17,13 @@ public class ApiApprovalTests [TestCase(typeof(AspNetCoreHttpScopeProvider))] public void PublicApi(Type type) { - string publicApi = type.Assembly.GeneratePublicApi(new ApiGeneratorOptions + string publicApi = type.Assembly.GeneratePublicApi(new() { IncludeAssemblyAttributes = false, AllowNamespacePrefixes = ["System", "Microsoft.Extensions.DependencyInjection"], ExcludeAttributes = ["System.Diagnostics.DebuggerDisplayAttribute"], }); - publicApi.ShouldMatchApproved(options => options!.WithFilenameGenerator((testMethodInfo, discriminator, fileType, fileExtension) => $"{type.Assembly.GetName().Name!}.{fileType}.{fileExtension}")); + publicApi.ShouldMatchApproved(options => options.NoDiff().WithFilenameGenerator((testMethodInfo, discriminator, fileType, fileExtension) => $"{type.Assembly.GetName().Name!}.{fileType}.{fileExtension}")); } } diff --git a/src/SteroidsDI.Tests/Cases.Approval/SteroidsDI.approved.txt b/src/SteroidsDI.Tests/Cases.Approval/SteroidsDI.approved.txt index 1660f00..1471820 100644 --- a/src/SteroidsDI.Tests/Cases.Approval/SteroidsDI.approved.txt +++ b/src/SteroidsDI.Tests/Cases.Approval/SteroidsDI.approved.txt @@ -19,9 +19,13 @@ namespace SteroidsDI { public BindingContext(Microsoft.Extensions.DependencyInjection.IServiceCollection services) { } public Microsoft.Extensions.DependencyInjection.IServiceCollection Services { get; } + public SteroidsDI.BindingContext Default() + where TImplementation : TService { } + public SteroidsDI.BindingContext Default(Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime) + where TImplementation : TService { } public SteroidsDI.BindingContext Named(object name) where TImplementation : TService { } - public SteroidsDI.BindingContext Named(object name, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = 2) + public SteroidsDI.BindingContext Named(object name, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime) where TImplementation : TService { } } public sealed class GenericScopeProvider : SteroidsDI.Core.IScopeProvider diff --git a/src/SteroidsDI.Tests/Cases/FactoryTests.cs b/src/SteroidsDI.Tests/Cases/FactoryTests.cs index 74d2b69..6af2b12 100644 --- a/src/SteroidsDI.Tests/Cases/FactoryTests.cs +++ b/src/SteroidsDI.Tests/Cases/FactoryTests.cs @@ -15,14 +15,71 @@ public void Named_Binding_Should_Throw_On_Unknown_Lifetime() { var services = new ServiceCollection(); var context = services.For(); - Should.Throw(() => context.Named("xxx")).Message.ShouldBe(@"The DI container does not have a default binding for the type 'SteroidsDI.Tests.IBuilder', so it is not possible to determine the value of Lifetime. -Use the 'Named' overload with explicit Lifetime or first set the default binding in the DI container."); + Should.Throw(() => context.Named("xxx")).Message.ShouldBe(@"The DI container does not register type 'SteroidsDI.Tests.IBuilder', so it is not possible to determine the value of Lifetime. +Use the 'Named'/'Default' overloads with explicit Lifetime or first register 'SteroidsDI.Tests.IBuilder' in the DI container."); + Should.Throw(() => context.Default()).Message.ShouldBe(@"The DI container does not register type 'SteroidsDI.Tests.IBuilder', so it is not possible to determine the value of Lifetime. +Use the 'Named'/'Default' overloads with explicit Lifetime or first register 'SteroidsDI.Tests.IBuilder' in the DI container."); } [Test] - public void Factory_And_Named_Bindings_Should_Work() + public void Named_Binding_Should_Allow_The_Same_Type_With_Different_Names() { - using var provider = ServicesBuilder.BuildDefault().BuildServiceProvider(validateScopes: true); + var services = new ServiceCollection() + .AddTransient() + .For() + .Named("aaa") + .Named("bbb") + .Named("ccc") + .Named("ddd") + .Named("eee") + .Services; + + services.Count.ShouldBe(7); + } + + [Test] + public void Default_Binding_Should_Allow_Redeclaration() + { + var services = new ServiceCollection() + .AddTransient() + .For() + .Default() + .Default() + .Services; + services.Count.ShouldBe(3); + var def = (NamedBinding)services.Last().ImplementationInstance!; + def.Name.ShouldBe(NamedBinding.DefaultName); + def.ImplementationType.ShouldBe(typeof(SpecialBuilder)); + } + + [Test] + public void Default_Binding_Should_Allow_Replace() + { + var services = new ServiceCollection() + .AddTransient() + .For() + .Default() + .Default() + .Services; + services.Count.ShouldBe(4); + var def = (NamedBinding)services[2].ImplementationInstance!; + def.Name.ShouldBe(NamedBinding.DefaultName); + def.ImplementationType.ShouldBe(typeof(SpecialBuilderOver9000Level)); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void Factory_And_Named_Bindings_Should_Work(bool useDefault) + { + var services = ServicesBuilder.BuildDefault(addDefalt: useDefault); + + // not specific to this test - just add some additional registration for another type and place it in front of other registrations to increase code coverage + services.For().Named("some", ServiceLifetime.Singleton); + services.Insert(0, services.Last()); + services.RemoveAt(services.Count - 1); + + using var provider = services.BuildServiceProvider(validateScopes: true); using var scope = provider.CreateScope(); GenericScope.CurrentScope = scope; @@ -59,10 +116,12 @@ public void Factory_And_Named_Bindings_Should_Work() } [Test] + [TestCase(true)] + [TestCase(false)] [Category("Generic")] - public void Generic_Factory_And_Named_Bindings_Should_Work() + public void Generic_Factory_And_Named_Bindings_Should_Work(bool useDefault) { - using var provider = ServicesBuilder.BuildDefault().BuildServiceProvider(validateScopes: true); + using var provider = ServicesBuilder.BuildDefault(addDefalt: useDefault).BuildServiceProvider(validateScopes: true); using var scope = provider.CreateScope(); GenericScope.CurrentScope = scope; @@ -100,35 +159,91 @@ public void Generic_Factory_And_Named_Bindings_Should_Work() [Test] [Category("Throw")] - public void Null_Binding_Should_Throw() + public void Null_Binding_Should_Throw_When_No_Default() { using var provider = ServicesBuilder.BuildDefault().BuildServiceProvider(validateScopes: true); var controller = provider.GetRequiredService(); - Should.Throw(() => controller.Factory.CCC(null!)); - Should.Throw(() => controller.Generic.CCC(null!)); + var msg1 = Should.Throw(() => controller.Factory.CCC(null!)).Message; + msg1.ShouldBe("Destination type not found for named binding '' to type 'SteroidsDI.Tests.IBuilder' and no default binding exists. Verify that either a named binding or default binding is specified in the DI container."); + var msg2 = Should.Throw(() => controller.Generic.CCC(null!)).Message; + msg2.ShouldBe("Destination type not found for named binding '' to type 'SteroidsDI.Tests.IBuilder' and no default binding exists. Verify that either a named binding or default binding is specified in the DI container."); } [Test] - [Category("Throw")] - public void Unknown_Binding_Should_Throw() + public void Null_Binding_Should_Work_When_Default() + { + using var provider = ServicesBuilder.BuildDefault(addDefalt: true).BuildServiceProvider(validateScopes: true); + var controller = provider.GetRequiredService(); + + var builder1 = controller.Factory.CCC(null!); + builder1.ShouldBeAssignableTo(); + builder1.Build(); + + var builder2 = controller.Generic.CCC(null!); + builder2.ShouldBeAssignableTo(); + builder2.Build(); + } + + [Test] + public void Unknown_Binding_Should_Throw_When_No_Default() { using var provider = ServicesBuilder.BuildDefault().BuildServiceProvider(validateScopes: true); var controller = provider.GetRequiredService(); - Should.Throw(() => controller.Factory.CCC("Zorro")); - Should.Throw(() => controller.Generic.CCC("Zorro")); + var msg1 = Should.Throw(() => controller.Factory.CCC("Zorro")).Message; + msg1.ShouldBe("Destination type not found for named binding 'Zorro' to type 'SteroidsDI.Tests.IBuilder' and no default binding exists. Verify that either a named binding or default binding is specified in the DI container."); + var msg2 = Should.Throw(() => controller.Generic.CCC("Zorro")).Message; + msg2.ShouldBe("Destination type not found for named binding 'Zorro' to type 'SteroidsDI.Tests.IBuilder' and no default binding exists. Verify that either a named binding or default binding is specified in the DI container."); - Should.Throw(() => controller.Factory.DDD(ManagerType.Angry)); - Should.Throw(() => controller.Generic.DDD(ManagerType.Angry)); + var msg3 = Should.Throw(() => controller.Factory.DDD(ManagerType.Angry)).Message; + msg3.ShouldBe("Destination type not found for named binding 'Angry' to type 'SteroidsDI.Tests.IBuilder' and no default binding exists. Verify that either a named binding or default binding is specified in the DI container."); + var msg4 = Should.Throw(() => controller.Generic.DDD(ManagerType.Angry)).Message; + msg4.ShouldBe("Destination type not found for named binding 'Angry' to type 'SteroidsDI.Tests.IBuilder' and no default binding exists. Verify that either a named binding or default binding is specified in the DI container."); + } + + [Test] + [Category("Throw")] + public void Unknown_Binding_Should_Work_When_Default() + { + using var provider = ServicesBuilder.BuildDefault(addDefalt: true).BuildServiceProvider(validateScopes: true); + var controller = provider.GetRequiredService(); + + var builder1 = controller.Factory.CCC("Zorro"); + builder1.ShouldBeAssignableTo(); + builder1.Build(); + + var builder2 = controller.Generic.CCC("Zorro"); + builder2.ShouldBeAssignableTo(); + builder2.Build(); + + var builder3 = controller.Factory.DDD(ManagerType.Angry); + builder3.ShouldBeAssignableTo(); + builder3.Build(); + + var builder4 = controller.Generic.DDD(ManagerType.Angry); + builder4.ShouldBeAssignableTo(); + builder4.Build(); } [Test] [Category("Throw")] public void Named_Binding_With_Invalid_Properties_Should_Throw() { - Should.Throw(() => ServicesBuilder.BuildDefault().For().Named(null!).Services.BuildServiceProvider(validateScopes: true)); - Should.Throw(() => ServicesBuilder.BuildDefault().For().Named("oops", ServiceLifetime.Transient).Services.BuildServiceProvider(validateScopes: true)); + var msg1 = Should.Throw(() => ServicesBuilder.BuildDefault().For().Named(null!).Services.BuildServiceProvider(validateScopes: true)).Message; + msg1.ShouldBe("No binding name specified. (Parameter 'name')"); + var msg2 = Should.Throw(() => ServicesBuilder.BuildDefault().For().Named("oops", ServiceLifetime.Transient).Services.BuildServiceProvider(validateScopes: true)).Message; + msg2.ShouldBe(@"It is not possible to add a named binding 'oops' for type SteroidsDI.Tests.IBuilder, because the DI container +already has a binding on type SteroidsDI.Tests.SpecialBuilderOver9000Level with different characteristics. This is a limitation of the current implementation."); + } + + [Test] + [Category("Throw")] + public void Default_Binding_With_Invalid_Properties_Should_Throw() + { + var msg = Should.Throw(() => ServicesBuilder.BuildDefault(addDefalt: true).For().Default(ServiceLifetime.Transient).Services.BuildServiceProvider(validateScopes: true)).Message; + msg.ShouldBe(@"It is not possible to add a default binding for type SteroidsDI.Tests.IBuilder, because the DI container +already has a binding on type SteroidsDI.Tests.DefaultBuilder with different characteristics. This is a limitation of the current implementation."); } [Test] diff --git a/src/SteroidsDI.Tests/Model/IBuilder.cs b/src/SteroidsDI.Tests/Model/IBuilder.cs index 832e854..7ad33d9 100644 --- a/src/SteroidsDI.Tests/Model/IBuilder.cs +++ b/src/SteroidsDI.Tests/Model/IBuilder.cs @@ -19,3 +19,8 @@ internal class SpecialBuilderOver9000Level : IBuilder { public void Build() => Console.WriteLine("!!!!!!!SpecialBuilderOver9000Level!!!!!!!"); } + +internal class DefaultBuilder : IBuilder +{ + public void Build() => Console.WriteLine("Default"); +} diff --git a/src/SteroidsDI.Tests/ServicesBuilder.cs b/src/SteroidsDI.Tests/ServicesBuilder.cs index a0f1dbc..bf0b9bd 100644 --- a/src/SteroidsDI.Tests/ServicesBuilder.cs +++ b/src/SteroidsDI.Tests/ServicesBuilder.cs @@ -5,7 +5,7 @@ namespace SteroidsDI.Tests; internal class ServicesBuilder { - public static IServiceCollection BuildDefault(bool addScopeProvider = true) + public static IServiceCollection BuildDefault(bool addScopeProvider = true, bool addDefalt = false) { var services = new ServiceCollection() .AddDefer() @@ -19,14 +19,17 @@ public static IServiceCollection BuildDefault(bool addScopeProvider = true) .AddFactory() .AddFactory>() .AddTransient() - .AddSingleton() - .For() - .Named("xxx") - .Named("yyy") - .Named("oops", ServiceLifetime.Singleton) - .Named(ManagerType.Good) - .Named(ManagerType.Bad, ServiceLifetime.Singleton) - .Services; + .AddSingleton(); + + var context = services.For() + .Named("xxx") + .Named("yyy") + .Named("oops", ServiceLifetime.Singleton) + .Named(ManagerType.Good) + .Named(ManagerType.Bad, ServiceLifetime.Singleton); + + if (addDefalt) + context.Default(ServiceLifetime.Singleton); if (addScopeProvider) services.AddSingleton>(); diff --git a/src/SteroidsDI/Factory/BindingContext.cs b/src/SteroidsDI/Factory/BindingContext.cs index 78a2ea8..efc4c5e 100644 --- a/src/SteroidsDI/Factory/BindingContext.cs +++ b/src/SteroidsDI/Factory/BindingContext.cs @@ -33,7 +33,7 @@ public BindingContext(IServiceCollection services) /// /// The lifetime of the dependency object. /// Reference to this to be able to call methods in a chain. - public BindingContext Named(object name, ServiceLifetime lifetime = ServiceLifetime.Transient) + public BindingContext Named(object name, ServiceLifetime lifetime) where TImplementation : TService { if (name == null) @@ -56,7 +56,7 @@ public BindingContext Named(object name, ServiceLifet /// /// Registers a named binding from type to type - /// with a lifetime equal to the lifetime of the object from the default binding. + /// with a lifetime equal to the lifetime of . /// /// Implementation type. /// @@ -68,10 +68,59 @@ public BindingContext Named(object name) where TImplementation : TService => Named(name, GetServiceLifetime()); + /// + /// Registers a default binding from type + /// to type . Default binding is such + /// a binding used in the absence of a named one. A user should set default + /// binding explicitly to be able to resolve services for unregistered names. + /// + /// Implementation type. + /// The lifetime of the dependency object. + /// Reference to this to be able to call methods in a chain. + public BindingContext Default(ServiceLifetime lifetime) + where TImplementation : TService + { + var existing = Services.SingleOrDefault(descriptor => descriptor.ServiceType == typeof(TImplementation)); + if (existing != null && (existing.ImplementationType != typeof(TImplementation) || existing.Lifetime != lifetime || existing.ImplementationFactory != null || existing.ImplementationInstance != null)) + { + throw new InvalidOperationException($@"It is not possible to add a default binding for type {typeof(TService)}, because the DI container +already has a binding on type {typeof(TImplementation)} with different characteristics. This is a limitation of the current implementation."); + } + + if (existing == null) + Services.Add(new ServiceDescriptor(typeof(TImplementation), typeof(TImplementation), lifetime)); + + var def = new NamedBinding(NamedBinding.DefaultName, typeof(TService), typeof(TImplementation)); + var descriptor = ServiceDescriptor.Singleton(def); + for (int i = 0; i < Services.Count; ++i) + { + if (Services[i].ImplementationInstance is NamedBinding named && named.Name == NamedBinding.DefaultName && named.ServiceType == typeof(TService)) + { + Services[i] = descriptor; + return this; + } + } + + Services.Add(descriptor); + + return this; + } + + /// + /// Registers a default binding from type to type + /// with a lifetime equal to the lifetime of the object from the default binding. Default binding is such a binding used in + /// the absence of a named one. A user should set default binding explicitly to be able to resolve services for unregistered names. + /// + /// Implementation type. + /// Reference to this to be able to call methods in a chain. + public BindingContext Default() + where TImplementation : TService + => Default(GetServiceLifetime()); + private ServiceLifetime GetServiceLifetime() { return Services.FirstOrDefault(s => s.ServiceType == typeof(TService))?.Lifetime - ?? throw new InvalidOperationException($@"The DI container does not have a default binding for the type '{typeof(TService)}', so it is not possible to determine the value of Lifetime. -Use the 'Named' overload with explicit Lifetime or first set the default binding in the DI container."); + ?? throw new InvalidOperationException($@"The DI container does not register type '{typeof(TService)}', so it is not possible to determine the value of Lifetime. +Use the 'Named'/'Default' overloads with explicit Lifetime or first register '{typeof(TService)}' in the DI container."); } } diff --git a/src/SteroidsDI/Factory/NamedBinding.cs b/src/SteroidsDI/Factory/NamedBinding.cs index 7ef9697..4a1ea74 100644 --- a/src/SteroidsDI/Factory/NamedBinding.cs +++ b/src/SteroidsDI/Factory/NamedBinding.cs @@ -9,6 +9,9 @@ namespace SteroidsDI; [DebuggerDisplay("{Name}: {ServiceType.Name} -> {ImplementationType.Name}")] internal sealed class NamedBinding { + /// Name for default bindings. + internal static readonly object DefaultName = new(); + public NamedBinding(object name, Type serviceType, Type implementationType) { Name = name; @@ -16,7 +19,12 @@ public NamedBinding(object name, Type serviceType, Type implementationType) ImplementationType = implementationType; } - /// Gets the name of the binding. An arbitrary object, not just a string. + /// + /// Gets the name of the binding. An arbitrary object, not just a string. + /// in case of default binding. Default binding is + /// such a binding used in the absence of a named one. A user should set + /// default binding explicitly to be able to resolve services for unregistered names. + /// public object Name { get; } /// Gets the service type. diff --git a/src/SteroidsDI/Resolver.cs b/src/SteroidsDI/Resolver.cs index 2f36cd8..be99b67 100644 --- a/src/SteroidsDI/Resolver.cs +++ b/src/SteroidsDI/Resolver.cs @@ -5,12 +5,33 @@ namespace SteroidsDI; internal static class Resolver { - internal static TService ResolveByNamedBinding(this IServiceProvider provider, object name, IEnumerable bindings, ServiceProviderAdvancedOptions options) + internal static TService ResolveByNamedBinding(this IServiceProvider provider, object name, List bindings, ServiceProviderAdvancedOptions options) { - var binding = bindings.Where(b => b.ServiceType == typeof(TService)).SingleOrDefault(b => b.Name.Equals(name)); + var binding = FindBinding(name, bindings); + return binding == null - ? throw new InvalidOperationException($"Destination type not found for named binding '{name}' to type '{typeof(TService)}'. Verify that a named binding is specified in the DI container.") + ? throw new InvalidOperationException($"Destination type not found for named binding '{name}' to type '{typeof(TService)}' and no default binding exists. Verify that either a named binding or default binding is specified in the DI container.") : (TService)provider.Resolve(binding.ImplementationType, options); + + // Look for particular named binding or default binding. + static NamedBinding? FindBinding(object name, List bindings) + { + NamedBinding? binding = null; + + foreach (var namedBinding in bindings) + { + if (namedBinding.ServiceType != typeof(TService)) + continue; + + if (namedBinding.Name.Equals(name)) + return namedBinding; + + if (namedBinding.Name.Equals(NamedBinding.DefaultName)) + binding = namedBinding; + } + + return binding; + } } internal static TService Resolve(this IServiceProvider rootProvider, ServiceProviderAdvancedOptions options)