Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Version 1.1.0

### Changes

- 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

- Fixed `ToTarget<T>` matching for inherited and nested targets. `.ToTarget<Base>()` now matches derived target types, `.ToTarget<Derived>()` 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

### Fixes
Expand Down
67 changes: 47 additions & 20 deletions Docs/docs/core-concepts/binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ Most bindings follow this general pattern or a combination of these:
```csharp
BindComponent<IAudioService, AudioManager>()
.ToID("hud") // Optional qualifier that matches [Inject("hud")]
.ToTarget<CombatHud>() // Optional qualifier that matches injection targets of type CombatHud
.ToMember("audioService") // Optional qualifier that matches and methods named "audioService"
.ToTarget<CombatHud>() // 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
```
Expand Down Expand Up @@ -167,16 +167,18 @@ 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<TTarget>()` | 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<TTarget>()` | Fields, properties, methods owned by `TTarget` objects and derived 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.
- 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<TTarget>()` 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.

Expand Down Expand Up @@ -223,35 +225,38 @@ 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:
Duplicate and ambiguity checks use these criteria:

1. Same scope.
2. Same binding family (`ComponentBindingNode`, `AssetBindingNode`, or `GlobalComponentBindingNode`).
3. Same primary type:
- `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 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 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:

```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<AudioManager>()
.FromScopeSelf();

Expand All @@ -274,6 +279,28 @@ BindAsset<IGameConfig, GameConfigAsset>()
.FromResources("Configs/Gameplay");
```

```csharp
// Distinct: one binding matches ID "menu" and the other matches injection sites without an ID.
BindAsset<IGameConfig, GameConfigAsset>()
.ToID("menu")
.FromResources("Configs/Menu");

BindAsset<IGameConfig, GameConfigAsset>()
.ToTarget<MainMenuController>()
.FromResources("Configs/Menu");
```

```csharp
// Ambiguous: both bindings can resolve the config member on MainMenuController.
BindAsset<IGameConfig, GameConfigAsset>()
.ToMember("config")
.FromResources("Configs/Menu");

BindAsset<IGameConfig, GameConfigAsset>()
.ToTarget<MainMenuController>()
.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<AudioManager>()` is invalid even if declared in another scope.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
132 changes: 99 additions & 33 deletions UnityProject/Saneject/Assets/Plugins/Saneject/Editor/Core/Resolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,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(
Expand Down Expand Up @@ -49,16 +63,28 @@ out HashSet<Type> 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<object> dependencySet))
if (!globalMap.TryGetValue
(
binding.ScopeNode,
out HashSet<object> dependencySet
))
{
dependencySet = new HashSet<object>();
globalMap.Add(binding.ScopeNode, dependencySet);

globalMap.Add
(
binding.ScopeNode,
dependencySet
);
}

object resolved = candidates.FirstOrDefault();
Expand All @@ -68,7 +94,11 @@ out HashSet<Type> rejectedTypes
}

foreach ((ScopeNode scopeNode, HashSet<object> dependencies) in globalMap)
context.RegisterGlobalDependencies(scopeNode, dependencies);
context.RegisterGlobalDependencies
(
scopeNode,
dependencies
);
}

private static void ResolveFields(
Expand All @@ -85,7 +115,13 @@ private static void ResolveFields(
foreach (FieldNode fieldNode in fieldNodes)
{
progressTracker.UpdateInfoText($"Resolving field: {fieldNode.ShortPath}");
ResolveField(fieldNode, context);

ResolveField
(
fieldNode,
context
);

progressTracker.NextStep();
}
}
Expand All @@ -104,7 +140,13 @@ private static void ResolveMethods(
foreach (MethodNode methodNode in methodNodes)
{
progressTracker.UpdateInfoText($"Resolving method: {methodNode.ShortPath}");
ResolveMethod(methodNode, context);

ResolveMethod
(
methodNode,
context
);

progressTracker.NextStep();
}
}
Expand All @@ -117,7 +159,7 @@ private static void ResolveField(
(
currentScope: fieldNode.ComponentNode.TransformNode.NearestScopeNode,
context: context,
declaringType: fieldNode.DeclaringType,
targetType: fieldNode.Owner.GetType(),
requestedType: fieldNode.RequestedType,
isCollection: fieldNode.IsCollection,
qualifyingMemberName: fieldNode.QualifyingName,
Expand All @@ -144,12 +186,15 @@ out HashSet<Type> rejectedTypes
);

if (candidates is not { Length: > 0 })
context.RegisterError(new MissingDependencyError
context.RegisterError
(
bindingNode,
fieldNode,
rejectedTypes
));
new MissingDependencyError
(
bindingNode,
fieldNode,
rejectedTypes
)
);

resolved = ResolveCandidates
(
Expand All @@ -162,7 +207,11 @@ out HashSet<Type> rejectedTypes
context.RegisterUsedBinding(bindingNode);
}

context.RegisterFieldDependency(fieldNode, resolved);
context.RegisterFieldDependency
(
fieldNode,
resolved
);
}

private static void ResolveMethod(
Expand All @@ -177,7 +226,7 @@ private static void ResolveMethod(
(
currentScope: methodNode.ComponentNode.TransformNode.NearestScopeNode,
context: context,
declaringType: methodNode.DeclaringType,
targetType: methodNode.Owner.GetType(),
requestedType: parameterNode.RequestedType,
isCollection: parameterNode.IsCollection,
qualifyingMemberName: methodNode.QualifyingName,
Expand All @@ -204,12 +253,15 @@ out HashSet<Type> rejectedTypes
);

if (candidates is not { Length: > 0 })
context.RegisterError(new MissingDependencyError
context.RegisterError
(
bindingNode,
parameterNode,
rejectedTypes
));
new MissingDependencyError
(
bindingNode,
parameterNode,
rejectedTypes
)
);

resolved = ResolveCandidates
(
Expand All @@ -224,13 +276,17 @@ out HashSet<Type> rejectedTypes
context.RegisterUsedBinding(bindingNode);
}

context.RegisterMethodDependencies(methodNode, resolvedParameters);
context.RegisterMethodDependencies
(
methodNode,
resolvedParameters
);
}

private static BindingNode FindMatchingBindingNode(
ScopeNode currentScope,
InjectionContext context,
Type declaringType,
Type targetType,
Type requestedType,
bool isCollection,
string qualifyingMemberName,
Expand Down Expand Up @@ -277,7 +333,7 @@ bool MatchesIsCollection(BindingNode bindingNode)
bool MatchesTargetTypeQualifiers(BindingNode bindingNode)
{
return bindingNode.TargetTypeQualifiers.Count == 0 ||
bindingNode.TargetTypeQualifiers.Contains(declaringType);
bindingNode.TargetTypeQualifiers.Any(t => t.IsAssignableFrom(targetType));
}

bool MatchesMemberNameQualifiers(BindingNode bindingNode)
Expand All @@ -288,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;
}
}

Expand All @@ -311,10 +369,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;
}
Expand All @@ -334,4 +400,4 @@ private static object ResolveCandidates(
}
}
}
}
}
Loading
Loading