Expose sequence identity registrations through the facade

This commit is contained in:
2026-10-23 20:59:23 +00:00
parent 33a51606da
commit 0023617a07
20 changed files with 530 additions and 212 deletions

View File

@@ -10,17 +10,20 @@ import (
)
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
policy := sequencePolicy{identityFields: make(map[string]string)}
registrations := make([]sequenceIdentityRegistration, 0, 128)
var inputs []*document.Node
for source := 0; source < 3; source++ {
var text strings.Builder
for collection := 0; collection < 128; collection++ {
key := fmt.Sprintf("records%d", collection)
policy.identityFields[Keys(key).String()] = "id"
registrations = append(registrations, sequenceIdentityRegistration{
selector: sequenceIdentitySelector{literalSequenceSelector(key)}, identityFields: []string{"id"},
})
fmt.Fprintf(&text, "%s: [{id: source%d}]\n", key, source)
}
inputs = append(inputs, parseCompositionDocument(t, fmt.Sprintf("source-%d", source), text.String()))
}
policy := mustCompileSequenceIdentityPolicy(t, registrations)
composed, diagnostics := composeDocumentsWithPolicy(policy, inputs...)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
@@ -242,7 +245,7 @@ func TestComposeDocumentsAppendsRegisteredSequencesAndReturnsRanges(t *testing.T
first := parseCompositionDocument(t, "source-1", "records: [{id: '', left: 1}, {id: ' spaced ', right: 1}]\nother: [one]\n")
second := parseCompositionDocument(t, "source-2", "records: [{id: ' ', text: second}]\n")
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}})
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 {
@@ -287,10 +290,10 @@ escaped/~:
name: escaped-second-name
`)
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
policy := sequencePolicy{identityFields: map[string]string{
Keys("mounted", "records").String(): "id",
Keys("escaped/~", "7").String(): "id",
}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("mounted"), literalSequenceSelector("records")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("escaped/~"), literalSequenceSelector("7")}, identityFields: []string{"id"}},
})
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 {
@@ -442,7 +445,7 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}),
parseCompositionDocument(t, "source-1", test.body),
)
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode || diagnostics[0].Path.String() != test.path {
@@ -570,7 +573,7 @@ func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T)
}
_, diagnostics = composeDocumentsWithCollectionPolicy(
sequencePolicy{identityFields: map[string]string{path.String(): "id"}}, lower,
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), lower,
[]sequenceDeclarationSpec{declaration},
parseCompositionDocument(t, "source-1", "records: [{name: replacement}]\n"),
)
@@ -581,7 +584,7 @@ func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T)
func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}),
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\n"),
)
@@ -613,7 +616,7 @@ func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) {
inputs = append(inputs, parseCompositionDocument(t, "source-2", test.second))
}
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}}, inputs...,
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), inputs...,
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
t.Fatalf("diagnostics = %#v, want duplicate conflict at /records", diagnostics)
@@ -631,9 +634,12 @@ func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) {
}
func TestComposeDocumentsSkipsMissingAndNonSequenceRegistrations(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{
"/absent": "id", "/not-a-sequence": "id", "/catalog": "id", "/catalog/records": "id",
}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("absent")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("not-a-sequence")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("catalog")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("records")}, identityFields: []string{"id"}},
})
composed, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "source-1", `
not-a-sequence: null
catalog:
@@ -647,7 +653,7 @@ catalog:
}
composed, diagnostics = composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}),
parseCompositionDocument(t, "source-1", "records: []\n"),
parseCompositionDocument(t, "source-2", "records: []\n"),
)
@@ -664,7 +670,7 @@ catalog:
func TestComposeDocumentsKeepsUnregisteredSequenceConflicts(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/registered": "id"}},
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("registered")}, identityFields: []string{"id"}}}),
parseCompositionDocument(t, "source-1", "unregistered: [one]\n"),
parseCompositionDocument(t, "source-2", "unregistered: [two]\n"),
)
@@ -684,7 +690,7 @@ func TestComposeDocumentsKeepsNonSequenceRegisteredOverlapsConflicting(t *testin
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}),
parseCompositionDocument(t, "source-1", test.first),
parseCompositionDocument(t, "source-2", test.second),
)

View File

@@ -197,8 +197,8 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
t.Parallel()
definition := Definition[map[string]any]{
Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }},
sequenceRegistrations: []sequenceRegistration{{path: Keys("catalog", "entries"), identityField: "id"}},
Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }},
sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}},
}
item := func(label string, value map[string]any) Input {
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}

View File

@@ -10,7 +10,10 @@ import (
)
func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/other": "id"}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("other")}, identityFields: []string{"id"}},
})
first := parseCompositionDocument(t, "source-1", "records: [{id: a, value: first}]\nother: [{id: x}]\n")
second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n")
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
@@ -76,7 +79,10 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
}
func TestSequenceHistoryPreservesTierReplacementAndEmptyLists(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/independent": "id"}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("independent")}, identityFields: []string{"id"}},
})
first, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "underlay-1a", "records: [{id: shared}]\nindependent: [{id: x}]\n"),
parseCompositionDocument(t, "underlay-1b", "records: [{id: lower}]\n"),
@@ -148,7 +154,7 @@ func TestHistoryMergeSupportsSemanticIndexedPrefixes(t *testing.T) {
}
func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}})
composed, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"),
parseCompositionDocument(t, "second", "records: [{id: second, token: private}]"),
@@ -193,7 +199,7 @@ func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T
}
func TestReplacedProjectedSensitiveRecordRemainsRedacted(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}})
composed, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"),
parseCompositionDocument(t, "second", "records: [{id: second, token: PRIVATE-RECORD-CANARY}]"),

View File

@@ -115,7 +115,7 @@ tasks: [{id: z-parent}]
input, diagnostics := assembleBaseInput[config](File(mainPath), Options[config]{
Imports: true,
sequencePolicy: sequencePolicy{identityFields: map[string]string{"/tasks": "id"}},
sequencePolicy: mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}}),
})
if len(diagnostics) != 0 {
t.Fatalf("assemble imports: %#v", diagnostics)
@@ -679,7 +679,7 @@ func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T)
t.Fatalf("assemble item import: %#v", diagnostics)
}
authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n")
composed, diagnostics := composeDocumentsWithPolicy(sequencePolicy{identityFields: map[string]string{"/items": "id"}}, authored, input.document)
composed, diagnostics := composeDocumentsWithPolicy(mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}), authored, input.document)
if len(diagnostics) != 0 {
t.Fatalf("compose authored and imported lists: %#v", diagnostics)
}
@@ -1009,7 +1009,7 @@ func TestItemContributionsUseDependencyReadySchedules(t *testing.T) {
type config struct {
Items []item `yaml:"items"`
}
definition := Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}
definition := Definition[config]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:

View File

@@ -197,7 +197,7 @@ func TestNestedItemPolicyProjectionUsesStableOutputIndexes(t *testing.T) {
func TestOverlaySequenceTransfersIncomingItemPolicyOwnership(t *testing.T) {
path := Keys("records")
registered := mustCompileSequencePolicy(t, []sequenceRegistration{{path: path, identityField: "id"}})
registered := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}})
makePolicies := func(childKey string) collectionPolicyScope {
local := collectionPolicyScope{}
local.set(Keys("steps"), collectionPolicy{

View File

@@ -17,7 +17,7 @@ type Definition[T any] struct {
Version *VersionRule
Secret func(Path) bool
sequenceRegistrations []sequenceRegistration
sequenceIdentityRegistrations []sequenceIdentityRegistration
}
// Options contains the application definition and per-load input selections.
@@ -146,13 +146,13 @@ func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
options, diagnostics := selectOptions(source, supplied)
policy, policyDiagnostics := compileSequencePolicy(options.Definition.sequenceRegistrations)
policy, policyDiagnostics := compileSequenceIdentityPolicy(options.Definition.sequenceIdentityRegistrations)
diagnostics = append(diagnostics, policyDiagnostics...)
if len(diagnostics) != 0 {
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
}
definition := options.Definition
definition.sequenceRegistrations = nil
definition.sequenceIdentityRegistrations = nil
request := loadRequest[T]{
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
definition: definition, importDefaults: snapshotImportDefaults(options.importDefaults), sequencePolicy: policy, overlay: options.Overlay,

View File

@@ -52,10 +52,10 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
var diagnostics []Diagnostic
request.importDefaults = snapshotImportDefaults(request.importDefaults)
diagnostics = append(diagnostics, validateImportDefaults(request.importDefaults)...)
policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations)
policy, policyDiagnostics := compileSequenceIdentityPolicy(request.definition.sequenceIdentityRegistrations)
request.sequencePolicy = policy
diagnostics = append(diagnostics, policyDiagnostics...)
request.definition.sequenceRegistrations = nil
request.definition.sequenceIdentityRegistrations = nil
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,

View File

@@ -46,7 +46,7 @@ items: [{id: b}]
{source: File(zRoot), imports: true},
{source: File(bRoot), imports: true},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}},
}
result, err := testGroupedLoad(request)
if err != nil {
@@ -132,7 +132,7 @@ func TestUnderlayNamespaceAnchorsUseInputPositionsAndFilesystemIdentity(t *testi
{source: secondNamespace.File("root.yml")},
{source: firstNamespace.File("later.yml")},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}},
}
result, err := testGroupedLoad(request)
if err != nil {
@@ -163,7 +163,7 @@ func TestUnderlayItemConflictUsesScheduledGroupIndex(t *testing.T) {
{source: File(zItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
{source: File(aItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}},
}
_, err := testGroupedLoad(request)
loadError, ok := err.(*LoadError)
@@ -207,7 +207,7 @@ func TestUnderlayItemHelperConflictUsesScheduledGroupIndex(t *testing.T) {
{source: File(zBad), imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem},
{source: File(aGood), destination: Keys("items"), placementMode: placementModeSequenceItem},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}},
}
_, err := testGroupedLoad(request)
loadError, ok := err.(*LoadError)
@@ -403,11 +403,11 @@ func TestLayeredCollectionPolicyFlowsBetweenTiers(t *testing.T) {
filesystem := NewFilesystem("overlay", fstest.MapFS{
"overlays/testing.yml": &fstest.MapFile{Data: []byte("configkit:\n sequences:\n - destination: [items]\n key: id\n mode: append\nitems: [{id: overlay}]\n")},
})
definition := Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}
definition := Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}
request = loadRequest[map[string]any]{
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: lower}]\n"))}}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations),
overlay: "testing", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
}
result, err = testGroupedLoad(request)

View File

@@ -1,10 +1,5 @@
package engine
type sequenceRegistration struct {
path Path
identityField string
}
type sequenceSelectorSegmentKind uint8
const (
@@ -22,6 +17,24 @@ type sequenceIdentitySelector []sequenceSelectorSegment
type sequenceIdentityRegistration struct {
selector sequenceIdentitySelector
identityFields []string
invalidPath *Path
}
// SequenceIdentitySelectorSegment carries one root-owned selector segment into
// an engine definition. This transport type remains behind the internal package
// boundary.
type SequenceIdentitySelectorSegment struct {
Key string
AnyMappingKey bool
}
// SequenceIdentityRegistrationInput carries detached root-owned registration
// data into an engine definition without exposing its compiled policy. A
// non-nil ExactPath selects exact-path registration; otherwise Selector is used.
type SequenceIdentityRegistrationInput struct {
ExactPath *Path
Selector []SequenceIdentitySelectorSegment
IdentityFields []string
}
type sequenceSelectorTrie struct {
@@ -32,62 +45,59 @@ type sequenceSelectorTrie struct {
}
type sequencePolicy struct {
// identityFields preserves the old exact-path view for existing internal
// callers while selector matching is introduced behind the same policy.
identityFields map[string]string
selectorTrie *sequenceSelectorTrie
selectorTrie *sequenceSelectorTrie
}
// DefinitionWithSequenceRegistrations attaches facade-owned registrations to a
// detached engine definition without exposing the registration representation.
func DefinitionWithSequenceRegistrations[T any](definition Definition[T], paths []Path, identityFields []string) Definition[T] {
if paths == nil {
definition.sequenceRegistrations = nil
// DefinitionWithSequenceIdentityRegistrations attaches facade-owned
// registrations to a detached engine definition without exposing compiled
// engine policy.
func DefinitionWithSequenceIdentityRegistrations[T any](definition Definition[T], registrations []SequenceIdentityRegistrationInput) Definition[T] {
if registrations == nil {
definition.sequenceIdentityRegistrations = nil
return definition
}
definition.sequenceRegistrations = make([]sequenceRegistration, len(paths))
for index := range paths {
definition.sequenceRegistrations[index] = sequenceRegistration{
path: paths[index].clone(), identityField: identityFields[index],
definition.sequenceIdentityRegistrations = make([]sequenceIdentityRegistration, len(registrations))
for index, registration := range registrations {
compiled := sequenceIdentityRegistration{identityFields: append([]string(nil), registration.IdentityFields...)}
if registration.ExactPath != nil {
path, err := normalizeDestination(registration.ExactPath.clone())
if err != nil {
invalid := registration.ExactPath.clone()
compiled.invalidPath = &invalid
} else {
compiled.selector = make(sequenceIdentitySelector, len(path.segments))
for segmentIndex, segment := range path.segments {
compiled.selector[segmentIndex] = sequenceSelectorSegment{kind: sequenceSelectorLiteral, key: segment.key}
}
}
} else {
compiled.selector = make(sequenceIdentitySelector, len(registration.Selector))
for segmentIndex, segment := range registration.Selector {
kind := sequenceSelectorLiteral
if segment.AnyMappingKey {
kind = sequenceSelectorAnyMappingKey
}
compiled.selector[segmentIndex] = sequenceSelectorSegment{kind: kind, key: segment.Key}
}
}
definition.sequenceIdentityRegistrations[index] = compiled
}
return definition
}
func compileSequencePolicy(registrations []sequenceRegistration) (sequencePolicy, []Diagnostic) {
common := make([]sequenceIdentityRegistration, 0, len(registrations))
var diagnostics []Diagnostic
legacyPaths := make(map[string]string, len(registrations))
for _, registration := range registrations {
path, err := normalizeDestination(registration.path)
if err != nil {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOptionsInvalid, Severity: SeverityError,
Path: registration.path.clone(), Message: "sequence registration path must contain only non-reserved mapping keys",
})
continue
}
selector := make(sequenceIdentitySelector, len(path.segments))
for index, segment := range path.segments {
selector[index] = sequenceSelectorSegment{kind: sequenceSelectorLiteral, key: segment.key}
}
common = append(common, sequenceIdentityRegistration{
selector: selector, identityFields: []string{registration.identityField},
})
legacyPaths[path.String()] = registration.identityField
}
policy, compiledDiagnostics := compileSequenceIdentityPolicy(common)
policy.identityFields = legacyPaths
diagnostics = append(diagnostics, compiledDiagnostics...)
return policy, canonicalDiagnostics(diagnostics)
}
func compileSequenceIdentityPolicy(registrations []sequenceIdentityRegistration) (sequencePolicy, []Diagnostic) {
policy := sequencePolicy{}
compiled := make([]sequenceIdentityRegistration, 0, len(registrations))
var diagnostics []Diagnostic
for _, registration := range registrations {
selector := cloneSequenceIdentitySelector(registration.selector)
if registration.invalidPath != nil {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOptionsInvalid, Severity: SeverityError, Path: registration.invalidPath.clone(),
Message: "sequence registration path must contain only non-reserved mapping keys",
})
continue
}
fields, err := normalizeSequenceIdentityFields(registration.identityFields)
if err != nil {
diagnostics = append(diagnostics, Diagnostic{
@@ -172,11 +182,7 @@ func (p sequencePolicy) identityFieldsAt(path Path) ([]string, bool) {
return nil, false
}
if p.selectorTrie == nil {
field, ok := p.identityFields[path.String()]
if !ok {
return nil, false
}
return []string{field}, true
return nil, false
}
current := []*sequenceSelectorTrie{p.selectorTrie}
for _, segment := range path.segments {

View File

@@ -7,54 +7,44 @@ import (
"testing/fstest"
)
func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
parsed, err := ParsePath("/items/0/a~1b")
if err != nil {
t.Fatal(err)
}
func TestCompileSequenceIdentityPolicyNormalizesRegistrations(t *testing.T) {
tests := []struct {
name string
registrations []sequenceRegistration
registrations []sequenceIdentityRegistration
want map[string]string
wantError bool
}{
{
name: "identical registrations coalesce",
registrations: []sequenceRegistration{
{path: Keys("items"), identityField: "id"},
{path: Keys("items"), identityField: "id"},
registrations: []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}},
},
want: map[string]string{"/items": "id"},
},
{
name: "different identity fields conflict",
registrations: []sequenceRegistration{
{path: Keys("items"), identityField: "id"},
{path: Keys("items"), identityField: "name"},
registrations: []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"name"}},
},
wantError: true,
},
{
name: "empty identity field is literal",
registrations: []sequenceRegistration{{path: Keys("items"), identityField: ""}},
registrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{""}}},
want: map[string]string{"/items": ""},
},
{
name: "numeric and escaped tokens remain literal keys",
registrations: []sequenceRegistration{{path: parsed, identityField: "id"}},
registrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items"), literalSequenceSelector("0"), literalSequenceSelector("a/b")}, identityFields: []string{"id"}}},
want: map[string]string{"/items/0/a~1b": "id"},
},
{
name: "explicit index is invalid",
registrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}},
wantError: true,
},
{
name: "unused and root registrations are accepted",
registrations: []sequenceRegistration{
{path: Path{}, identityField: "id"},
{path: Keys("unused"), identityField: "name"},
registrations: []sequenceIdentityRegistration{
{selector: nil, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("unused")}, identityFields: []string{"name"}},
},
want: map[string]string{"": "id", "/unused": "name"},
},
@@ -63,7 +53,7 @@ func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
policy, diagnostics := compileSequencePolicy(test.registrations)
policy, diagnostics := compileSequenceIdentityPolicy(test.registrations)
if (len(diagnostics) != 0) != test.wantError {
t.Fatalf("diagnostics = %#v, wantError = %t", diagnostics, test.wantError)
}
@@ -73,12 +63,14 @@ func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
}
return
}
if len(policy.identityFields) != len(test.want) {
t.Fatalf("compiled fields = %#v, want %#v", policy.identityFields, test.want)
}
for path, field := range test.want {
if got := policy.identityFields[path]; got != field {
t.Errorf("identity field at %q = %q, want %q", path, got, field)
parsed, err := ParsePath(path)
if err != nil {
t.Fatal(err)
}
gotFields, ok := policy.identityFieldsAt(parsed)
if !ok || len(gotFields) != 1 || gotFields[0] != field {
t.Errorf("identity fields at %q = %#v, want [%q]", path, gotFields, field)
}
}
})
@@ -247,7 +239,7 @@ func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) {
ordinaryReader := strings.NewReader("value: ordinary\n")
_, err := Load[config](Reader("ordinary.yml", ordinaryReader), SecretBinding{}, Options[config]{
Definition: Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}}},
Definition: DefinitionWithSequenceIdentityRegistrations(Definition[config]{}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items").Index(0)), IdentityFields: []string{"id"}}}),
})
requireSequenceRegistrationOptionError(t, err)
if ordinaryReader.Len() == 0 {
@@ -257,7 +249,7 @@ func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) {
mainReader := strings.NewReader("value: main\n")
underlayReader := strings.NewReader("value: underlay\n")
_, err = LoadLayered[config](Input{Source: Reader("main.yml", mainReader)}, SecretBinding{}, LayeredOptions[config]{
Definition: Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}}},
Definition: DefinitionWithSequenceIdentityRegistrations(Definition[config]{}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items").Index(0)), IdentityFields: []string{"id"}}}),
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayReader)}}}},
})
requireSequenceRegistrationOptionError(t, err)
@@ -266,6 +258,8 @@ func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) {
}
}
func pathPointer(path Path) *Path { return &path }
func requireSequenceRegistrationOptionError(t *testing.T, err error) {
t.Helper()
if err == nil {
@@ -289,7 +283,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
type config struct {
Items []item `yaml:"items"`
}
definition := Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}
definition := Definition[config]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}
registered := Options[config]{Definition: definition}
loadCode := func(t *testing.T, err error) Code {
t.Helper()
@@ -327,7 +321,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
{source: Bytes("first", []byte("items: [{id: lower}]\n"))},
{source: Bytes("second", []byte("items: [{id: other}]\n"))},
}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations),
}
result, err := testGroupedLoad(request)
if err != nil || len(result.Config.Items) != 3 || result.Config.Items[0].ID != "lower" || result.Config.Items[1].ID != "other" || result.Config.Items[2].ID != "shared" {
@@ -352,7 +346,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
request := loadRequest[config]{
main: loadInput{source: Bytes("main", []byte("items: []\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: lower}]\n"))}}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations),
}
result, err = testGroupedLoad(request)
if err != nil || len(result.Config.Items) != 1 || result.Config.Items[0].ID != "lower" {
@@ -378,7 +372,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
request := loadRequest[config]{
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: same}, {id: same}]\n"))}}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations),
}
if got := loadCode(t, func() error { _, err := testGroupedLoad(request); return err }()); got != CodeComposeConflict {
t.Fatalf("hidden lower-tier error = %s", got)
@@ -447,9 +441,9 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
})
}
func mustCompileSequencePolicy(t *testing.T, registrations []sequenceRegistration) sequencePolicy {
func mustCompileSequenceIdentityPolicy(t *testing.T, registrations []sequenceIdentityRegistration) sequencePolicy {
t.Helper()
policy, diagnostics := compileSequencePolicy(registrations)
policy, diagnostics := compileSequenceIdentityPolicy(registrations)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}