Resolve collection identity during strict composition
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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
|
||||
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
|
||||
|
||||
Scope: adapt pending-item/helper composition to Stage 11's core using the acquired
|
||||
|
||||
@@ -32,6 +32,22 @@ func resolveCollectionPolicy(
|
||||
applicationIdentityFields []string,
|
||||
lower *collectionPolicy,
|
||||
) (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)
|
||||
if len(applicationIdentityFields) != 0 {
|
||||
var err error
|
||||
@@ -44,8 +60,8 @@ func resolveCollectionPolicy(
|
||||
}
|
||||
}
|
||||
result := collectionPolicyResolution{
|
||||
declarations: cloneSequenceDeclarations(declarations),
|
||||
declarationsPresent: len(declarations) != 0,
|
||||
declarations: normalizedDeclarations,
|
||||
declarationsPresent: len(normalizedDeclarations) != 0,
|
||||
applicationIdentityFields: normalizedApplicationIdentityFields,
|
||||
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
|
||||
}
|
||||
@@ -55,7 +71,7 @@ func resolveCollectionPolicy(
|
||||
var declaredIdentityLocation *Location
|
||||
var declaredMode sequenceMode
|
||||
var declaredModeLocation *Location
|
||||
for _, declaration := range declarations {
|
||||
for _, declaration := range normalizedDeclarations {
|
||||
if declaration.identityPresent {
|
||||
result.identityDeclarationPresent = true
|
||||
if declaredIdentityFields == nil {
|
||||
|
||||
@@ -6,16 +6,17 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
// precedence layers. Claims remain ordered by source and declaration traversal;
|
||||
// origins retain every source that contributes to a composite mapping.
|
||||
type composedDocument struct {
|
||||
root *document.Node
|
||||
acquisition []acquisitionScope
|
||||
importGraph importInclusionGraph
|
||||
claims []compositionClaim
|
||||
sequenceRanges []sequenceRange
|
||||
origins map[string]Origin
|
||||
overlays []overlayRecord
|
||||
applications []historyApplication
|
||||
inputBytes int64
|
||||
inputNodes int
|
||||
root *document.Node
|
||||
acquisition []acquisitionScope
|
||||
importGraph importInclusionGraph
|
||||
collectionPolicies collectionPolicyScope
|
||||
claims []compositionClaim
|
||||
sequenceRanges []sequenceRange
|
||||
origins map[string]Origin
|
||||
overlays []overlayRecord
|
||||
applications []historyApplication
|
||||
inputBytes int64
|
||||
inputNodes int
|
||||
}
|
||||
|
||||
type sequenceHistoryMode uint8
|
||||
@@ -54,10 +55,11 @@ func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||
}
|
||||
return &composedDocument{
|
||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||
acquisition: cloneAcquisitionScopes(base.acquisition),
|
||||
importGraph: base.importGraph.clone(),
|
||||
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
acquisition: cloneAcquisitionScopes(base.acquisition),
|
||||
importGraph: base.importGraph.clone(),
|
||||
collectionPolicies: cloneCollectionPolicyScope(base.collectionPolicies),
|
||||
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
applications: cloneHistoryApplications(base.applications),
|
||||
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
|
||||
// deliberately preserves the existing unregistered behavior for current callers.
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
identityFieldsByPath, collectionPolicies, diagnostics := resolveStrictCollectionPolicies(
|
||||
collected.ordered, policy, lower, declarations,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
checked := make(map[string]struct{}, len(collected.byPath))
|
||||
for _, claim := range collected.ordered {
|
||||
pointer := claim.path.String()
|
||||
@@ -163,13 +183,13 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
|
||||
}
|
||||
checked[pointer] = struct{}{}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
@@ -177,7 +197,7 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
|
||||
root := document.Clone(inputs[0])
|
||||
index := new(mappingIndex)
|
||||
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))
|
||||
seenSequences := make(map[string]bool)
|
||||
@@ -204,11 +224,12 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
|
||||
origins := projectedCompositionOrigins(inputs, ranges)
|
||||
completeCompositionOrigins(root, Path{}, origins)
|
||||
return &composedDocument{
|
||||
root: root,
|
||||
claims: cloneCompositionClaims(collected.ordered),
|
||||
sequenceRanges: cloneSequenceRanges(ranges),
|
||||
origins: origins,
|
||||
applications: applications,
|
||||
root: root,
|
||||
collectionPolicies: collectionPolicies,
|
||||
claims: cloneCompositionClaims(collected.ordered),
|
||||
sequenceRanges: cloneSequenceRanges(ranges),
|
||||
origins: origins,
|
||||
applications: applications,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -264,106 +285,140 @@ type validatedSequence struct {
|
||||
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 = ©
|
||||
}
|
||||
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)
|
||||
identitiesByPath := make(map[string]map[string]Location)
|
||||
identitiesByPath := make(map[string]map[string]document.Location)
|
||||
lengthsByPath := make(map[string]int)
|
||||
var ranges []sequenceRange
|
||||
for _, claim := range claims {
|
||||
if claim.kind != document.Sequence {
|
||||
continue
|
||||
}
|
||||
identityFields, registered := policy.identityFieldsAt(claim.path)
|
||||
if !registered {
|
||||
identityFields := identityFieldsByPath[claim.path.String()]
|
||||
if len(identityFields) == 0 {
|
||||
continue
|
||||
}
|
||||
node := claim.node
|
||||
pathKey := claim.path.String()
|
||||
identities := identitiesByPath[pathKey]
|
||||
if identities == nil {
|
||||
identities = make(map[string]Location, len(node.Sequence))
|
||||
identities = make(map[string]document.Location, len(node.Sequence))
|
||||
identitiesByPath[pathKey] = identities
|
||||
}
|
||||
start := lengthsByPath[pathKey]
|
||||
contribution := validatedSequence{items: node.Sequence}
|
||||
for itemIndex, item := range node.Sequence {
|
||||
itemPath := claim.path.Index(start + itemIndex)
|
||||
if item == nil || item.Kind != document.Mapping {
|
||||
location := (*Location)(nil)
|
||||
if item != nil {
|
||||
converted := publicLocation(item.Location)
|
||||
location = &converted
|
||||
}
|
||||
return nil, nil, []Diagnostic{{
|
||||
Code: CodeComposeInvalid, Severity: SeverityError,
|
||||
Path: itemPath, Message: "registered sequence items must be mappings", Primary: 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)
|
||||
records, diagnostics := validateNormalizedSequenceIdentityFrom(node, claim.path, identityFields, sequenceIdentityDiagnosticCodes{
|
||||
invalid: CodeComposeInvalid, conflict: CodeComposeConflict,
|
||||
}, start)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, nil, diagnostics
|
||||
}
|
||||
byIndex := make([]sequenceIdentityRecord, len(node.Sequence))
|
||||
for _, record := range records {
|
||||
byIndex[record.index] = record
|
||||
}
|
||||
for _, record := range byIndex {
|
||||
if first, duplicate := identities[record.tuple]; duplicate {
|
||||
current := publicLocation(record.location)
|
||||
return nil, nil, []Diagnostic{{
|
||||
Code: CodeComposeConflict, Severity: SeverityError,
|
||||
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 {
|
||||
itemsByPath[pathKey] = make(map[int]validatedSequence)
|
||||
@@ -377,7 +432,7 @@ func validateRegisteredSequences(claims []compositionClaim, policy sequencePolic
|
||||
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 {
|
||||
entryPath := path.Key(incomingEntry.Key)
|
||||
position := index.find(target, incomingEntry.Key)
|
||||
@@ -388,7 +443,7 @@ func mergeComposedMappings(target, incoming *document.Node, path Path, index *ma
|
||||
continue
|
||||
}
|
||||
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]
|
||||
if contribution.items != nil {
|
||||
for _, item := range contribution.items {
|
||||
@@ -397,15 +452,15 @@ func mergeComposedMappings(target, incoming *document.Node, path Path, index *ma
|
||||
}
|
||||
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 {
|
||||
return
|
||||
}
|
||||
mergeComposedMappings(target, incoming, path, index, sourceIndex, policy, validated)
|
||||
mergeComposedMappings(target, incoming, path, index, sourceIndex, identityFieldsByPath, validated)
|
||||
}
|
||||
|
||||
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
|
||||
|
||||
@@ -437,10 +437,7 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
|
||||
wantCode Code
|
||||
}{
|
||||
{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: "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},
|
||||
{name: "missing identity", body: "records: [{name: plain}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
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) {
|
||||
_, diagnostics := composeDocumentsWithPolicy(
|
||||
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
|
||||
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
|
||||
parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1" {
|
||||
t.Fatalf("diagnostics = %#v, want invalid item at /records/1", diagnostics)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1/id" {
|
||||
t.Fatalf("diagnostics = %#v, want invalid identity field at /records/1/id", diagnostics)
|
||||
}
|
||||
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("malformed item location = %#v, want source-2", diagnostics[0].Primary)
|
||||
|
||||
@@ -214,6 +214,10 @@ func (index sequenceMappingIndex) identity(fields []string) (string, []document.
|
||||
// validateSequenceIdentity checks mapping shape, direct identity fields, and
|
||||
// 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) {
|
||||
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)
|
||||
if err != 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 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 {
|
||||
location := (*Location)(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))
|
||||
for itemIndex, item := range sequence.Sequence {
|
||||
itemPath := path.Index(itemIndex)
|
||||
itemPath := path.Index(startIndex + itemIndex)
|
||||
if item == nil || item.Kind != document.Mapping {
|
||||
var primary *Location
|
||||
if item != nil {
|
||||
|
||||
Reference in New Issue
Block a user