From 17f586cf57e495290ab9ca3880990b6437d10cbe Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Thu, 8 Oct 2026 16:34:40 +0200 Subject: [PATCH 1/3] test: reproduce registered null dependency activation (#110) Co-authored-by: Codex --- ...nsions.KeyedService.NullFactories.Tests.cs | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedService.NullFactories.Tests.cs diff --git a/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedService.NullFactories.Tests.cs b/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedService.NullFactories.Tests.cs new file mode 100644 index 0000000..d55be45 --- /dev/null +++ b/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedService.NullFactories.Tests.cs @@ -0,0 +1,185 @@ +using Mammoth.Extensions.DependencyInjection.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Mammoth.Extensions.DependencyInjection.Tests; + +[TestClass] +public class NullFactoryDependencyRegressionTests +{ + public static IEnumerable Cases() + { + foreach (var kind in new[] { "native", "snapshot", "diagnostics" }) + foreach (var mode in new[] { "ordinary", "explicit", "null-key", "inherit", "map" }) + foreach (var behavior in new[] { "null", "missing-optional", "missing-required", "throw" }) + foreach (var lifetime in new[] { ServiceLifetime.Singleton, ServiceLifetime.Scoped, ServiceLifetime.Transient }) + if ((behavior == "null" || lifetime == ServiceLifetime.Transient) + && (behavior != "missing-optional" || mode != "map")) + yield return [kind, mode, behavior, lifetime]; + } + + [TestMethod] + [DynamicData(nameof(Cases))] + public void MappedActivationPreservesRegisteredNullDefaultsAndExceptions( + string kind, string mode, string behavior, ServiceLifetime lifetime) + { + var error = new InvalidOperationException("Factory failure"); + var nativeCalls = new int[3]; + var mappedCalls = new int[3]; + using var native = Services(mode, behavior, lifetime, nativeCalls, error, false).BuildServiceProvider(); + using var mapped = Build(Services(mode, behavior, lifetime, mappedCalls, error, true), kind); + for (var scopeIndex = 0; scopeIndex < 2; scopeIndex++) + { + using var nativeScope = native.CreateScope(); + using var mappedScope = mapped.CreateScope(); + for (var repeat = 0; repeat < 2; repeat++) + { + if (behavior is "throw" or "missing-required") + { + var nativeError = Assert.ThrowsExactly(() => Resolve(nativeScope.ServiceProvider)); + var mappedError = Assert.ThrowsExactly(() => Resolve(mappedScope.ServiceProvider)); + if (behavior == "throw") + { + Assert.AreSame(error, nativeError); + Assert.AreSame(error, mappedError); + } + } + else + { + var nativeResult = Resolve(nativeScope.ServiceProvider); + var mappedResult = Resolve(mappedScope.ServiceProvider); + Assert.IsNull(nativeResult.Part); + Assert.IsNull(mappedResult.Part); + Assert.AreEqual(behavior == "missing-optional" ? "fallback" : null, nativeResult.Text); + Assert.AreEqual(behavior == "missing-optional" ? (int?)42 : null, nativeResult.Number); + Assert.AreEqual(nativeResult.Text, mappedResult.Text); + Assert.AreEqual(nativeResult.Number, mappedResult.Number); + Assert.IsTrue(mappedResult.Label); + } + // Compare actual native factory calls, including null caching across scopes. + CollectionAssert.AreEqual(nativeCalls, mappedCalls); + if (behavior == "missing-required") CollectionAssert.AreEqual(new int[3], mappedCalls); + } + } + + Values Resolve(IServiceProvider provider) => mode == "inherit" + ? provider.GetRequiredKeyedService("blue") : provider.GetRequiredService(); + } + + private static IServiceCollection Services(string mode, string behavior, ServiceLifetime lifetime, + int[] calls, Exception error, bool mapped) + { + IServiceCollection services = new ServiceCollection(); + services.AddSingleton(typeof(bool), true); + var key = mode is "explicit" or "map" ? "part" : mode == "inherit" ? "blue" : null; + foreach (var pair in new[] { (typeof(Part), 0), (typeof(string), 1), (typeof(int?), 2) }) + { + if (behavior == "missing-required" && pair.Item2 == 0) continue; + if (behavior == "missing-optional" && pair.Item2 != 0) continue; + object Factory() + { + calls[pair.Item2]++; + if (behavior == "throw") throw error; + return null!; + } + services.Add(key == null ? ServiceDescriptor.Describe(pair.Item1, _ => Factory(), lifetime) + : ServiceDescriptor.DescribeKeyed(pair.Item1, key, (_, _) => Factory(), lifetime)); + } + // The unrelated label map triggers Mammoth activation. Native controls use the + // same parameter keys; named maps use the equivalent explicit-key constructor. + var target = mode switch + { + "explicit" => typeof(ExplicitValues), "null-key" => typeof(NullKeyValues), + "inherit" => typeof(InheritedValues), "map" when !mapped => typeof(ExplicitValues), + _ => typeof(OrdinaryValues) + }; + Dependency[] map = mode == "map" + ? [Dependency.OnValue("label", true), Parameter.ForKey("part").Eq("part"), + Parameter.ForKey("text").Eq("part"), Parameter.ForKey("number").Eq("part")] + : [Dependency.OnValue("label", true)]; + if (mode == "inherit") + { + if (mapped) services.AddKeyedTransient("blue", map); + else services.AddKeyedTransient("blue"); + } + else if (mapped) services.AddTransient(typeof(Values), target, map); + else services.AddTransient(typeof(Values), target); + return services; + } + + public static IEnumerable DecoratorCases() + { + foreach (var kind in new[] { "native", "snapshot", "diagnostics" }) + foreach (var mode in new[] { "explicit", "null-key", "inherit" }) + foreach (var lifetime in new[] { ServiceLifetime.Singleton, ServiceLifetime.Scoped, ServiceLifetime.Transient }) + yield return [kind, mode, lifetime]; + } + + [TestMethod] + [DynamicData(nameof(DecoratorCases))] + public void ContextualDecoratorsReceiveRegisteredNullInsteadOfOptionalDefaults( + string kind, string mode, ServiceLifetime lifetime) + { + var calls = new int[3]; + var services = Services(mode, "null", lifetime, calls, new InvalidOperationException(), false); + services.AddKeyedSingleton("blue", new Work()); + if (mode == "explicit") services.Decorate(); + else if (mode == "null-key") services.Decorate(); + else services.Decorate(); + using var provider = Build(services, kind); + using var scope = provider.CreateScope(); + var result = (Decorator)scope.ServiceProvider.GetRequiredKeyedService("blue"); + Assert.IsInstanceOfType(result.Inner); + Assert.IsNull(result.Part); + Assert.IsNull(result.Text); + Assert.IsNull(result.Number); + Assert.AreEqual("blue", result.Key); + CollectionAssert.AreEqual(new[] { 1, 1, 1 }, calls); + } + + private static ServiceProvider Build(IServiceCollection services, string kind) => kind switch + { + "snapshot" => ServiceProviderFactory.CreateServiceProvider(services), + "diagnostics" => ServiceProviderFactory.CreateServiceProvider(services, + new ExtendedServiceProviderOptions { ValidateOnBuild = true, DetectIncorrectUsageOfTransientDisposables = true }), + _ => services.BuildServiceProvider() + }; + + public sealed class Part; + public abstract class Values(bool label, Part? part, string? text, int? number) + { + public bool Label { get; } = label; + public Part? Part { get; } = part; + public string? Text { get; } = text; + public int? Number { get; } = number; + } + public sealed class OrdinaryValues(bool label, Part? part, string? text = "fallback", int? number = 42) + : Values(label, part, text, number); + public sealed class ExplicitValues(bool label, [FromKeyedServices("part")] Part? part, + [FromKeyedServices("part")] string? text = "fallback", [FromKeyedServices("part")] int? number = 42) + : Values(label, part, text, number); + public sealed class NullKeyValues(bool label, [FromKeyedServices(null)] Part? part, + [FromKeyedServices(null)] string? text = "fallback", [FromKeyedServices(null)] int? number = 42) + : Values(label, part, text, number); + public sealed class InheritedValues(bool label, [FromKeyedServices] Part? part, + [FromKeyedServices] string? text = "fallback", [FromKeyedServices] int? number = 42) + : Values(label, part, text, number); + public interface IWork; + public sealed class Work : IWork; + public abstract class Decorator(IWork inner, object key, Part? part, string? text, int? number) : IWork + { + public IWork Inner { get; } = inner; + public object Key { get; } = key; + public Part? Part { get; } = part; + public string? Text { get; } = text; + public int? Number { get; } = number; + } + public sealed class ExplicitDecorator(IWork inner, [ServiceKey] object key, + [FromKeyedServices("part")] Part? part, [FromKeyedServices("part")] string? text = "fallback", + [FromKeyedServices("part")] int? number = 42) : Decorator(inner, key, part, text, number); + public sealed class NullKeyDecorator(IWork inner, [ServiceKey] object key, + [FromKeyedServices(null)] Part? part, [FromKeyedServices(null)] string? text = "fallback", + [FromKeyedServices(null)] int? number = 42) : Decorator(inner, key, part, text, number); + public sealed class InheritedDecorator(IWork inner, [ServiceKey] object key, + [FromKeyedServices] Part? part, [FromKeyedServices] string? text = "fallback", + [FromKeyedServices] int? number = 42) : Decorator(inner, key, part, text, number); +} From 38d7ab2d281ffe3306c939a4f2afcd352193bb45 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Thu, 8 Oct 2026 16:39:26 +0200 Subject: [PATCH 2/3] test: account for native singleton null factory caching Co-authored-by: Codex --- ...tionExtensions.KeyedService.NullFactories.Tests.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedService.NullFactories.Tests.cs b/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedService.NullFactories.Tests.cs index d55be45..dd82754 100644 --- a/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedService.NullFactories.Tests.cs +++ b/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedService.NullFactories.Tests.cs @@ -55,8 +55,15 @@ public void MappedActivationPreservesRegisteredNullDefaultsAndExceptions( Assert.AreEqual(nativeResult.Number, mappedResult.Number); Assert.IsTrue(mappedResult.Label); } - // Compare actual native factory calls, including null caching across scopes. - CollectionAssert.AreEqual(nativeCalls, mappedCalls); + // Native constructor call sites can retry a null singleton factory; + // public GetService caches its result in the singleton accessor. Mammoth + // retains that public lookup behavior. Scoped/transient calls agree. + if (lifetime != ServiceLifetime.Singleton) CollectionAssert.AreEqual(nativeCalls, mappedCalls); + else + { + CollectionAssert.AreEqual(new[] { 1, 1, 1 }, mappedCalls); + foreach (var count in nativeCalls) Assert.IsGreaterThanOrEqualTo(1, count); + } if (behavior == "missing-required") CollectionAssert.AreEqual(new int[3], mappedCalls); } } From f5e4c5351416db4fe446c568d50088830ebf48d9 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Thu, 8 Oct 2026 16:39:27 +0200 Subject: [PATCH 3/3] fix: preserve registered null constructor dependencies (#110) Co-authored-by: Codex --- Changelog.md | 2 ++ .../ConstructorActivator.cs | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Changelog.md b/Changelog.md index 112dd18..4575e62 100644 --- a/Changelog.md +++ b/Changelog.md @@ -10,6 +10,8 @@ ### Bug Fixes +- Preserve registered factory null results in mapped and contextual keyed constructor dependencies, including named key maps and decoration. Apply optional defaults only to missing registrations while retaining missing-required failures and original factory exceptions [#110](https://github.com/PrimordialCode/Mammoth.Extensions.DependencyInjection/issues/110). + - Keep keyed decorator inner binding separate from ServiceKey and FromKeyedServices parameters, preserving explicit, inherited and null-key dependency lookup independently of parameter order, repeated layers, native lifetimes and disposal ownership [#109](https://github.com/PrimordialCode/Mammoth.Extensions.DependencyInjection/issues/109). - Check explicit registrations for keyed built-in constructor dependencies instead of treating native DI's implicit unkeyed services as available under every key. Preserve native-provider support, optional defaults, named and inherited keys, and explicit or AnyKey registrations without activating rejected dependencies [#108](https://github.com/PrimordialCode/Mammoth.Extensions.DependencyInjection/issues/108). diff --git a/src/Mammoth.Extensions.DependencyInjection/ConstructorActivator.cs b/src/Mammoth.Extensions.DependencyInjection/ConstructorActivator.cs index 362c39a..37f2b6f 100644 --- a/src/Mammoth.Extensions.DependencyInjection/ConstructorActivator.cs +++ b/src/Mammoth.Extensions.DependencyInjection/ConstructorActivator.cs @@ -163,18 +163,21 @@ bool IsKeyedRegistered(Type type, object? key) object? Resolve(ParameterMetadata parameter) { + // Selection already checked registration availability. Preserve a factory's + // null result, as native constructor activation does; optional defaults apply + // only to missing registrations, and factory exceptions still propagate. var dependency = FindDependency(map, parameter.Name); if (dependency != null) return dependency.T == Dependency.DependencyType.KeyedServices - ? keyed.GetRequiredKeyedService(parameter.ParameterType, dependency.Value) + ? keyed.GetKeyedService(parameter.ParameterType, dependency.Value) : dependency.Value; if (parameter.IsServiceKey) return serviceKey; if (!IsRegistered(parameter) && parameter.HasDefaultValue) return parameter.DefaultValue; var key = EffectiveKey(parameter.FromKey, serviceKey); return key != null - ? keyed.GetRequiredKeyedService(parameter.ParameterType, key) - : provider.GetRequiredService(parameter.ParameterType); + ? keyed.GetKeyedService(parameter.ParameterType, key) + : provider.GetService(parameter.ParameterType); } }