Expose sequence identity registrations through the facade
This commit is contained in:
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}]"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user