From 1d51bd70128f8dc50b2e0e29183a7c5ac5bc65cf Mon Sep 17 00:00:00 2001 From: Alexander Larsen Date: Fri, 1 May 2026 16:06:52 +0200 Subject: [PATCH 1/8] Fix inherited target qualifiers and ambiguous binding validation. - Fixed `ToTarget` matching so bindings targeted at a base component type also apply to derived component targets. - Updated binding conflict detection to catch duplicate or ambiguous qualifier combinations that can resolve the same injection site. - Added binding equality coverage for ID-only and target-only qualifier ambiguity. - Updated binding qualifier and uniqueness documentation to describe inheritance-aware target matching and ambiguous binding conflicts. - Added changelog notes for version 1.0.6. --- CHANGELOG.md | 7 + Docs/docs/core-concepts/binding.md | 46 ++++--- .../Saneject/Editor/Core/BindingValidator.cs | 2 +- .../Plugins/Saneject/Editor/Core/Resolver.cs | 127 +++++++++++++----- .../Editor/Data/Graph/Nodes/BindingNode.cs | 48 ++++--- .../Editor/Data/Graph/Nodes/MemberNode.cs | 2 - .../Extensions/EnumerableOverlapExtensions.cs | 14 +- .../Equality/BindingNodeEqualityTests.cs | 31 ++++- .../Editor/Graph/InjectionGraphMemberTests.cs | 11 -- 9 files changed, 205 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63b8516f..1d144618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Version 1.0.6 + +### Fixes + +- Fixed `ToTarget` matching for inherited component targets, so target qualifiers now match derived component types. +- Fixed binding validation for duplicate or ambiguous qualifier combinations that could resolve the same injection site. + ## Version 1.0.5 ### Fixes diff --git a/Docs/docs/core-concepts/binding.md b/Docs/docs/core-concepts/binding.md index c5ef35cc..e6036ad1 100644 --- a/Docs/docs/core-concepts/binding.md +++ b/Docs/docs/core-concepts/binding.md @@ -167,16 +167,17 @@ Full runtime proxy API: Binding qualifiers restrict which injection sites (fields, properties, methods) can be resolved from a binding. -| Qualifier | Injection site match | -|------------------------------|---------------------------------------------------------------| -| `ToID("someId")` | Fields, properties, methods marked with `[Inject("someId")]` | -| `ToTarget()` | Fields, properties, methods declared in a `TTarget` component | -| `ToMember("someMemberName")` | Fields, properties, methods with name `"someMemberName"` | +| Qualifier | Injection site match | +|------------------------------|---------------------------------------------------------------------------------| +| `ToID("someId")` | Fields, properties, methods marked with `[Inject("someId")]` | +| `ToTarget()` | Fields, properties, methods on `TTarget` components and derived component types | +| `ToMember("someMemberName")` | Fields, properties, methods with name `"someMemberName"` | Important behavior: - Binding qualifiers are additive, so all specified qualifiers must match. - If a binding qualifier is not set on the binding, the binding matches by `TInterface` or `TConcrete` only. +- `ToTarget()` matches the actual injection target component type. A binding targeted to a base component type also matches derived component types. - Binding qualifiers apply to component, asset, and runtime proxy bindings. - Binding qualifiers do not apply to global bindings. @@ -223,18 +224,18 @@ protected override void DeclareBindings() } ``` -| Binding Family | Filter Support | -|--------------------------------------------------------------------------|--------------------------------------------------------------------------------------------| -| Component bindings | Yes | -| Asset bindings | Yes | -| Global bindings | Yes (same filter API as component bindings). | -| Runtime proxy bindings | No | +| Binding Family | Filter Support | +|------------------------|----------------------------------------------| +| Component bindings | Yes | +| Asset bindings | Yes | +| Global bindings | Yes (same filter API as component bindings). | +| Runtime proxy bindings | No | If a binding filter throws an exception, Saneject logs a binding filter error for that binding. ## Binding uniqueness -Saneject enforces binding unique within each `Scope`. When a binding is considered duplicate, Saneject logs an error and excludes the duplicate from the injection run. +Saneject enforces unambiguous bindings within each `Scope`. When two bindings are considered duplicate or ambiguous, Saneject logs an error and excludes the conflicting binding from the injection run. Duplicate checks use these criteria: @@ -244,14 +245,16 @@ Duplicate checks use these criteria: - `TInterface` when present. - Otherwise `TConcrete`. 4. Same single/collection shape. -5. Qualifier overlap: - - If both bindings have no binding qualifiers at all, they conflict. - - Otherwise, conflict requires full overlap in `ToTarget`, `ToMember`, and `ToID` simultaneously. +5. Qualifier ambiguity: + - If neither binding has qualifiers that separate it from the other, they conflict. + - Empty qualifier sets are unrestricted and do not separate bindings. + - `ToTarget` qualifiers overlap when their target component type hierarchies overlap. + - `ToMember` and `ToID` qualifiers separate bindings only when both bindings specify non-overlapping values. Examples: ```csharp -// Duplicate: same scope, same family, same type, same shape, no qualifiers. +// Duplicate or ambiguous: same scope, same family, same type, same shape, no qualifiers. BindComponent() .FromScopeSelf(); @@ -274,6 +277,17 @@ BindAsset() .FromResources("Configs/Gameplay"); ``` +```csharp +// Ambiguous: both bindings can resolve [Inject("menu")] members on MainMenuController. +BindAsset() + .ToID("menu") + .FromResources("Configs/Menu"); + +BindAsset() + .ToTarget() + .FromResources("Configs/Menu"); +``` + Global bindings have an extra rule: only one global binding per concrete component type is allowed across active bindings across all scopes. A second `BindGlobal()` is invalid even if declared in another scope. diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/BindingValidator.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/BindingValidator.cs index d4a0ede7..91931927 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/BindingValidator.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/BindingValidator.cs @@ -43,7 +43,7 @@ private static void ValidateBinding( errors.Add(new InvalidBindingError ( bindingNode: bindingNode, - reason: "Duplicate binding within same Scope detected" + reason: "Duplicate or ambiguous binding within same Scope detected" )); switch (bindingNode) diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs index 65d8fd75..839b8590 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs @@ -7,6 +7,7 @@ using Plugins.Saneject.Editor.Data.Graph; using Plugins.Saneject.Editor.Data.Graph.Nodes; using Plugins.Saneject.Editor.Data.Injection; +using UnityEngine; using Object = UnityEngine.Object; namespace Plugins.Saneject.Editor.Core @@ -18,9 +19,23 @@ public static void Resolve( InjectionContext context, InjectionProgressTracker progressTracker) { - ResolveGlobals(context, progressTracker); - ResolveFields(context, progressTracker); - ResolveMethods(context, progressTracker); + ResolveGlobals + ( + context, + progressTracker + ); + + ResolveFields + ( + context, + progressTracker + ); + + ResolveMethods + ( + context, + progressTracker + ); } private static void ResolveGlobals( @@ -49,16 +64,28 @@ out HashSet rejectedTypes ); if (candidates is not { Length: > 0 }) - context.RegisterError(new MissingGlobalDependencyError + context.RegisterError ( - binding, - rejectedTypes - )); + new MissingGlobalDependencyError + ( + binding, + rejectedTypes + ) + ); - if (!globalMap.TryGetValue(binding.ScopeNode, out HashSet dependencySet)) + if (!globalMap.TryGetValue + ( + binding.ScopeNode, + out HashSet dependencySet + )) { dependencySet = new HashSet(); - globalMap.Add(binding.ScopeNode, dependencySet); + + globalMap.Add + ( + binding.ScopeNode, + dependencySet + ); } object resolved = candidates.FirstOrDefault(); @@ -68,7 +95,11 @@ out HashSet rejectedTypes } foreach ((ScopeNode scopeNode, HashSet dependencies) in globalMap) - context.RegisterGlobalDependencies(scopeNode, dependencies); + context.RegisterGlobalDependencies + ( + scopeNode, + dependencies + ); } private static void ResolveFields( @@ -85,7 +116,13 @@ private static void ResolveFields( foreach (FieldNode fieldNode in fieldNodes) { progressTracker.UpdateInfoText($"Resolving field: {fieldNode.ShortPath}"); - ResolveField(fieldNode, context); + + ResolveField + ( + fieldNode, + context + ); + progressTracker.NextStep(); } } @@ -104,7 +141,13 @@ private static void ResolveMethods( foreach (MethodNode methodNode in methodNodes) { progressTracker.UpdateInfoText($"Resolving method: {methodNode.ShortPath}"); - ResolveMethod(methodNode, context); + + ResolveMethod + ( + methodNode, + context + ); + progressTracker.NextStep(); } } @@ -117,7 +160,7 @@ private static void ResolveField( ( currentScope: fieldNode.ComponentNode.TransformNode.NearestScopeNode, context: context, - declaringType: fieldNode.DeclaringType, + componentType: fieldNode.ComponentNode.Component.GetType(), requestedType: fieldNode.RequestedType, isCollection: fieldNode.IsCollection, qualifyingMemberName: fieldNode.QualifyingName, @@ -144,12 +187,15 @@ out HashSet rejectedTypes ); if (candidates is not { Length: > 0 }) - context.RegisterError(new MissingDependencyError + context.RegisterError ( - bindingNode, - fieldNode, - rejectedTypes - )); + new MissingDependencyError + ( + bindingNode, + fieldNode, + rejectedTypes + ) + ); resolved = ResolveCandidates ( @@ -162,7 +208,11 @@ out HashSet rejectedTypes context.RegisterUsedBinding(bindingNode); } - context.RegisterFieldDependency(fieldNode, resolved); + context.RegisterFieldDependency + ( + fieldNode, + resolved + ); } private static void ResolveMethod( @@ -177,7 +227,7 @@ private static void ResolveMethod( ( currentScope: methodNode.ComponentNode.TransformNode.NearestScopeNode, context: context, - declaringType: methodNode.DeclaringType, + componentType: methodNode.ComponentNode.Component.GetType(), requestedType: parameterNode.RequestedType, isCollection: parameterNode.IsCollection, qualifyingMemberName: methodNode.QualifyingName, @@ -204,12 +254,15 @@ out HashSet rejectedTypes ); if (candidates is not { Length: > 0 }) - context.RegisterError(new MissingDependencyError + context.RegisterError ( - bindingNode, - parameterNode, - rejectedTypes - )); + new MissingDependencyError + ( + bindingNode, + parameterNode, + rejectedTypes + ) + ); resolved = ResolveCandidates ( @@ -224,13 +277,17 @@ out HashSet rejectedTypes context.RegisterUsedBinding(bindingNode); } - context.RegisterMethodDependencies(methodNode, resolvedParameters); + context.RegisterMethodDependencies + ( + methodNode, + resolvedParameters + ); } private static BindingNode FindMatchingBindingNode( ScopeNode currentScope, InjectionContext context, - Type declaringType, + Type componentType, Type requestedType, bool isCollection, string qualifyingMemberName, @@ -275,9 +332,9 @@ bool MatchesIsCollection(BindingNode bindingNode) } bool MatchesTargetTypeQualifiers(BindingNode bindingNode) - { + { return bindingNode.TargetTypeQualifiers.Count == 0 || - bindingNode.TargetTypeQualifiers.Contains(declaringType); + bindingNode.TargetTypeQualifiers.Any(t => t.IsAssignableFrom(componentType)); } bool MatchesMemberNameQualifiers(BindingNode bindingNode) @@ -311,10 +368,18 @@ private static object ResolveCandidates( case TypeShape.Array: { - Array array = Array.CreateInstance(requestedType, candidates.Length); + Array array = Array.CreateInstance + ( + requestedType, + candidates.Length + ); for (int i = 0; i < candidates.Length; i++) - array.SetValue(candidates[i], i); + array.SetValue + ( + candidates[i], + i + ); return array; } diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs index 32e6bb1b..7da8a6b2 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs @@ -47,32 +47,49 @@ public bool Equals(BindingNode other) if (other is null) return false; - if (ReferenceEquals(this, other)) + if (ReferenceEquals + ( + this, + other + )) return true; - if (!Equals(ScopeNode, other.ScopeNode)) + if (!Equals + ( + ScopeNode, + other.ScopeNode + )) return false; if (other.GetType() != GetType()) return false; - if (InterfaceType != null ? InterfaceType != other.InterfaceType : ConcreteType != other.ConcreteType) + if (InterfaceType != null + ? InterfaceType != other.InterfaceType + : ConcreteType != other.ConcreteType) return false; if (IsCollectionBinding != other.IsCollectionBinding) return false; - if (TargetTypeQualifiers.Count == 0 - && MemberNameQualifiers.Count == 0 - && IdQualifiers.Count == 0 - && other.TargetTypeQualifiers.Count == 0 - && other.MemberNameQualifiers.Count == 0 - && other.IdQualifiers.Count == 0) - return true; + bool separatedByTarget = + TargetTypeQualifiers.Count > 0 && + other.TargetTypeQualifiers.Count > 0 && + !TargetTypeQualifiers.OverlapsWith(other.TargetTypeQualifiers); + + bool separatedByMemberName = + MemberNameQualifiers.Count > 0 && + other.MemberNameQualifiers.Count > 0 && + !MemberNameQualifiers.OverlapsWith(other.MemberNameQualifiers); + + bool separatedById = + IdQualifiers.Count > 0 && + other.IdQualifiers.Count > 0 && + !IdQualifiers.OverlapsWith(other.IdQualifiers); - return TargetTypeQualifiers.OverlapsWith(other.TargetTypeQualifiers) - && MemberNameQualifiers.OverlapsWith(other.MemberNameQualifiers) - && IdQualifiers.OverlapsWith(other.IdQualifiers); + return !separatedByTarget + && !separatedByMemberName + && !separatedById; } public override bool Equals(object obj) @@ -87,10 +104,7 @@ public override int GetHashCode() ScopeNode, GetType(), InterfaceType ?? ConcreteType, - IsCollectionBinding, - TargetTypeQualifiers.Count > 0, - MemberNameQualifiers.Count > 0, - IdQualifiers.Count > 0 + IsCollectionBinding ); } } diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/MemberNode.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/MemberNode.cs index 91b636c4..54e45616 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/MemberNode.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/MemberNode.cs @@ -19,7 +19,6 @@ protected MemberNode( { Owner = owner; ComponentNode = componentNode; - DeclaringType = memberInfo.DeclaringType; QualifyingName = NameUtility.GetLogicalName(memberInfo.Name); InjectId = injectAttribute.ID; SuppressMissingErrors = injectAttribute.SuppressMissingErrors; @@ -29,7 +28,6 @@ protected MemberNode( public object Owner { get; } public ComponentNode ComponentNode { get; } - public Type DeclaringType { get; } public string QualifyingName { get; } public string InjectId { get; } public bool SuppressMissingErrors { get; } diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Extensions/EnumerableOverlapExtensions.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Extensions/EnumerableOverlapExtensions.cs index 324c4489..825cb8e9 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Extensions/EnumerableOverlapExtensions.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Extensions/EnumerableOverlapExtensions.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; + // ReSharper disable ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator namespace Plugins.Saneject.Editor.Extensions @@ -28,8 +29,17 @@ public static bool OverlapsWith( this IEnumerable a, IEnumerable b) { - HashSet aSet = new(a.Where(s => !string.IsNullOrWhiteSpace(s)), StringComparer.Ordinal); - HashSet bSet = new(b.Where(s => !string.IsNullOrWhiteSpace(s)), StringComparer.Ordinal); + HashSet aSet = new + ( + a.Where(s => !string.IsNullOrWhiteSpace(s)), + StringComparer.Ordinal + ); + + HashSet bSet = new + ( + b.Where(s => !string.IsNullOrWhiteSpace(s)), + StringComparer.Ordinal + ); foreach (string id in aSet) if (bSet.Contains(id)) diff --git a/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Equality/BindingNodeEqualityTests.cs b/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Equality/BindingNodeEqualityTests.cs index 077d1863..c5ef8b97 100644 --- a/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Equality/BindingNodeEqualityTests.cs +++ b/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Equality/BindingNodeEqualityTests.cs @@ -161,7 +161,7 @@ public void BindingNode_SameScope_TConcrete_WithOverlappingQualifiers_IsEqual() } [Test] - public void BindingNode_SameScope_TConcrete_WithOnlyIDQualifiers_IsNotEqual() + public void BindingNode_SameScope_TConcrete_WithOnlyIDQualifiers_IsEqual() { // Set up scene TestScene scene = TestScene.Create(roots: 1, width: 1, depth: 1); @@ -182,7 +182,32 @@ public void BindingNode_SameScope_TConcrete_WithOnlyIDQualifiers_IsNotEqual() BindingNode secondBinding = scopeNode.BindingNodes[1]; // Assert - Assert.That(firstBinding, Is.Not.EqualTo(secondBinding)); + Assert.That(firstBinding, Is.EqualTo(secondBinding)); + } + + [Test] + public void BindingNode_SameScope_TConcrete_WithIDAndTargetQualifiers_IsEqual() + { + // Set up scene + TestScene scene = TestScene.Create(roots: 1, width: 1, depth: 1); + TestScope scope = scene.Add("Root 1"); + + // Bind + scope.BindComponent() + .ToID("qualified") + .FromSelf(); + + scope.BindComponent() + .ToTarget() + .FromAnywhere(); + + // Build graph and fetch binding nodes + ScopeNode scopeNode = CreateScopeNode(scene, "Root 1"); + BindingNode firstBinding = scopeNode.BindingNodes[0]; + BindingNode secondBinding = scopeNode.BindingNodes[1]; + + // Assert + Assert.That(firstBinding, Is.EqualTo(secondBinding)); } [Test] @@ -311,4 +336,4 @@ private static ScopeNode CreateScopeNode( return scopeNode; } } -} \ No newline at end of file +} diff --git a/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Graph/InjectionGraphMemberTests.cs b/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Graph/InjectionGraphMemberTests.cs index 6983ee86..f8410c8a 100644 --- a/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Graph/InjectionGraphMemberTests.cs +++ b/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Graph/InjectionGraphMemberTests.cs @@ -47,7 +47,6 @@ public void FieldNode_GivenTopLevelFieldAndPropertyTargets_CapturesFieldMetadata FieldNode concreteFieldNode = concreteFieldComponentNode.FieldNodes.Single(); Assert.That(concreteFieldNode.Owner, Is.EqualTo(concreteFieldComponentNode.Component)); - Assert.That(concreteFieldNode.DeclaringType, Is.EqualTo(typeof(SingleConcreteComponentTarget))); Assert.That(concreteFieldNode.QualifyingName, Is.EqualTo("dependency")); Assert.That(concreteFieldNode.InjectId, Is.Null); Assert.That(concreteFieldNode.SuppressMissingErrors, Is.False); @@ -61,7 +60,6 @@ public void FieldNode_GivenTopLevelFieldAndPropertyTargets_CapturesFieldMetadata Assert.That(concreteFieldNode.ShortPath, Is.EqualTo("SingleConcreteComponentTarget.dependency")); FieldNode interfaceFieldNode = interfaceFieldComponentNode.FieldNodes.Single(); - Assert.That(interfaceFieldNode.DeclaringType, Is.EqualTo(typeof(SingleInterfaceTarget))); Assert.That(interfaceFieldNode.QualifyingName, Is.EqualTo("dependency")); Assert.That(interfaceFieldNode.FieldType, Is.EqualTo(typeof(IDependency))); Assert.That(interfaceFieldNode.RequestedType, Is.EqualTo(typeof(IDependency))); @@ -73,7 +71,6 @@ public void FieldNode_GivenTopLevelFieldAndPropertyTargets_CapturesFieldMetadata Assert.That(interfaceFieldNode.ShortPath, Is.EqualTo("SingleInterfaceTarget.dependency")); FieldNode concretePropertyNode = concretePropertyComponentNode.FieldNodes.Single(); - Assert.That(concretePropertyNode.DeclaringType, Is.EqualTo(typeof(SingleConcreteComponentPropertyTarget))); Assert.That(concretePropertyNode.QualifyingName, Is.EqualTo("Dependency")); Assert.That(concretePropertyNode.FieldType, Is.EqualTo(typeof(ComponentDependency))); Assert.That(concretePropertyNode.RequestedType, Is.EqualTo(typeof(ComponentDependency))); @@ -163,7 +160,6 @@ public void MethodNode_GivenMethodTargets_CapturesMethodAndParameterMetadata() MethodNode singleMethodNode = singleMethodComponentNode.MethodNodes.Single(); Assert.That(singleMethodNode.Owner, Is.EqualTo(singleMethodComponentNode.Component)); - Assert.That(singleMethodNode.DeclaringType, Is.EqualTo(typeof(SingleConcreteComponentMethodTarget))); Assert.That(singleMethodNode.QualifyingName, Is.EqualTo("Inject")); Assert.That(singleMethodNode.InjectId, Is.Null); Assert.That(singleMethodNode.SuppressMissingErrors, Is.False); @@ -282,14 +278,12 @@ public void ComponentNode_GivenNestedSerializableMembers_CapturesNestedOwnerPath Assert.That(componentNode.MethodNodes.Count, Is.EqualTo(2)); Assert.That(topLevelFieldNode.Owner, Is.EqualTo(target)); - Assert.That(topLevelFieldNode.DeclaringType, Is.EqualTo(typeof(NestedRootTarget))); Assert.That(topLevelFieldNode.InjectId, Is.EqualTo("field-id")); Assert.That(topLevelFieldNode.SuppressMissingErrors, Is.True); Assert.That(topLevelFieldNode.DisplayPath, Is.EqualTo("Root 1/NestedRootTarget/fieldDependency")); Assert.That(topLevelFieldNode.ShortPath, Is.EqualTo("NestedRootTarget.fieldDependency")); Assert.That(topLevelPropertyNode.Owner, Is.EqualTo(target)); - Assert.That(topLevelPropertyNode.DeclaringType, Is.EqualTo(typeof(NestedRootTarget))); Assert.That(topLevelPropertyNode.InjectId, Is.EqualTo("property-id")); Assert.That(topLevelPropertyNode.SuppressMissingErrors, Is.True); Assert.That(topLevelPropertyNode.TypeShape, Is.EqualTo(TypeShape.List)); @@ -299,14 +293,12 @@ public void ComponentNode_GivenNestedSerializableMembers_CapturesNestedOwnerPath Assert.That(topLevelPropertyNode.ShortPath, Is.EqualTo("NestedRootTarget.PropertyDependencies")); Assert.That(nestedFieldNode.Owner, Is.EqualTo(target.nested)); - Assert.That(nestedFieldNode.DeclaringType, Is.EqualTo(typeof(NestedChildTarget))); Assert.That(nestedFieldNode.InjectId, Is.Null); Assert.That(nestedFieldNode.SuppressMissingErrors, Is.False); Assert.That(nestedFieldNode.DisplayPath, Is.EqualTo("Root 1/NestedRootTarget/nested.nestedFieldDependency")); Assert.That(nestedFieldNode.ShortPath, Is.EqualTo("NestedChildTarget.nestedFieldDependency")); Assert.That(nestedPropertyNode.Owner, Is.EqualTo(target.nested)); - Assert.That(nestedPropertyNode.DeclaringType, Is.EqualTo(typeof(NestedChildTarget))); Assert.That(nestedPropertyNode.InjectId, Is.EqualTo("nested-property-id")); Assert.That(nestedPropertyNode.SuppressMissingErrors, Is.True); Assert.That(nestedPropertyNode.IsPropertyBackingField, Is.True); @@ -314,7 +306,6 @@ public void ComponentNode_GivenNestedSerializableMembers_CapturesNestedOwnerPath Assert.That(nestedPropertyNode.ShortPath, Is.EqualTo("NestedChildTarget.NestedPropertyDependency")); Assert.That(deepFieldNode.Owner, Is.EqualTo(target.nested.deepNested)); - Assert.That(deepFieldNode.DeclaringType, Is.EqualTo(typeof(NestedDeepTarget))); Assert.That(deepFieldNode.InjectId, Is.EqualTo("deep-field-id")); Assert.That(deepFieldNode.SuppressMissingErrors, Is.True); Assert.That(deepFieldNode.DisplayPath, Is.EqualTo("Root 1/NestedRootTarget/nested.deepNested.deepFieldDependency")); @@ -323,7 +314,6 @@ public void ComponentNode_GivenNestedSerializableMembers_CapturesNestedOwnerPath CollectionAssert.IsEmpty(componentNode.FieldNodes.Where(node => node.DisplayPath.Contains("nullNested")).ToArray()); Assert.That(topLevelMethodNode.Owner, Is.EqualTo(target)); - Assert.That(topLevelMethodNode.DeclaringType, Is.EqualTo(typeof(NestedRootTarget))); Assert.That(topLevelMethodNode.InjectId, Is.EqualTo("method-id")); Assert.That(topLevelMethodNode.SuppressMissingErrors, Is.True); Assert.That(topLevelMethodNode.DisplayPath, Is.EqualTo("Root 1/NestedRootTarget/InjectTopLevel")); @@ -342,7 +332,6 @@ public void ComponentNode_GivenNestedSerializableMembers_CapturesNestedOwnerPath ); Assert.That(nestedMethodNode.Owner, Is.EqualTo(target.nested)); - Assert.That(nestedMethodNode.DeclaringType, Is.EqualTo(typeof(NestedChildTarget))); Assert.That(nestedMethodNode.InjectId, Is.EqualTo("nested-method-id")); Assert.That(nestedMethodNode.SuppressMissingErrors, Is.False); Assert.That(nestedMethodNode.DisplayPath, Is.EqualTo("Root 1/NestedRootTarget/nested.InjectNested")); From a0dd60ccd6e6d67bbc54d5056d1ae2f092900a2b Mon Sep 17 00:00:00 2001 From: Alexander Larsen Date: Fri, 1 May 2026 16:55:44 +0200 Subject: [PATCH 2/8] Update ID qualifier matching behavior. - Updated ID matching so injection sites with an ID only match bindings with the same `ToID`. - Updated binding conflict detection so ID-qualified and non-ID-qualified bindings are distinct. - Updated binding qualifier and uniqueness documentation for ID matching behavior. - Added changelog notes for the ID qualifier change. --- CHANGELOG.md | 5 +++++ Docs/docs/core-concepts/binding.md | 21 +++++++++++++++---- .../Plugins/Saneject/Editor/Core/Resolver.cs | 11 +++++----- .../Editor/Data/Graph/Nodes/BindingNode.cs | 5 +---- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d144618..99934b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Version 1.0.6 +### Changes + +- Updated ID matching so injection sites with an ID only match bindings with the same `ToID`. +- Updated binding uniqueness detection so ID-qualified and non-ID-qualified bindings are distinct. + ### Fixes - Fixed `ToTarget` matching for inherited component targets, so target qualifiers now match derived component types. diff --git a/Docs/docs/core-concepts/binding.md b/Docs/docs/core-concepts/binding.md index e6036ad1..2ac8a55f 100644 --- a/Docs/docs/core-concepts/binding.md +++ b/Docs/docs/core-concepts/binding.md @@ -176,7 +176,8 @@ Binding qualifiers restrict which injection sites (fields, properties, methods) Important behavior: - Binding qualifiers are additive, so all specified qualifiers must match. -- If a binding qualifier is not set on the binding, the binding matches by `TInterface` or `TConcrete` only. +- Injection sites without an ID match bindings without `ToID`. Injection sites with an ID only match bindings with the same `ToID`. +- If `ToTarget` or `ToMember` is not set on the binding, that qualifier does not restrict where the binding applies. - `ToTarget()` matches the actual injection target component type. A binding targeted to a base component type also matches derived component types. - Binding qualifiers apply to component, asset, and runtime proxy bindings. - Binding qualifiers do not apply to global bindings. @@ -247,9 +248,10 @@ Duplicate checks use these criteria: 4. Same single/collection shape. 5. Qualifier ambiguity: - If neither binding has qualifiers that separate it from the other, they conflict. - - Empty qualifier sets are unrestricted and do not separate bindings. + - `ToID` separates bindings by ID. A binding without `ToID` does not overlap a binding with `ToID`, and two bindings with `ToID` overlap only when their IDs overlap. - `ToTarget` qualifiers overlap when their target component type hierarchies overlap. - - `ToMember` and `ToID` qualifiers separate bindings only when both bindings specify non-overlapping values. + - `ToMember` qualifiers separate bindings only when both bindings specify non-overlapping values. + - Empty `ToTarget` and `ToMember` qualifier sets are unrestricted and do not separate bindings. Examples: @@ -278,7 +280,7 @@ BindAsset() ``` ```csharp -// Ambiguous: both bindings can resolve [Inject("menu")] members on MainMenuController. +// Distinct: one binding matches ID "menu" and the other matches injection sites without an ID. BindAsset() .ToID("menu") .FromResources("Configs/Menu"); @@ -288,6 +290,17 @@ BindAsset() .FromResources("Configs/Menu"); ``` +```csharp +// Ambiguous: both bindings can resolve the config member on MainMenuController. +BindAsset() + .ToMember("config") + .FromResources("Configs/Menu"); + +BindAsset() + .ToTarget() + .FromResources("Configs/Menu"); +``` + Global bindings have an extra rule: only one global binding per concrete component type is allowed across active bindings across all scopes. A second `BindGlobal()` is invalid even if declared in another scope. diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs index 839b8590..86d846e5 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs @@ -7,7 +7,6 @@ using Plugins.Saneject.Editor.Data.Graph; using Plugins.Saneject.Editor.Data.Graph.Nodes; using Plugins.Saneject.Editor.Data.Injection; -using UnityEngine; using Object = UnityEngine.Object; namespace Plugins.Saneject.Editor.Core @@ -332,9 +331,9 @@ bool MatchesIsCollection(BindingNode bindingNode) } bool MatchesTargetTypeQualifiers(BindingNode bindingNode) - { + { return bindingNode.TargetTypeQualifiers.Count == 0 || - bindingNode.TargetTypeQualifiers.Any(t => t.IsAssignableFrom(componentType)); + bindingNode.TargetTypeQualifiers.Any(t => t.IsAssignableFrom(componentType)); } bool MatchesMemberNameQualifiers(BindingNode bindingNode) @@ -345,8 +344,10 @@ bool MatchesMemberNameQualifiers(BindingNode bindingNode) bool MatchesIdQualifiers(BindingNode bindingNode) { - return bindingNode.IdQualifiers.Count == 0 || - bindingNode.IdQualifiers.Contains(injectId); + if (!string.IsNullOrWhiteSpace(injectId)) + return bindingNode.IdQualifiers.Count > 0 && bindingNode.IdQualifiers.Contains(injectId); + + return bindingNode.IdQualifiers.Count == 0; } } diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs index 7da8a6b2..aab95db6 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs @@ -82,10 +82,7 @@ public bool Equals(BindingNode other) other.MemberNameQualifiers.Count > 0 && !MemberNameQualifiers.OverlapsWith(other.MemberNameQualifiers); - bool separatedById = - IdQualifiers.Count > 0 && - other.IdQualifiers.Count > 0 && - !IdQualifiers.OverlapsWith(other.IdQualifiers); + bool separatedById = !IdQualifiers.OverlapsWith(other.IdQualifiers); return !separatedByTarget && !separatedByMemberName From 12df5f4f4dfef232bae79fd67c91b1756b187744 Mon Sep 17 00:00:00 2001 From: Alexander Larsen Date: Fri, 1 May 2026 16:56:44 +0200 Subject: [PATCH 3/8] Bump package version to 1.0.6 --- UnityProject/Saneject/Assets/Plugins/Saneject/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/package.json b/UnityProject/Saneject/Assets/Plugins/Saneject/package.json index a789229b..d0f05c6f 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/package.json +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/package.json @@ -2,7 +2,7 @@ "name": "com.alexanderlarsen.saneject", "author": "Alexander Larsen", "displayName": "Saneject", - "version": "1.0.5", + "version": "1.0.6", "description": "Inject dependencies in the Unity Editor, not Play Mode, by writing them directly into serialized fields at edit-time using familiar DI APIs, so everything stays visible in the Inspector, including interfaces.\n\nNo runtime container. No startup cost. No hidden wiring. No weird lifecycles. Just simple, deterministic edit-time DI that works with Unity, not around it.", "documentationUrl": "https://github.com/alexanderlarsen/Saneject/blob/main/README.md", "changelogUrl": "https://github.com/alexanderlarsen/Saneject/blob/main/CHANGELOG.md", @@ -16,4 +16,4 @@ "path": "Samples~/DemoGame" } ] -} +} \ No newline at end of file From f320fbe2c9076dbc278882d68a5debb24f67825e Mon Sep 17 00:00:00 2001 From: Alexander Larsen Date: Fri, 1 May 2026 16:59:54 +0200 Subject: [PATCH 4/8] Bump package version to 1.1.0 and update CHANGELOG --- CHANGELOG.md | 6 +++--- UnityProject/Saneject/Assets/Plugins/Saneject/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99934b9c..4308758d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,11 @@ # Changelog -## Version 1.0.6 +## Version 1.1.0 ### Changes -- Updated ID matching so injection sites with an ID only match bindings with the same `ToID`. -- Updated binding uniqueness detection so ID-qualified and non-ID-qualified bindings are distinct. +- ID-qualified injection sites now require a matching `ToID(...)` binding. Unqualified bindings only match injection sites without an ID. +- If an `[Inject("id")]` site previously relied on an unqualified binding, add `.ToID("id")` to the binding or remove the ID from the injection site. ### Fixes diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/package.json b/UnityProject/Saneject/Assets/Plugins/Saneject/package.json index d0f05c6f..40e07e04 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/package.json +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/package.json @@ -2,7 +2,7 @@ "name": "com.alexanderlarsen.saneject", "author": "Alexander Larsen", "displayName": "Saneject", - "version": "1.0.6", + "version": "1.1.0", "description": "Inject dependencies in the Unity Editor, not Play Mode, by writing them directly into serialized fields at edit-time using familiar DI APIs, so everything stays visible in the Inspector, including interfaces.\n\nNo runtime container. No startup cost. No hidden wiring. No weird lifecycles. Just simple, deterministic edit-time DI that works with Unity, not around it.", "documentationUrl": "https://github.com/alexanderlarsen/Saneject/blob/main/README.md", "changelogUrl": "https://github.com/alexanderlarsen/Saneject/blob/main/CHANGELOG.md", From 5b14e53551a760cdf4649f27bb2e6a7846d0f3cb Mon Sep 17 00:00:00 2001 From: Alexander Larsen Date: Fri, 1 May 2026 19:50:37 +0200 Subject: [PATCH 5/8] Update ID qualifier handling and add new binding equality tests. - Clarified ID qualifier documentation in `InjectAttribute`, `AssetBindingBuilder`, and `ComponentBindingBuilder` summaries. - Refined `BindingNode` ID qualifier comparison logic to handle cases with or without qualifiers explicitly. - Added new tests for `BindingNodeEqualityTests` to validate equality and inequality based on ID and additional qualifiers. --- .../Editor/Data/Graph/Nodes/BindingNode.cs | 3 +- .../Runtime/Attributes/InjectAttribute.cs | 2 +- .../Bindings/Asset/AssetBindingBuilder.cs | 5 +- .../Component/ComponentBindingBuilder.cs | 3 +- .../Equality/BindingNodeEqualityTests.cs | 105 +++++++++++++++++- 5 files changed, 110 insertions(+), 8 deletions(-) diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs index aab95db6..ca29f4e3 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Data/Graph/Nodes/BindingNode.cs @@ -82,7 +82,8 @@ public bool Equals(BindingNode other) other.MemberNameQualifiers.Count > 0 && !MemberNameQualifiers.OverlapsWith(other.MemberNameQualifiers); - bool separatedById = !IdQualifiers.OverlapsWith(other.IdQualifiers); + bool separatedById = (IdQualifiers.Count > 0 || other.IdQualifiers.Count > 0) && + !IdQualifiers.OverlapsWith(other.IdQualifiers); return !separatedByTarget && !separatedByMemberName diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Attributes/InjectAttribute.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Attributes/InjectAttribute.cs index 57d2beb2..4906113a 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Attributes/InjectAttribute.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Attributes/InjectAttribute.cs @@ -49,7 +49,7 @@ public InjectAttribute(bool suppressMissingErrors) /// /// Marks the field or method for injection with an ID while optionally suppressing missing binding and missing dependency logs for the field. /// - /// The binding ID to match against. + /// The binding ID to match against. Only bindings with the same ID will be used for resolution. /// /// If true, suppresses error logs when no binding or dependency is found. /// diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs index e827676f..9e233f26 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs @@ -63,11 +63,10 @@ public AssetFilterBuilder FromFolder(string folderPath) #region QUALIFIER METHODS /// - /// Qualifies this binding with an ID. - /// Only injection targets annotated with + /// Only injection sites annotated with /// that specify the same ID will resolve using this binding. /// - /// The identifiers to match against injection targets. + /// The identifiers to match against injection sites. /// The builder instance for fluent chaining. public AssetBindingBuilder ToID(params string[] ids) { diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs index 3cecce92..a839281a 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs @@ -49,8 +49,7 @@ public RuntimeProxyBindingBuilder FromRuntimeProxy() #region QUALIFIER METHODS /// - /// Qualifies this binding with one or more IDs. - /// Only injection sites (fields/properties/methods) annotated with an + /// Only injection sites annotated with /// that specify the same ID will resolve using this binding. /// /// The identifiers to match against injection sites. diff --git a/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Equality/BindingNodeEqualityTests.cs b/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Equality/BindingNodeEqualityTests.cs index c5ef8b97..1a354b4f 100644 --- a/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Equality/BindingNodeEqualityTests.cs +++ b/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Equality/BindingNodeEqualityTests.cs @@ -186,7 +186,57 @@ public void BindingNode_SameScope_TConcrete_WithOnlyIDQualifiers_IsEqual() } [Test] - public void BindingNode_SameScope_TConcrete_WithIDAndTargetQualifiers_IsEqual() + public void BindingNode_SameScope_TConcrete_WithOnlyOverlappingIDQualifiers_IsEqual() + { + // Set up scene + TestScene scene = TestScene.Create(roots: 1, width: 1, depth: 1); + TestScope scope = scene.Add("Root 1"); + + // Bind + scope.BindComponent() + .ToID("qualified", "alternate") + .FromSelf(); + + scope.BindComponent() + .ToID("alternate", "other") + .FromAnywhere(); + + // Build graph and fetch binding nodes + ScopeNode scopeNode = CreateScopeNode(scene, "Root 1"); + BindingNode firstBinding = scopeNode.BindingNodes[0]; + BindingNode secondBinding = scopeNode.BindingNodes[1]; + + // Assert + Assert.That(firstBinding, Is.EqualTo(secondBinding)); + } + + [Test] + public void BindingNode_SameScope_TConcrete_WithOnlyDifferentIDQualifiers_IsNotEqual() + { + // Set up scene + TestScene scene = TestScene.Create(roots: 1, width: 1, depth: 1); + TestScope scope = scene.Add("Root 1"); + + // Bind + scope.BindComponent() + .ToID("qualified", "alternate") + .FromSelf(); + + scope.BindComponent() + .ToID("other", "fallback") + .FromAnywhere(); + + // Build graph and fetch binding nodes + ScopeNode scopeNode = CreateScopeNode(scene, "Root 1"); + BindingNode firstBinding = scopeNode.BindingNodes[0]; + BindingNode secondBinding = scopeNode.BindingNodes[1]; + + // Assert + Assert.That(firstBinding, Is.Not.EqualTo(secondBinding)); + } + + [Test] + public void BindingNode_SameScope_TConcrete_WithIDAndTargetQualifiers_IsNotEqual() { // Set up scene TestScene scene = TestScene.Create(roots: 1, width: 1, depth: 1); @@ -206,6 +256,59 @@ public void BindingNode_SameScope_TConcrete_WithIDAndTargetQualifiers_IsEqual() BindingNode firstBinding = scopeNode.BindingNodes[0]; BindingNode secondBinding = scopeNode.BindingNodes[1]; + // Assert + Assert.That(firstBinding, Is.Not.EqualTo(secondBinding)); + } + + [Test] + public void BindingNode_SameScope_TConcrete_WithIDAndOverlappingTargetAndMemberQualifiers_IsNotEqual() + { + // Set up scene + TestScene scene = TestScene.Create(roots: 1, width: 1, depth: 1); + TestScope scope = scene.Add("Root 1"); + + // Bind + scope.BindComponent() + .ToTarget() + .ToMember("dependency") + .FromSelf(); + + scope.BindComponent() + .ToID("qualified") + .ToTarget() + .ToMember("dependency") + .FromAnywhere(); + + // Build graph and fetch binding nodes + ScopeNode scopeNode = CreateScopeNode(scene, "Root 1"); + BindingNode firstBinding = scopeNode.BindingNodes[0]; + BindingNode secondBinding = scopeNode.BindingNodes[1]; + + // Assert + Assert.That(firstBinding, Is.Not.EqualTo(secondBinding)); + } + + [Test] + public void BindingNode_SameScope_TConcrete_WithOverlappingTargetAndMemberQualifiers_IsEqual() + { + // Set up scene + TestScene scene = TestScene.Create(roots: 1, width: 1, depth: 1); + TestScope scope = scene.Add("Root 1"); + + // Bind + scope.BindComponent() + .ToTarget() + .FromSelf(); + + scope.BindComponent() + .ToMember("dependency") + .FromAnywhere(); + + // Build graph and fetch binding nodes + ScopeNode scopeNode = CreateScopeNode(scene, "Root 1"); + BindingNode firstBinding = scopeNode.BindingNodes[0]; + BindingNode secondBinding = scopeNode.BindingNodes[1]; + // Assert Assert.That(firstBinding, Is.EqualTo(secondBinding)); } From 9547f3bd12d9708132665b84804ed89f8db11543 Mon Sep 17 00:00:00 2001 From: Alexander Larsen Date: Fri, 1 May 2026 20:04:36 +0200 Subject: [PATCH 6/8] Clarify injection behavior and scope qualifier logic in docs --- Docs/docs/core-concepts/binding.md | 12 ++++++------ .../Saneject/Runtime/Attributes/InjectAttribute.cs | 6 +++--- .../Runtime/Bindings/Asset/AssetBindingBuilder.cs | 14 +++++++------- .../Bindings/Component/ComponentBindingBuilder.cs | 8 ++++---- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Docs/docs/core-concepts/binding.md b/Docs/docs/core-concepts/binding.md index 2ac8a55f..803c41d0 100644 --- a/Docs/docs/core-concepts/binding.md +++ b/Docs/docs/core-concepts/binding.md @@ -238,7 +238,7 @@ If a binding filter throws an exception, Saneject logs a binding filter error fo Saneject enforces unambiguous bindings within each `Scope`. When two bindings are considered duplicate or ambiguous, Saneject logs an error and excludes the conflicting binding from the injection run. -Duplicate checks use these criteria: +Duplicate and ambiguity checks use these criteria: 1. Same scope. 2. Same binding family (`ComponentBindingNode`, `AssetBindingNode`, or `GlobalComponentBindingNode`). @@ -247,11 +247,11 @@ Duplicate checks use these criteria: - Otherwise `TConcrete`. 4. Same single/collection shape. 5. Qualifier ambiguity: - - If neither binding has qualifiers that separate it from the other, they conflict. - - `ToID` separates bindings by ID. A binding without `ToID` does not overlap a binding with `ToID`, and two bindings with `ToID` overlap only when their IDs overlap. - - `ToTarget` qualifiers overlap when their target component type hierarchies overlap. - - `ToMember` qualifiers separate bindings only when both bindings specify non-overlapping values. - - Empty `ToTarget` and `ToMember` qualifier sets are unrestricted and do not separate bindings. + - If the criteria above do not separate two bindings, they conflict unless at least one qualifier rule below separates them. + - `ToID` separates bindings by ID. A binding without `ToID` does not overlap a binding with `ToID`, and two bindings with `ToID` overlap only when their IDs overlap. + - `ToTarget` qualifiers overlap when their target component type hierarchies overlap. + - `ToMember` qualifiers separate bindings only when both bindings specify non-overlapping values. + - Empty `ToTarget` and `ToMember` qualifier sets are unrestricted and do not separate bindings. Examples: diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Attributes/InjectAttribute.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Attributes/InjectAttribute.cs index 4906113a..371eca10 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Attributes/InjectAttribute.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Attributes/InjectAttribute.cs @@ -16,10 +16,10 @@ namespace Plugins.Saneject.Runtime.Attributes public sealed class InjectAttribute : PropertyAttribute { /// - /// Marks the field or method for injection, using only type-based binding resolution. + /// Marks the field or method for injection without an ID. /// /// - /// The dependency is resolved by matching the member's type against bindings in the scope hierarchy. + /// The dependency is resolved by matching the member's type against bindings in the scope hierarchy that do not specify an ID. /// public InjectAttribute() { @@ -62,7 +62,7 @@ public InjectAttribute( } /// - /// Gets the binding ID used for dependency resolution, or null if only type-based resolution is used. + /// Gets the binding ID used for dependency resolution, or null if bindings without an ID should be used. /// public string ID { get; } diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs index 9e233f26..87e2da26 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs @@ -75,8 +75,8 @@ public AssetBindingBuilder ToID(params string[] ids) } /// - /// Qualifies this binding to apply only when the injection target is of the given type. - /// The injection target is the that owns the field or property + /// Qualifies this binding to apply only when the injection target is of the given type or a derived type. + /// The injection target is the that owns the field, property, or method /// marked with . /// /// The target type this binding applies to. @@ -88,8 +88,8 @@ public AssetBindingBuilder ToTarget() } /// - /// Qualifies this binding to apply only when the injection target is one of the specified types. - /// The injection target is the that owns the field or property + /// Qualifies this binding to apply only when the injection target is one of the specified types or a derived type. + /// The injection target is the that owns the field, property, or method /// marked with . /// /// One or more target objects to match against. @@ -101,10 +101,10 @@ public AssetBindingBuilder ToTarget(params Type[] targetTypes) } /// - /// Qualifies this binding to apply only when the injection target member (field or property) + /// Qualifies this binding to apply only when the injection target member (field, property, or method) /// has one of the specified names. /// - /// The field or property names on the injection target that this binding should apply to. + /// The field, property, or method names on the injection target that this binding should apply to. /// The builder instance for fluent chaining. public AssetBindingBuilder ToMember(params string[] memberNames) { @@ -205,4 +205,4 @@ public AssetFilterBuilder FromMethod(Func> method) #endregion } -} \ No newline at end of file +} diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs index a839281a..9d0436b6 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs @@ -61,7 +61,7 @@ public ComponentBindingBuilder ToID(params string[] ids) } /// - /// Qualifies this binding to apply only when the injection target is of the given type. + /// Qualifies this binding to apply only when the injection target is of the given type or a derived type. /// The injection target is the that owns the field, property or method /// marked with . /// @@ -74,7 +74,7 @@ public ComponentBindingBuilder ToTarget() } /// - /// Qualifies this binding to apply only when the injection target is one of the specified types. + /// Qualifies this binding to apply only when the injection target is one of the specified types or a derived type. /// The injection target is the that owns the field, property or method /// marked with . /// @@ -87,9 +87,9 @@ public ComponentBindingBuilder ToTarget(params Type[] targetTypes) } /// - /// Qualifies this binding to apply only when the injection target member (field or property) has one of the specified names. + /// Qualifies this binding to apply only when the injection target member (field, property, or method) has one of the specified names. /// - /// The field or property names on the injection target that this binding should apply to. + /// The field, property, or method names on the injection target that this binding should apply to. /// The builder instance for fluent chaining. public ComponentBindingBuilder ToMember(params string[] memberNames) { From 51158a27e478e6f336d1eaf1125ebdb7da11ac05 Mon Sep 17 00:00:00 2001 From: Alexander Larsen Date: Fri, 1 May 2026 20:11:00 +0200 Subject: [PATCH 7/8] Update CHANGELOG entry for `ToTarget` inheritance behavior fix --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4308758d..3fa97c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixes -- Fixed `ToTarget` matching for inherited component targets, so target qualifiers now match derived component types. +- Fixed `ToTarget` matching for inherited component targets. `.ToTarget()` now matches derived component types, and `.ToTarget()` also applies to inherited injection members on the same derived component. - Fixed binding validation for duplicate or ambiguous qualifier combinations that could resolve the same injection site. ## Version 1.0.5 From ceadd0916c339f673f682d968fbe12849eb6f73f Mon Sep 17 00:00:00 2001 From: Alexander Larsen Date: Fri, 1 May 2026 20:34:28 +0200 Subject: [PATCH 8/8] Fix `ToTarget` for nested injection scenarios, update target qualifiers - Added a new test for nested serializable object injection using `ToTarget` and various ID bindings. - Updated `BindingNode` logic to use the actual owner type (instead of components) as the injection target for binding matches. - Standardized target type qualifiers across components and nested objects in `ComponentBindingBuilder` and `AssetBindingBuilder`. - Updated binding documentation and summaries to reflect the shift to owner object targeting. - Fixed ambiguous qualifier detection for nested, derived, and inherited targets. --- CHANGELOG.md | 2 +- Docs/docs/core-concepts/binding.md | 10 ++-- .../Plugins/Saneject/Editor/Core/Resolver.cs | 10 ++-- .../Bindings/Asset/AssetBindingBuilder.cs | 4 +- .../Component/ComponentBindingBuilder.cs | 4 +- .../ComponentBindingQualifierTests.cs | 57 ++++++++++++++++++- 6 files changed, 71 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fa97c58..1baf3e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixes -- Fixed `ToTarget` matching for inherited component targets. `.ToTarget()` now matches derived component types, and `.ToTarget()` also applies to inherited injection members on the same derived component. +- Fixed `ToTarget` matching for inherited and nested targets. `.ToTarget()` now matches derived target types, `.ToTarget()` also applies to inherited injection members on the same derived target, and nested serializable injection targets are matched by their nested owner type. - Fixed binding validation for duplicate or ambiguous qualifier combinations that could resolve the same injection site. ## Version 1.0.5 diff --git a/Docs/docs/core-concepts/binding.md b/Docs/docs/core-concepts/binding.md index 803c41d0..02396fc0 100644 --- a/Docs/docs/core-concepts/binding.md +++ b/Docs/docs/core-concepts/binding.md @@ -40,8 +40,8 @@ Most bindings follow this general pattern or a combination of these: ```csharp BindComponent() .ToID("hud") // Optional qualifier that matches [Inject("hud")] - .ToTarget() // Optional qualifier that matches injection targets of type CombatHud - .ToMember("audioService") // Optional qualifier that matches and methods named "audioService" + .ToTarget() // Optional qualifier that matches injection target objects of type CombatHud + .ToMember("audioService") // Optional qualifier that matches members named "audioService" .FromTargetSelf() // Required locator strategy that looks for the AudioManager on the transform of the CombatHud .WhereComponent(c => c.isActiveAndEnabled); // Optional filter that only includes enabled components ``` @@ -170,7 +170,7 @@ Binding qualifiers restrict which injection sites (fields, properties, methods) | Qualifier | Injection site match | |------------------------------|---------------------------------------------------------------------------------| | `ToID("someId")` | Fields, properties, methods marked with `[Inject("someId")]` | -| `ToTarget()` | Fields, properties, methods on `TTarget` components and derived component types | +| `ToTarget()` | Fields, properties, methods owned by `TTarget` objects and derived types | | `ToMember("someMemberName")` | Fields, properties, methods with name `"someMemberName"` | Important behavior: @@ -178,7 +178,7 @@ Important behavior: - Binding qualifiers are additive, so all specified qualifiers must match. - Injection sites without an ID match bindings without `ToID`. Injection sites with an ID only match bindings with the same `ToID`. - If `ToTarget` or `ToMember` is not set on the binding, that qualifier does not restrict where the binding applies. -- `ToTarget()` matches the actual injection target component type. A binding targeted to a base component type also matches derived component types. +- `ToTarget()` matches the actual object that owns the injected member. This can be a component or a nested serializable object, and a binding targeted to a base type also matches derived types. - Binding qualifiers apply to component, asset, and runtime proxy bindings. - Binding qualifiers do not apply to global bindings. @@ -249,7 +249,7 @@ Duplicate and ambiguity checks use these criteria: 5. Qualifier ambiguity: - If the criteria above do not separate two bindings, they conflict unless at least one qualifier rule below separates them. - `ToID` separates bindings by ID. A binding without `ToID` does not overlap a binding with `ToID`, and two bindings with `ToID` overlap only when their IDs overlap. - - `ToTarget` qualifiers overlap when their target component type hierarchies overlap. + - `ToTarget` qualifiers overlap when their target type hierarchies overlap. - `ToMember` qualifiers separate bindings only when both bindings specify non-overlapping values. - Empty `ToTarget` and `ToMember` qualifier sets are unrestricted and do not separate bindings. diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs index 86d846e5..af0f3e85 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs @@ -159,7 +159,7 @@ private static void ResolveField( ( currentScope: fieldNode.ComponentNode.TransformNode.NearestScopeNode, context: context, - componentType: fieldNode.ComponentNode.Component.GetType(), + targetType: fieldNode.Owner.GetType(), requestedType: fieldNode.RequestedType, isCollection: fieldNode.IsCollection, qualifyingMemberName: fieldNode.QualifyingName, @@ -226,7 +226,7 @@ private static void ResolveMethod( ( currentScope: methodNode.ComponentNode.TransformNode.NearestScopeNode, context: context, - componentType: methodNode.ComponentNode.Component.GetType(), + targetType: methodNode.Owner.GetType(), requestedType: parameterNode.RequestedType, isCollection: parameterNode.IsCollection, qualifyingMemberName: methodNode.QualifyingName, @@ -286,7 +286,7 @@ out HashSet rejectedTypes private static BindingNode FindMatchingBindingNode( ScopeNode currentScope, InjectionContext context, - Type componentType, + Type targetType, Type requestedType, bool isCollection, string qualifyingMemberName, @@ -333,7 +333,7 @@ bool MatchesIsCollection(BindingNode bindingNode) bool MatchesTargetTypeQualifiers(BindingNode bindingNode) { return bindingNode.TargetTypeQualifiers.Count == 0 || - bindingNode.TargetTypeQualifiers.Any(t => t.IsAssignableFrom(componentType)); + bindingNode.TargetTypeQualifiers.Any(t => t.IsAssignableFrom(targetType)); } bool MatchesMemberNameQualifiers(BindingNode bindingNode) @@ -400,4 +400,4 @@ private static object ResolveCandidates( } } } -} \ No newline at end of file +} diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs index 87e2da26..34c7d3db 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Asset/AssetBindingBuilder.cs @@ -76,7 +76,7 @@ public AssetBindingBuilder ToID(params string[] ids) /// /// Qualifies this binding to apply only when the injection target is of the given type or a derived type. - /// The injection target is the that owns the field, property, or method + /// The injection target is the object that owns the field, property, or method /// marked with . /// /// The target type this binding applies to. @@ -89,7 +89,7 @@ public AssetBindingBuilder ToTarget() /// /// Qualifies this binding to apply only when the injection target is one of the specified types or a derived type. - /// The injection target is the that owns the field, property, or method + /// The injection target is the object that owns the field, property, or method /// marked with . /// /// One or more target objects to match against. diff --git a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs index 9d0436b6..a1078520 100644 --- a/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs +++ b/UnityProject/Saneject/Assets/Plugins/Saneject/Runtime/Bindings/Component/ComponentBindingBuilder.cs @@ -62,7 +62,7 @@ public ComponentBindingBuilder ToID(params string[] ids) /// /// Qualifies this binding to apply only when the injection target is of the given type or a derived type. - /// The injection target is the that owns the field, property or method + /// The injection target is the object that owns the field, property or method /// marked with . /// /// The target type this binding applies to. @@ -75,7 +75,7 @@ public ComponentBindingBuilder ToTarget() /// /// Qualifies this binding to apply only when the injection target is one of the specified types or a derived type. - /// The injection target is the that owns the field, property or method + /// The injection target is the object that owns the field, property or method /// marked with . /// /// One or more target objects to match against. diff --git a/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Qualification/ComponentBindingQualifierTests.cs b/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Qualification/ComponentBindingQualifierTests.cs index 3615568f..dc75ea38 100644 --- a/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Qualification/ComponentBindingQualifierTests.cs +++ b/UnityProject/Saneject/Assets/Tests/Saneject/Editor/Binding/Qualification/ComponentBindingQualifierTests.cs @@ -131,5 +131,60 @@ public void ToTarget_Generic_TConcrete_InjectsMatchingTarget_NotDifferentTarget( Assert.That(matchingTarget.dependency, Is.EqualTo(dependency)); Assert.That(nonMatchingTarget.dependency, Is.Null); } + + [Test] + public void ToTarget_Generic_TConcrete_InjectsNestedSerializableTarget() + { + // Set up scene + TestScene scene = TestScene.Create(roots: 1, width: 1, depth: 2); + TestScope scope = scene.Add("Root 1"); + NestedRootTarget target = scene.Add("Root 1"); + + // Find dependency + AssetDependency assetDependency = Resources.Load("AssetDependency 1"); + ComponentDependency singleDependency = scene.Add("Root 1"); + + ComponentDependency[] dependencies = + { + singleDependency, + scene.Add("Root 1/Child 1") + }; + + // Bind + scope.BindAsset() + .ToTarget() + .FromAssetLoad("Assets/Tests/Saneject/Fixtures/Resources/AssetDependency 1.asset"); + + scope.BindAsset() + .ToID("nested-method-id") + .ToTarget() + .FromAssetLoad("Assets/Tests/Saneject/Fixtures/Resources/AssetDependency 1.asset"); + + scope.BindComponents() + .ToID("nested-method-id") + .ToTarget() + .FromDescendants(includeSelf: true); + + scope.BindComponent() + .ToID("nested-method-id") + .ToTarget() + .FromSelf(); + + // Inject + InjectionRunner.Run(scene.Roots, ContextWalkFilter.SceneObjects); + + // Assert + Assert.That(assetDependency, Is.Not.Null); + Assert.That(target.nested.nestedFieldDependency, Is.EqualTo(assetDependency)); + Assert.That(target.nested.NestedMethodAssetDependency, Is.EqualTo(assetDependency)); + + CollectionAssert.AreEquivalent + ( + dependencies, + target.nested.NestedMethodComponentDependencies + ); + + Assert.That(target.nested.NestedMethodInterfaceDependency, Is.EqualTo(singleDependency)); + } } -} \ No newline at end of file +}