Resolve collection identity during strict composition

This commit is contained in:
2026-10-23 18:39:04 +00:00
parent 628fc53d2c
commit d03e56398b
5 changed files with 319 additions and 116 deletions

View File

@@ -1,6 +1,6 @@
# Sequence Identity And Combination Implementation Plan # Sequence Identity And Combination Implementation Plan
Status: implementation in progress; Stages 1–10 complete. Status: implementation in progress; Stages 1–11 complete.
Audience: a Luna coding agent implementing one numbered stage per prompt, in Audience: a Luna coding agent implementing one numbered stage per prompt, in
order. This plan starts at commit order. This plan starts at commit
@@ -440,6 +440,9 @@ Validation: composition and sequence identity tests plus the everyday check.
Keep unregistered non-mapping conflicts unchanged. Public load activation remains Keep unregistered non-mapping conflicts unchanged. Public load activation remains
in the later integration stages. in the later integration stages.
**Completion:** Implemented and verified with focused composition, collection
policy, and sequence identity tests plus `./scripts/check.sh`.
## Stage 12 — Integrate Policy-Aware Item And Helper Scopes ## Stage 12 — Integrate Policy-Aware Item And Helper Scopes
Scope: adapt pending-item/helper composition to Stage 11's core using the acquired Scope: adapt pending-item/helper composition to Stage 11's core using the acquired

View File

@@ -32,6 +32,22 @@ func resolveCollectionPolicy(
applicationIdentityFields []string, applicationIdentityFields []string,
lower *collectionPolicy, lower *collectionPolicy,
) (collectionPolicyResolution, []Diagnostic) { ) (collectionPolicyResolution, []Diagnostic) {
normalizedDeclarations := cloneSequenceDeclarations(declarations)
for index := range normalizedDeclarations {
declaration := &normalizedDeclarations[index]
if !declaration.identityPresent {
continue
}
fields, err := normalizeSequenceIdentityFields(declaration.identityFields)
if err != nil {
return collectionPolicyResolution{}, []Diagnostic{{
Code: CodeSequenceInvalid, Severity: SeverityError, Path: destination.clone(),
Message: "collection identity declaration requires at least one field",
Primary: collectionDeclarationLocation(declaration.identityLocation, declaration.location),
}}
}
declaration.identityFields = fields
}
normalizedApplicationIdentityFields := cloneStrings(applicationIdentityFields) normalizedApplicationIdentityFields := cloneStrings(applicationIdentityFields)
if len(applicationIdentityFields) != 0 { if len(applicationIdentityFields) != 0 {
var err error var err error
@@ -44,8 +60,8 @@ func resolveCollectionPolicy(
} }
} }
result := collectionPolicyResolution{ result := collectionPolicyResolution{
declarations: cloneSequenceDeclarations(declarations), declarations: normalizedDeclarations,
declarationsPresent: len(declarations) != 0, declarationsPresent: len(normalizedDeclarations) != 0,
applicationIdentityFields: normalizedApplicationIdentityFields, applicationIdentityFields: normalizedApplicationIdentityFields,
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0, applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
} }
@@ -55,7 +71,7 @@ func resolveCollectionPolicy(
var declaredIdentityLocation *Location var declaredIdentityLocation *Location
var declaredMode sequenceMode var declaredMode sequenceMode
var declaredModeLocation *Location var declaredModeLocation *Location
for _, declaration := range declarations { for _, declaration := range normalizedDeclarations {
if declaration.identityPresent { if declaration.identityPresent {
result.identityDeclarationPresent = true result.identityDeclarationPresent = true
if declaredIdentityFields == nil { if declaredIdentityFields == nil {

View File

@@ -6,16 +6,17 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// precedence layers. Claims remain ordered by source and declaration traversal; // precedence layers. Claims remain ordered by source and declaration traversal;
// origins retain every source that contributes to a composite mapping. // origins retain every source that contributes to a composite mapping.
type composedDocument struct { type composedDocument struct {
root *document.Node root *document.Node
acquisition []acquisitionScope acquisition []acquisitionScope
importGraph importInclusionGraph importGraph importInclusionGraph
claims []compositionClaim collectionPolicies collectionPolicyScope
sequenceRanges []sequenceRange claims []compositionClaim
origins map[string]Origin sequenceRanges []sequenceRange
overlays []overlayRecord origins map[string]Origin
applications []historyApplication overlays []overlayRecord
inputBytes int64 applications []historyApplication
inputNodes int inputBytes int64
inputNodes int
} }
type sequenceHistoryMode uint8 type sequenceHistoryMode uint8
@@ -54,10 +55,11 @@ func cloneComposedDocument(base *composedDocument) *composedDocument {
} }
return &composedDocument{ return &composedDocument{
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims), root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
acquisition: cloneAcquisitionScopes(base.acquisition), acquisition: cloneAcquisitionScopes(base.acquisition),
importGraph: base.importGraph.clone(), importGraph: base.importGraph.clone(),
sequenceRanges: cloneSequenceRanges(base.sequenceRanges), collectionPolicies: cloneCollectionPolicyScope(base.collectionPolicies),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays), sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
applications: cloneHistoryApplications(base.applications), applications: cloneHistoryApplications(base.applications),
inputBytes: base.inputBytes, inputNodes: base.inputNodes, inputBytes: base.inputBytes, inputNodes: base.inputNodes,
} }
@@ -138,6 +140,17 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
// sequence rules when policy contains registrations. The ordinary adapter above // sequence rules when policy contains registrations. The ordinary adapter above
// deliberately preserves the existing unregistered behavior for current callers. // deliberately preserves the existing unregistered behavior for current callers.
func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) (*composedDocument, []Diagnostic) { func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) (*composedDocument, []Diagnostic) {
return composeDocumentsWithCollectionPolicy(policy, collectionPolicyScope{}, nil, inputs...)
}
// composeDocumentsWithCollectionPolicy resolves collection declarations and
// eligible lower policy before strictly composing completed peer documents.
func composeDocumentsWithCollectionPolicy(
policy sequencePolicy,
lower collectionPolicyScope,
declarations []sequenceDeclarationSpec,
inputs ...*document.Node,
) (*composedDocument, []Diagnostic) {
if len(inputs) == 0 { if len(inputs) == 0 {
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)} return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
} }
@@ -155,6 +168,13 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
collected.append(input, Path{}, sourceIndex) collected.append(input, Path{}, sourceIndex)
} }
identityFieldsByPath, collectionPolicies, diagnostics := resolveStrictCollectionPolicies(
collected.ordered, policy, lower, declarations,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
checked := make(map[string]struct{}, len(collected.byPath)) checked := make(map[string]struct{}, len(collected.byPath))
for _, claim := range collected.ordered { for _, claim := range collected.ordered {
pointer := claim.path.String() pointer := claim.path.String()
@@ -163,13 +183,13 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
} }
checked[pointer] = struct{}{} checked[pointer] = struct{}{}
claims := collected.byPath[pointer] claims := collected.byPath[pointer]
if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && policy.registered(claim.path)) { if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && len(identityFieldsByPath[pointer]) != 0) {
continue continue
} }
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)} return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
} }
validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, policy) validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, identityFieldsByPath)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, diagnostics return nil, diagnostics
} }
@@ -177,7 +197,7 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
root := document.Clone(inputs[0]) root := document.Clone(inputs[0])
index := new(mappingIndex) index := new(mappingIndex)
for sourceIndex, input := range inputs[1:] { for sourceIndex, input := range inputs[1:] {
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, policy, validated) mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, identityFieldsByPath, validated)
} }
applications := make([]historyApplication, 0, len(inputs)) applications := make([]historyApplication, 0, len(inputs))
seenSequences := make(map[string]bool) seenSequences := make(map[string]bool)
@@ -204,11 +224,12 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
origins := projectedCompositionOrigins(inputs, ranges) origins := projectedCompositionOrigins(inputs, ranges)
completeCompositionOrigins(root, Path{}, origins) completeCompositionOrigins(root, Path{}, origins)
return &composedDocument{ return &composedDocument{
root: root, root: root,
claims: cloneCompositionClaims(collected.ordered), collectionPolicies: collectionPolicies,
sequenceRanges: cloneSequenceRanges(ranges), claims: cloneCompositionClaims(collected.ordered),
origins: origins, sequenceRanges: cloneSequenceRanges(ranges),
applications: applications, origins: origins,
applications: applications,
}, nil }, nil
} }
@@ -264,106 +285,140 @@ type validatedSequence struct {
items []*document.Node items []*document.Node
} }
func validateRegisteredSequences(claims []compositionClaim, policy sequencePolicy) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) { func resolveStrictCollectionPolicies(
claims []compositionClaim,
applicationPolicy sequencePolicy,
lower collectionPolicyScope,
declarations []sequenceDeclarationSpec,
) (map[string][]string, collectionPolicyScope, []Diagnostic) {
paths := make([]Path, 0, len(claims)+len(declarations))
seen := make(map[string]bool, len(claims)+len(declarations))
sequencePresent := make(map[string]bool)
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
for _, claim := range claims {
if claim.kind != document.Sequence {
continue
}
key := claim.path.String()
sequencePresent[key] = true
if !seen[key] {
seen[key] = true
paths = append(paths, claim.path.clone())
}
}
for _, declaration := range declarations {
key := declaration.destination.String()
declarationsByPath[key] = append(declarationsByPath[key], declaration)
if !seen[key] {
seen[key] = true
paths = append(paths, declaration.destination.clone())
}
}
identityFieldsByPath := make(map[string][]string)
collectionPolicies := cloneCollectionPolicyScope(lower)
var diagnostics []Diagnostic
for _, path := range paths {
key := path.String()
currentDeclarations := declarationsByPath[key]
lowerEntry, hasLower := lower.collections[key]
var lowerPolicy *collectionPolicy
if hasLower {
copy := cloneCollectionPolicy(lowerEntry.policy)
lowerPolicy = &copy
}
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
// A replacing value drops the old YAML identity before this tier's
// peers are checked. The explicit operation itself remains applicable.
selectedMode := sequenceMode(0)
for _, declaration := range currentDeclarations {
if declaration.modePresent {
selectedMode = declaration.mode
break
}
}
if selectedMode == 0 && lowerPolicy != nil && lowerPolicy.explicitModePresent {
selectedMode = lowerPolicy.explicitMode
}
if sequencePresent[key] && selectedMode == sequenceModeReplace && lowerPolicy != nil {
lowerPolicy.identityFields = nil
lowerPolicy.identityPresent = false
lowerPolicy.identityLocation = nil
}
resolution, found := resolveCollectionPolicy(path, currentDeclarations, applicationIdentity, lowerPolicy)
if len(found) != 0 {
diagnostics = append(diagnostics, found...)
continue
}
if len(resolution.effectiveIdentityFields) != 0 {
identityFieldsByPath[key] = cloneStrings(resolution.effectiveIdentityFields)
}
policyForPath := resolution.policy
if hasLower && !sequencePresent[key] {
// A declaration without a new value changes policy metadata but
// leaves retained record ownership untouched.
policyForPath = cloneCollectionPolicy(lowerEntry.policy)
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
policyForPath.identityPresent = resolution.policy.identityPresent
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
policyForPath.explicitMode = resolution.policy.explicitMode
policyForPath.explicitModePresent = resolution.policy.explicitModePresent
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
}
if collectionPolicies.collections == nil {
collectionPolicies.collections = make(map[string]collectionPolicyEntry)
}
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
}
return identityFieldsByPath, collectionPolicies, canonicalDiagnostics(diagnostics)
}
func validateRegisteredSequences(claims []compositionClaim, identityFieldsByPath map[string][]string) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
itemsByPath := make(map[string]map[int]validatedSequence) itemsByPath := make(map[string]map[int]validatedSequence)
identitiesByPath := make(map[string]map[string]Location) identitiesByPath := make(map[string]map[string]document.Location)
lengthsByPath := make(map[string]int) lengthsByPath := make(map[string]int)
var ranges []sequenceRange var ranges []sequenceRange
for _, claim := range claims { for _, claim := range claims {
if claim.kind != document.Sequence { if claim.kind != document.Sequence {
continue continue
} }
identityFields, registered := policy.identityFieldsAt(claim.path) identityFields := identityFieldsByPath[claim.path.String()]
if !registered { if len(identityFields) == 0 {
continue continue
} }
node := claim.node node := claim.node
pathKey := claim.path.String() pathKey := claim.path.String()
identities := identitiesByPath[pathKey] identities := identitiesByPath[pathKey]
if identities == nil { if identities == nil {
identities = make(map[string]Location, len(node.Sequence)) identities = make(map[string]document.Location, len(node.Sequence))
identitiesByPath[pathKey] = identities identitiesByPath[pathKey] = identities
} }
start := lengthsByPath[pathKey] start := lengthsByPath[pathKey]
contribution := validatedSequence{items: node.Sequence} contribution := validatedSequence{items: node.Sequence}
for itemIndex, item := range node.Sequence { records, diagnostics := validateNormalizedSequenceIdentityFrom(node, claim.path, identityFields, sequenceIdentityDiagnosticCodes{
itemPath := claim.path.Index(start + itemIndex) invalid: CodeComposeInvalid, conflict: CodeComposeConflict,
if item == nil || item.Kind != document.Mapping { }, start)
location := (*Location)(nil) if len(diagnostics) != 0 {
if item != nil { return nil, nil, diagnostics
converted := publicLocation(item.Location) }
location = &converted byIndex := make([]sequenceIdentityRecord, len(node.Sequence))
} for _, record := range records {
return nil, nil, []Diagnostic{{ byIndex[record.index] = record
Code: CodeComposeInvalid, Severity: SeverityError, }
Path: itemPath, Message: "registered sequence items must be mappings", Primary: location, for _, record := range byIndex {
}} if first, duplicate := identities[record.tuple]; duplicate {
} current := publicLocation(record.location)
var identity string
var identityLocation document.Location
if legacyField, legacy := policy.identityFields[claim.path.String()]; legacy {
fieldIndex := documentEntryIndex(item.Mapping, legacyField)
if fieldIndex < 0 {
location := publicLocation(item.Location)
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath, Message: "registered sequence item is missing its identity field",
Primary: &location,
}}
}
field := item.Mapping[fieldIndex].Value
if field == nil || field.Kind != document.String {
location := publicLocation(item.Mapping[fieldIndex].ValueLocation)
if field != nil {
location = publicLocation(field.Location)
}
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath.Key(legacyField), Message: "registered sequence identity must be a string",
Primary: &location,
}}
}
identity, identityLocation = field.Text, field.Location
} else {
mapping := newSequenceMappingIndex(item)
var locations []document.Location
var fieldName string
var fieldIndex int
identity, locations, fieldName, fieldIndex = mapping.identity(identityFields)
if fieldIndex == -1 {
location := publicLocation(item.Location)
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath.Key(fieldName), Message: "registered sequence item is missing an identity field",
Primary: &location,
}}
}
if fieldIndex == -2 || fieldIndex >= 0 {
location := publicLocation(item.Location)
fieldPath := itemPath.Key(fieldName)
if fieldIndex >= 0 {
location = publicLocation(item.Mapping[fieldIndex].ValueLocation)
if field := item.Mapping[fieldIndex].Value; field != nil {
location = publicLocation(field.Location)
}
}
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: fieldPath, Message: "registered sequence identity fields must be scalar values",
Primary: &location,
}}
}
identityLocation = locations[0]
}
if first, duplicate := identities[identity]; duplicate {
current := publicLocation(identityLocation)
return nil, nil, []Diagnostic{{ return nil, nil, []Diagnostic{{
Code: CodeComposeConflict, Severity: SeverityError, Code: CodeComposeConflict, Severity: SeverityError,
Path: claim.path.clone(), Message: "registered sequence contains duplicate identities", Path: claim.path.clone(), Message: "registered sequence contains duplicate identities",
Primary: &first, Related: []Location{current}, Primary: ptrLocation(publicLocation(first)), Related: []Location{current},
}} }}
} }
identities[identity] = publicLocation(identityLocation) identities[record.tuple] = record.location
} }
if itemsByPath[pathKey] == nil { if itemsByPath[pathKey] == nil {
itemsByPath[pathKey] = make(map[int]validatedSequence) itemsByPath[pathKey] = make(map[int]validatedSequence)
@@ -377,7 +432,7 @@ func validateRegisteredSequences(claims []compositionClaim, policy sequencePolic
return itemsByPath, ranges, nil return itemsByPath, ranges, nil
} }
func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) { func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, identityFieldsByPath map[string][]string, validated map[string]map[int]validatedSequence) {
for _, incomingEntry := range incoming.Mapping { for _, incomingEntry := range incoming.Mapping {
entryPath := path.Key(incomingEntry.Key) entryPath := path.Key(incomingEntry.Key)
position := index.find(target, incomingEntry.Key) position := index.find(target, incomingEntry.Key)
@@ -388,7 +443,7 @@ func mergeComposedMappings(target, incoming *document.Node, path Path, index *ma
continue continue
} }
current := target.Mapping[position].Value current := target.Mapping[position].Value
if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && policy.registered(entryPath) { if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && len(identityFieldsByPath[entryPath.String()]) != 0 {
contribution := validated[entryPath.String()][sourceIndex] contribution := validated[entryPath.String()][sourceIndex]
if contribution.items != nil { if contribution.items != nil {
for _, item := range contribution.items { for _, item := range contribution.items {
@@ -397,15 +452,15 @@ func mergeComposedMappings(target, incoming *document.Node, path Path, index *ma
} }
continue continue
} }
mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, policy, validated) mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, identityFieldsByPath, validated)
} }
} }
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) { func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, identityFieldsByPath map[string][]string, validated map[string]map[int]validatedSequence) {
if target.Kind != document.Mapping || incoming.Kind != document.Mapping { if target.Kind != document.Mapping || incoming.Kind != document.Mapping {
return return
} }
mergeComposedMappings(target, incoming, path, index, sourceIndex, policy, validated) mergeComposedMappings(target, incoming, path, index, sourceIndex, identityFieldsByPath, validated)
} }
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange { func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {

View File

@@ -437,10 +437,7 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
wantCode Code wantCode Code
}{ }{
{name: "nonmapping item", body: "records: [plain]\n", path: "/records/0", wantCode: CodeComposeInvalid}, {name: "nonmapping item", body: "records: [plain]\n", path: "/records/0", wantCode: CodeComposeInvalid},
{name: "missing identity", body: "records: [{name: plain}]\n", path: "/records/0", wantCode: CodeComposeInvalid}, {name: "missing identity", body: "records: [{name: plain}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
{name: "null identity", body: "records: [{id: null}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
{name: "numeric identity", body: "records: [{id: 12}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
{name: "boolean identity", body: "records: [{id: false}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -458,14 +455,138 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
} }
} }
func TestComposeDocumentsUsesLowerIdentityForStrictPeers(t *testing.T) {
path := Keys("records")
lower := collectionPolicyScope{collections: map[string]collectionPolicyEntry{
path.String(): {
destination: path,
policy: collectionPolicy{identityFields: []string{"id"}, identityPresent: true},
},
}}
composed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, nil,
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{id: second}]\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("compose strict peers with lower identity: %#v", diagnostics)
}
value, present := documentAtPath(composed.root, path)
if !present || value.Kind != document.Sequence || len(value.Sequence) != 2 {
t.Fatalf("composed records = %#v, %v; want two strict peer records", value, present)
}
resolved, present := composed.collectionPolicies.collections[path.String()]
if !present || !resolved.policy.identityPresent || !reflect.DeepEqual(resolved.policy.identityFields, []string{"id"}) {
t.Fatalf("composed collection policy = %#v, %v; want retained lower identity", resolved, present)
}
clone := cloneComposedDocument(composed)
resolved.policy.identityFields[0] = "mutated"
if !reflect.DeepEqual(clone.collectionPolicies.collections[path.String()].policy.identityFields, []string{"id"}) {
t.Fatal("cloned composition shares mutable collection identity fields")
}
}
func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) {
path := Keys("records")
declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec {
location := Location{SourceID: source, Line: line}
return sequenceDeclarationSpec{
destination: path, identityFields: fields, identityPresent: true,
identityLocation: &location,
}
}
_, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, collectionPolicyScope{},
[]sequenceDeclarationSpec{declaration("source-1", 2, "id"), declaration("source-2", 4, "name")},
parseCompositionDocument(t, "source-1", "left: true\n"),
parseCompositionDocument(t, "source-2", "right: true\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/records" {
t.Fatalf("conflicting policy-only declarations = %#v", diagnostics)
}
if diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
t.Fatalf("declaration conflict lacks both locations: %#v", diagnostics[0])
}
composed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, collectionPolicyScope{},
[]sequenceDeclarationSpec{declaration("source-1", 2, "tenant", "id"), declaration("source-2", 4, "id", "tenant")},
parseCompositionDocument(t, "source-1", "records: [{tenant: true, id: null}, {tenant: \"alpha\", id: 1}]\n"),
parseCompositionDocument(t, "source-2", "records: [{id: 1.0, tenant: \"beta\"}, {id: \"1\", tenant: \"alpha\"}]\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("compose normalized compound identities: %#v", diagnostics)
}
value, present := documentAtPath(composed.root, path)
if !present || len(value.Sequence) != 4 {
t.Fatalf("compound identity records = %#v, %v; want four distinct records", value, present)
}
_, diagnostics = composeDocumentsWithCollectionPolicy(
sequencePolicy{}, collectionPolicyScope{},
[]sequenceDeclarationSpec{declaration("source-1", 2, "id")},
parseCompositionDocument(t, "source-1", "records: [{id: 1}]\n"),
parseCompositionDocument(t, "source-2", "records: [{id: 1.0}]\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
t.Fatalf("equivalent numeric peer identities = %#v", diagnostics)
}
}
func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T) {
path := Keys("records")
lower := collectionPolicyScope{collections: map[string]collectionPolicyEntry{
path.String(): {
destination: path,
policy: collectionPolicy{identityFields: []string{"id"}, identityPresent: true},
},
}}
declaration := sequenceDeclarationSpec{destination: path, mode: sequenceModeReplace, modePresent: true}
_, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
parseCompositionDocument(t, "source-1", "records: [{name: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{name: second}]\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
t.Fatalf("replacement peers borrowing lower identity = %#v", diagnostics)
}
identityReplacement := sequenceDeclarationSpec{
destination: path, identityFields: []string{"name"}, identityPresent: true,
mode: sequenceModeReplace, modePresent: true,
}
composed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, []sequenceDeclarationSpec{identityReplacement},
parseCompositionDocument(t, "source-1", "records: [{name: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{name: second}]\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("replacement with a new identity declaration: %#v", diagnostics)
}
resolved := composed.collectionPolicies.collections[path.String()].policy
if !reflect.DeepEqual(resolved.identityFields, []string{"name"}) || !resolved.identityPresent {
t.Fatalf("replacement collection identity = %#v; want name", resolved)
}
_, diagnostics = composeDocumentsWithCollectionPolicy(
sequencePolicy{identityFields: map[string]string{path.String(): "id"}}, lower,
[]sequenceDeclarationSpec{declaration},
parseCompositionDocument(t, "source-1", "records: [{name: replacement}]\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/0/id" {
t.Fatalf("replacement ignored application identity constraint = %#v", diagnostics)
}
}
func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) { func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy( _, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}}, sequencePolicy{identityFields: map[string]string{"/records": "id"}},
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"), parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\n"), parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\n"),
) )
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1" { if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1/id" {
t.Fatalf("diagnostics = %#v, want invalid item at /records/1", diagnostics) t.Fatalf("diagnostics = %#v, want invalid identity field at /records/1/id", diagnostics)
} }
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" { if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
t.Fatalf("malformed item location = %#v, want source-2", diagnostics[0].Primary) t.Fatalf("malformed item location = %#v, want source-2", diagnostics[0].Primary)

View File

@@ -214,6 +214,10 @@ func (index sequenceMappingIndex) identity(fields []string) (string, []document.
// validateSequenceIdentity checks mapping shape, direct identity fields, and // validateSequenceIdentity checks mapping shape, direct identity fields, and
// uniqueness in one tuple-to-first-location index. It does not alter the nodes. // uniqueness in one tuple-to-first-location index. It does not alter the nodes.
func validateSequenceIdentity(sequence *document.Node, path Path, fields []string, codes sequenceIdentityDiagnosticCodes) (map[string]sequenceIdentityRecord, []Diagnostic) { func validateSequenceIdentity(sequence *document.Node, path Path, fields []string, codes sequenceIdentityDiagnosticCodes) (map[string]sequenceIdentityRecord, []Diagnostic) {
return validateSequenceIdentityFrom(sequence, path, fields, codes, 0)
}
func validateSequenceIdentityFrom(sequence *document.Node, path Path, fields []string, codes sequenceIdentityDiagnosticCodes, startIndex int) (map[string]sequenceIdentityRecord, []Diagnostic) {
normalized, err := normalizeSequenceIdentityFields(fields) normalized, err := normalizeSequenceIdentityFields(fields)
if err != nil { if err != nil {
location := (*Location)(nil) location := (*Location)(nil)
@@ -223,6 +227,10 @@ func validateSequenceIdentity(sequence *document.Node, path Path, fields []strin
} }
return nil, []Diagnostic{{Code: codes.invalid, Severity: SeverityError, Path: path, Message: "sequence identity requires at least one field", Primary: location}} return nil, []Diagnostic{{Code: codes.invalid, Severity: SeverityError, Path: path, Message: "sequence identity requires at least one field", Primary: location}}
} }
return validateNormalizedSequenceIdentityFrom(sequence, path, normalized, codes, startIndex)
}
func validateNormalizedSequenceIdentityFrom(sequence *document.Node, path Path, normalized []string, codes sequenceIdentityDiagnosticCodes, startIndex int) (map[string]sequenceIdentityRecord, []Diagnostic) {
if sequence == nil || sequence.Kind != document.Sequence { if sequence == nil || sequence.Kind != document.Sequence {
location := (*Location)(nil) location := (*Location)(nil)
if sequence != nil { if sequence != nil {
@@ -234,7 +242,7 @@ func validateSequenceIdentity(sequence *document.Node, path Path, fields []strin
firstByTuple := make(map[string]sequenceIdentityRecord, len(sequence.Sequence)) firstByTuple := make(map[string]sequenceIdentityRecord, len(sequence.Sequence))
for itemIndex, item := range sequence.Sequence { for itemIndex, item := range sequence.Sequence {
itemPath := path.Index(itemIndex) itemPath := path.Index(startIndex + itemIndex)
if item == nil || item.Kind != document.Mapping { if item == nil || item.Kind != document.Mapping {
var primary *Location var primary *Location
if item != nil { if item != nil {