Prepare sequence registration preflight

This commit is contained in:
2026-10-16 15:19:32 +00:00
parent 90c2d7e409
commit ff212885c4
6 changed files with 268 additions and 1 deletions

View File

@@ -16,6 +16,8 @@ type Definition[T any] struct {
Validate func(*T, *Validation)
Version *VersionRule
Secret func(Path) bool
sequenceRegistrations []sequenceRegistration
}
// Options contains the application definition and per-load input selections.
@@ -137,12 +139,16 @@ 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)
diagnostics = append(diagnostics, policyDiagnostics...)
if len(diagnostics) != 0 {
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
}
definition := options.Definition
definition.sequenceRegistrations = nil
request := loadRequest[T]{
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem},
definition: options.Definition, overlay: options.Overlay,
definition: definition, sequencePolicy: policy, overlay: options.Overlay,
overrides: options.Overrides, ordinary: true,
sourceVersionMode: options.SourceVersionMode,
effectiveVersion: effectiveSourceVersionRule(options.Definition.Version, Path{}, options.SourceVersionMode),

View File

@@ -10,6 +10,7 @@ type loadRequest[T any] struct {
main loadInput
underlays []loadGroup
definition Definition[T]
sequencePolicy sequencePolicy
sourceVersionMode sourceVersionMode
effectiveVersion *VersionRule
overlay string
@@ -37,6 +38,10 @@ type loadGroup struct{ inputs []loadInput }
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
request.ordinary = false
var diagnostics []Diagnostic
policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations)
request.sequencePolicy = policy
diagnostics = append(diagnostics, policyDiagnostics...)
request.definition.sequenceRegistrations = nil
if normalized, err := normalizeDestination(request.main.destination); err != nil {
diagnostics = append(diagnostics, invalidDestinationDiagnostic(request.main.destination))
} else {

View File

@@ -0,0 +1,38 @@
package engine
type sequenceRegistration struct {
path Path
identityField string
}
type sequencePolicy struct {
identityFields map[string]string
}
func compileSequencePolicy(registrations []sequenceRegistration) (sequencePolicy, []Diagnostic) {
policy := sequencePolicy{identityFields: make(map[string]string, len(registrations))}
var diagnostics []Diagnostic
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
}
key := path.String()
if current, ok := policy.identityFields[key]; ok {
if current != registration.identityField {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOptionsInvalid, Severity: SeverityError,
Path: path, Message: "sequence registrations at one path must use the same identity field",
})
}
continue
}
policy.identityFields[key] = registration.identityField
}
return policy, canonicalDiagnostics(diagnostics)
}

View File

@@ -0,0 +1,127 @@
package engine
import (
"errors"
"strings"
"testing"
)
func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
parsed, err := ParsePath("/items/0/a~1b")
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
registrations []sequenceRegistration
want map[string]string
wantError bool
}{
{
name: "identical registrations coalesce",
registrations: []sequenceRegistration{
{path: Keys("items"), identityField: "id"},
{path: Keys("items"), identityField: "id"},
},
want: map[string]string{"/items": "id"},
},
{
name: "different identity fields conflict",
registrations: []sequenceRegistration{
{path: Keys("items"), identityField: "id"},
{path: Keys("items"), identityField: "name"},
},
wantError: true,
},
{
name: "empty identity field is literal",
registrations: []sequenceRegistration{{path: Keys("items"), identityField: ""}},
want: map[string]string{"/items": ""},
},
{
name: "numeric and escaped tokens remain literal keys",
registrations: []sequenceRegistration{{path: parsed, identityField: "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"},
},
want: map[string]string{"": "id", "/unused": "name"},
},
{name: "zero option policy", want: map[string]string{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
policy, diagnostics := compileSequencePolicy(test.registrations)
if (len(diagnostics) != 0) != test.wantError {
t.Fatalf("diagnostics = %#v, wantError = %t", diagnostics, test.wantError)
}
if test.wantError {
if diagnostics[0].Code != CodeOptionsInvalid {
t.Fatalf("diagnostic code = %q", diagnostics[0].Code)
}
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)
}
}
})
}
}
func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
}
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"}}},
})
requireSequenceRegistrationOptionError(t, err)
if ordinaryReader.Len() == 0 {
t.Fatal("ordinary reader was consumed before invalid registration was rejected")
}
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"}}},
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayReader)}}}},
})
requireSequenceRegistrationOptionError(t, err)
if mainReader.Len() == 0 || underlayReader.Len() == 0 {
t.Fatal("layered source was consumed before invalid registration was rejected")
}
}
func requireSequenceRegistrationOptionError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected invalid sequence registration error")
}
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("error type = %T, want *LoadError", err)
}
diagnostics := loadErr.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOptionsInvalid {
t.Fatalf("diagnostics = %#v, want one options diagnostic", diagnostics)
}
}