Resolve inheritance between identified records

This commit is contained in:
2026-10-23 20:48:46 +00:00
parent 268fd03579
commit 33a51606da
10 changed files with 447 additions and 31 deletions

View File

@@ -22,6 +22,18 @@ type identifiedInheritanceGraph struct {
byIdentity map[string]int
}
func sequenceHasIdentifiedInheritanceReferences(sequence *document.Node) bool {
if sequence == nil || sequence.Kind != document.Sequence {
return false
}
for _, item := range sequence.Sequence {
if directive, found := inheritanceDirective(item); found && directive.Value.Kind != document.Null {
return true
}
}
return false
}
// analyzeIdentifiedInheritanceGraph validates relationships inside one
// identified sequence. It prepares a graph only; mapping-sibling expansion
// remains the active inheritance behavior until the reusable scope executor is

View File

@@ -147,6 +147,93 @@ func TestIdentifiedInheritanceScopeAdapterReusesTemplateExecution(t *testing.T)
}
}
func TestIdentifiedInheritanceResolvesNestedCollectionsAtEachDestination(t *testing.T) {
root := parseItemPolicyDocument(t, "identified-nested", `jobs:
- id: child
extends: {id: parent}
- id: parent
extends: {id: base}
steps:
- id: child-step
extends: {id: parent-step}
value: child-value
- id: parent-step
value: parent-value
- id: base
steps:
- id: base-step
value: base-value
`)
identity := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
policies := collectionPolicyScope{}
policies.set(Keys("jobs"), identity)
for index := 0; index < 3; index++ {
policies.set(Keys("jobs").Index(index).Key("steps"), identity)
}
expanded, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatalf("expand nested identified inheritance: %#v", diagnostics)
}
steps, found := documentAtPath(expanded.root, Keys("jobs").Index(0).Key("steps"))
if !found || steps.Kind != document.Sequence || len(steps.Sequence) != 3 {
t.Fatalf("child nested steps = %#v, found %t", steps, found)
}
for index, want := range []string{"base-step", "child-step", "parent-step"} {
id, exists := documentAtPath(steps.Sequence[index], Keys("id"))
if !exists || id.Text != want {
t.Fatalf("child step %d identity = %#v, found %t; want %q", index, id, exists, want)
}
}
value, found := documentAtPath(steps.Sequence[1], Keys("value"))
if !found || value.Text != "child-value" {
t.Fatalf("nested record inheritance = %#v, found %t", value, found)
}
if containsInheritanceDirective(expanded.root) {
t.Fatal("a nested record relationship remained after destination-local expansion")
}
childIdentity, found := expanded.origins["/jobs/0/id"]
if !found || len(childIdentity.Locations) == 0 || childIdentity.Locations[0].SourceID != "identified-nested" {
t.Fatalf("child identity origin = %#v, found %t", childIdentity, found)
}
if _, found := expanded.origins["/jobs/0/steps/1/value"]; !found {
t.Fatal("nested inherited value did not retain its destination origin")
}
for _, replacement := range expanded.replacements {
if replacement.path.String() == "/jobs/0/id" {
t.Fatal("parent identity was treated as payload replaced by the child's identity")
}
}
}
func TestIdentifiedInheritanceRespectsOutputNodeBudget(t *testing.T) {
root := parseItemPolicyDocument(t, "identified-budget", `jobs:
- id: child
extends: {id: parent}
- id: parent
value: inherited
`)
policies := collectionPolicyScope{}
policies.set(Keys("jobs"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
baseline, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatalf("unbounded identified inheritance: %#v", diagnostics)
}
outputNodes, within := inheritanceLowerBoundCount(baseline.root, -1)
if !within || outputNodes < 2 {
t.Fatalf("identified output node count = %d, within %t", outputNodes, within)
}
limits := defaultProjectionLimits()
limits.nodes = outputNodes
if result, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, limits); result == nil || len(diagnostics) != 0 {
t.Fatalf("at output-node boundary: result = %#v, diagnostics = %#v", result, diagnostics)
}
limits.nodes = outputNodes - 1
if result, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, limits); result != nil ||
len(diagnostics) != 1 || diagnostics[0].Code != CodeSchemaProjectionLimit {
t.Fatalf("below output-node boundary: result = %#v, diagnostics = %#v", result, diagnostics)
}
}
func TestIdentifiedInheritanceRequiresChildIdentityBeforeParentLookup(t *testing.T) {
sequence := parseIdentifiedInheritanceSequence(t, "missing-child-field", `- id: child
extends: {id: parent, tenant: acme}

View File

@@ -220,7 +220,7 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int
effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies))
parentPolicyScope := projectInheritanceCollectionPolicies(resolvedPolicies[node.parent], node.path)
childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path)
merged := document.Clone(parent)
merged := inheritanceParentPayload(parent, node.identityFields)
mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies(
parentPolicyScope, childPolicyScope, node.path, parent, adapter.applicationPolicy(),
)

View File

@@ -159,6 +159,25 @@ func preserveInheritanceIdentityFields(merged, child *document.Node, fields []st
}
}
func inheritanceParentPayload(parent *document.Node, identityFields []string) *document.Node {
cloned := document.Clone(parent)
if len(identityFields) == 0 || cloned == nil || cloned.Kind != document.Mapping {
return cloned
}
excluded := make(map[string]struct{}, len(identityFields))
for _, field := range identityFields {
excluded[field] = struct{}{}
}
kept := cloned.Mapping[:0]
for _, entry := range cloned.Mapping {
if _, omit := excluded[entry.Key]; !omit {
kept = append(kept, entry)
}
}
cloned.Mapping = kept
return cloned
}
func applyIdentifiedInheritanceTemplates(
graph *identifiedInheritanceGraph,
limits projectionLimits,

View File

@@ -158,6 +158,11 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
}
}
case document.Sequence:
if sequenceHasIdentifiedInheritanceReferences(*node) {
if problem := w.expandIdentifiedSequence(node, path); problem != nil {
return problem
}
}
working := **node
working.Sequence = slices.Clone(working.Sequence)
*node = &working
@@ -171,6 +176,60 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
return nil
}
func (w *inheritanceWalker) expandIdentifiedSequence(node **document.Node, path Path) *Diagnostic {
policy, hasPolicy := w.collectionPolicies.at(path)
applicationFields, _ := w.applicationPolicy.identityFieldsAt(path)
var lower *collectionPolicy
if hasPolicy {
lower = &policy
}
resolution, policyDiagnostics := resolveCollectionPolicy(path, nil, applicationFields, lower)
if len(policyDiagnostics) != 0 {
return &policyDiagnostics[0]
}
// Without an identity rule, retain the existing diagnostic for a reference
// that has no enclosing sibling scope. A null cancellation needs no lookup.
if len(resolution.effectiveIdentityFields) == 0 {
return nil
}
original := *node
graph, diagnostics := analyzeIdentifiedInheritanceGraph(*node, path, resolution.effectiveIdentityFields)
if len(diagnostics) != 0 {
return &diagnostics[0]
}
before, _ := inheritanceLowerBoundCount(*node, -1)
limits := w.limits
if limits.nodes >= 0 {
limits.nodes -= w.nodes - before
}
resolved, diagnostics := applyIdentifiedInheritanceTemplates(
graph, limits, w.origins, w.collectionPolicies, w.applicationPolicy, w.retained,
)
if len(diagnostics) != 0 {
return &diagnostics[0]
}
*node = resolved.root
for pointer, entry := range w.collectionPolicies.collections {
if pathHasPrefix(entry.destination, path) {
delete(w.collectionPolicies.collections, pointer)
}
}
for _, entry := range resolved.collectionPolicies.collections {
w.collectionPolicies.set(entry.destination, entry.policy)
}
deleteOriginSubtree(w.origins, path, original)
for pointer, origin := range resolved.origins {
w.origins[pointer] = origin
}
w.transfers = append(w.transfers, resolved.transfers...)
w.controls = append(w.controls, resolved.controls...)
w.replacements = append(w.replacements, resolved.replacements...)
w.operations = append(w.operations, resolved.operations...)
after, _ := inheritanceLowerBoundCount(*node, -1)
w.nodes += after - before
return nil
}
func scopeHasInheritanceDefinitions(scope *document.Node) bool {
for _, entry := range scope.Mapping {
if entry.Value.Kind != document.Mapping {