Build identified record inheritance graphs
This commit is contained in:
160
internal/engine/inheritance_identified.go
Normal file
160
internal/engine/inheritance_identified.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
type identifiedInheritanceTemplate struct {
|
||||
item *document.Node
|
||||
path Path
|
||||
tuple string
|
||||
parent int
|
||||
hasParent bool
|
||||
cancelled bool
|
||||
directive *document.Node
|
||||
directivePath Path
|
||||
}
|
||||
|
||||
type identifiedInheritanceGraph struct {
|
||||
scope *document.Node
|
||||
scopePath Path
|
||||
fields []string
|
||||
nodes []identifiedInheritanceTemplate
|
||||
edges []dependencyEdge
|
||||
byIdentity map[string]int
|
||||
}
|
||||
|
||||
// analyzeIdentifiedInheritanceGraph validates relationships inside one
|
||||
// identified sequence. It prepares a graph only; mapping-sibling expansion
|
||||
// remains the active inheritance behavior until the reusable scope executor is
|
||||
// activated.
|
||||
func analyzeIdentifiedInheritanceGraph(sequence *document.Node, scopePath Path, identityFields []string) (*identifiedInheritanceGraph, []Diagnostic) {
|
||||
if sequence == nil || sequence.Kind != document.Sequence {
|
||||
var primary *Location
|
||||
if sequence != nil {
|
||||
location := publicLocation(sequence.Location)
|
||||
primary = &location
|
||||
}
|
||||
return nil, []Diagnostic{{
|
||||
Code: CodeSequenceInvalid, Severity: SeverityError, Path: scopePath.clone(),
|
||||
Message: "identified inheritance scope must be a sequence", Primary: primary,
|
||||
}}
|
||||
}
|
||||
fields, err := normalizeSequenceIdentityFields(identityFields)
|
||||
if err != nil {
|
||||
location := publicLocation(sequence.Location)
|
||||
return nil, []Diagnostic{{
|
||||
Code: CodeSequenceInvalid, Severity: SeverityError, Path: scopePath.clone(),
|
||||
Message: "identified inheritance scope requires identity fields", Primary: &location,
|
||||
}}
|
||||
}
|
||||
lookup, diagnostics := validateNormalizedSequenceIdentityFrom(
|
||||
sequence, scopePath, fields, sequenceCombinationIdentityCodes, 0,
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
graph := &identifiedInheritanceGraph{
|
||||
scope: sequence, scopePath: scopePath.clone(), fields: cloneStrings(fields),
|
||||
nodes: make([]identifiedInheritanceTemplate, len(sequence.Sequence)),
|
||||
byIdentity: make(map[string]int, len(lookup)),
|
||||
}
|
||||
for tuple, record := range lookup {
|
||||
graph.byIdentity[tuple] = record.index
|
||||
graph.nodes[record.index] = identifiedInheritanceTemplate{
|
||||
item: sequence.Sequence[record.index], path: scopePath.Index(record.index), tuple: tuple,
|
||||
}
|
||||
}
|
||||
|
||||
for index := range graph.nodes {
|
||||
node := &graph.nodes[index]
|
||||
directive, found := inheritanceDirective(node.item)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
node.directive = directive.Value
|
||||
node.directivePath = node.path.Key(inheritanceKey)
|
||||
referenceLocation := publicLocation(directive.Value.Location)
|
||||
switch directive.Value.Kind {
|
||||
case document.Null:
|
||||
node.cancelled = true
|
||||
case document.Mapping:
|
||||
reference, issues := identifiedInheritanceReference(directive.Value, node.directivePath, fields)
|
||||
if len(issues) != 0 {
|
||||
return nil, issues
|
||||
}
|
||||
parent, exists := graph.byIdentity[reference]
|
||||
if !exists {
|
||||
return nil, []Diagnostic{inheritanceDiagnostic(
|
||||
CodeInheritanceMissing, node.directivePath, referenceLocation, nil,
|
||||
"referenced record does not exist in this collection",
|
||||
)}
|
||||
}
|
||||
node.parent = parent
|
||||
node.hasParent = true
|
||||
graph.edges = append(graph.edges, dependencyEdge{prerequisite: parent, dependent: index})
|
||||
default:
|
||||
return nil, []Diagnostic{inheritanceDiagnostic(
|
||||
CodeInheritanceInvalid, node.directivePath, referenceLocation, nil,
|
||||
"identified record inheritance reference must be a mapping or null",
|
||||
)}
|
||||
}
|
||||
}
|
||||
return graph, nil
|
||||
}
|
||||
|
||||
func identifiedInheritanceReference(reference *document.Node, path Path, identityFields []string) (string, []Diagnostic) {
|
||||
allowed := make(map[string]struct{}, len(identityFields))
|
||||
for _, field := range identityFields {
|
||||
allowed[field] = struct{}{}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(reference.Mapping))
|
||||
for _, entry := range reference.Mapping {
|
||||
if _, ok := allowed[entry.Key]; !ok {
|
||||
return "", []Diagnostic{inheritanceDiagnostic(
|
||||
CodeInheritanceInvalid, path.Key(entry.Key), publicLocation(entry.Value.Location), nil,
|
||||
"identified record inheritance reference has an unsupported field",
|
||||
)}
|
||||
}
|
||||
seen[entry.Key] = struct{}{}
|
||||
}
|
||||
for _, field := range identityFields {
|
||||
if _, ok := seen[field]; !ok {
|
||||
return "", []Diagnostic{inheritanceDiagnostic(
|
||||
CodeInheritanceInvalid, path.Key(field), publicLocation(reference.Location), nil,
|
||||
"identified record inheritance reference is missing an identity field",
|
||||
)}
|
||||
}
|
||||
}
|
||||
index := newSequenceMappingIndex(reference)
|
||||
tuple, _, missing, invalid := index.identity(identityFields)
|
||||
if missing != "" || invalid >= 0 || invalid == -2 {
|
||||
value := reference
|
||||
fieldPath := path.Key(missing)
|
||||
if invalid >= 0 && invalid < len(reference.Mapping) {
|
||||
value = reference.Mapping[invalid].Value
|
||||
fieldPath = path.Key(reference.Mapping[invalid].Key)
|
||||
}
|
||||
location := publicLocation(value.Location)
|
||||
return "", []Diagnostic{inheritanceDiagnostic(
|
||||
CodeInheritanceInvalid, fieldPath, location, nil,
|
||||
"identified record inheritance identity fields must be scalar values",
|
||||
)}
|
||||
}
|
||||
return tuple, nil
|
||||
}
|
||||
|
||||
func (graph *identifiedInheritanceGraph) schedule() ([]int, []Diagnostic) {
|
||||
keys := make([]dependencyKey, len(graph.nodes))
|
||||
for index, node := range graph.nodes {
|
||||
keys[index] = dependencyKey{"record", graph.scopePath.String(), node.path.String()}
|
||||
}
|
||||
order, cycle := scheduleDependencies(keys, graph.edges)
|
||||
if len(cycle) == 0 {
|
||||
return order, nil
|
||||
}
|
||||
parent, child := graph.nodes[cycle[0]], graph.nodes[cycle[1]]
|
||||
return nil, []Diagnostic{inheritanceDiagnostic(
|
||||
CodeInheritanceCycle, child.directivePath, publicLocation(child.directive.Location),
|
||||
[]Location{publicLocation(parent.item.Location)},
|
||||
"identified record inheritance contains a cycle",
|
||||
)}
|
||||
}
|
||||
200
internal/engine/inheritance_identified_test.go
Normal file
200
internal/engine/inheritance_identified_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func parseIdentifiedInheritanceSequence(t *testing.T, sourceID, input string) *document.Node {
|
||||
t.Helper()
|
||||
var indented strings.Builder
|
||||
indented.WriteString("jobs:\n")
|
||||
for _, line := range strings.Split(strings.TrimSuffix(input, "\n"), "\n") {
|
||||
indented.WriteString(" ")
|
||||
indented.WriteString(line)
|
||||
indented.WriteByte('\n')
|
||||
}
|
||||
root := parseItemPolicyDocument(t, sourceID, indented.String())
|
||||
sequence, found := documentAtPath(root, Keys("jobs"))
|
||||
if !found || sequence.Kind != document.Sequence {
|
||||
t.Fatalf("identified sequence missing from test document: %#v", root)
|
||||
}
|
||||
return sequence
|
||||
}
|
||||
|
||||
func TestIdentifiedInheritanceGraphSchedulesForwardReferencesInPlace(t *testing.T) {
|
||||
sequence := parseIdentifiedInheritanceSequence(t, "identified-graph", `- id: 2
|
||||
tenant: acme
|
||||
extends: {id: 1.0, tenant: acme}
|
||||
- id: 1e0
|
||||
tenant: acme
|
||||
extends: {id: 0, tenant: acme}
|
||||
- id: 0
|
||||
tenant: acme
|
||||
- id: 3
|
||||
tenant: acme
|
||||
extends: null
|
||||
`)
|
||||
path := Keys("jobs")
|
||||
graph, diagnostics := analyzeIdentifiedInheritanceGraph(sequence, path, []string{"tenant", "id"})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("analyze identified graph: %#v", diagnostics)
|
||||
}
|
||||
if len(graph.edges) != 2 {
|
||||
t.Fatalf("edges = %#v, want the two links in the inheritance chain", graph.edges)
|
||||
}
|
||||
order, diagnostics := graph.schedule()
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("schedule identified graph: %#v", diagnostics)
|
||||
}
|
||||
if len(order) != 4 || order[0] != 2 || order[1] != 1 || order[2] != 0 {
|
||||
t.Fatalf("schedule = %v, want the forward parent chain before its children", order)
|
||||
}
|
||||
if graph.nodes[0].path.String() != "/jobs/0" || graph.nodes[0].item != sequence.Sequence[0] ||
|
||||
!graph.nodes[3].cancelled || len(graph.nodes[3].directivePath.segments) == 0 {
|
||||
t.Fatalf("graph nodes changed authored positions or cancellation: %#v", graph.nodes)
|
||||
}
|
||||
if _, found := graph.byIdentity[graph.nodes[1].tuple]; !found {
|
||||
t.Fatal("identity index does not contain the parent record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentifiedInheritanceRequiresChildIdentityBeforeParentLookup(t *testing.T) {
|
||||
sequence := parseIdentifiedInheritanceSequence(t, "missing-child-field", `- id: child
|
||||
extends: {id: parent, tenant: acme}
|
||||
- id: parent
|
||||
tenant: acme
|
||||
`)
|
||||
_, diagnostics := analyzeIdentifiedInheritanceGraph(sequence, Keys("jobs"), []string{"id", "tenant"})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/jobs/0/tenant" {
|
||||
t.Fatalf("missing child identity diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentifiedInheritanceRejectsMalformedAndMissingReferences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantCode Code
|
||||
}{
|
||||
{name: "string shorthand", value: "extends: parent\n", wantCode: CodeInheritanceInvalid},
|
||||
{name: "sequence shorthand", value: "extends: [{id: parent, tenant: acme}]\n", wantCode: CodeInheritanceInvalid},
|
||||
{name: "missing component", value: "extends: {id: parent}\n", wantCode: CodeInheritanceInvalid},
|
||||
{name: "extra component", value: "extends: {id: parent, tenant: acme, extra: value}\n", wantCode: CodeInheritanceInvalid},
|
||||
{name: "mapping component", value: "extends: {id: {nested: value}, tenant: acme}\n", wantCode: CodeInheritanceInvalid},
|
||||
{name: "missing parent", value: "extends: {id: missing, tenant: acme}\n", wantCode: CodeInheritanceMissing},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
sequence := parseIdentifiedInheritanceSequence(t, "bad-reference", "- id: child\n tenant: acme\n "+test.value)
|
||||
_, diagnostics := analyzeIdentifiedInheritanceGraph(sequence, Keys("jobs"), []string{"id", "tenant"})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode || diagnostics[0].Path.String() != "/jobs/0/extends" && diagnostics[0].Code != CodeInheritanceInvalid {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics[0].Code == CodeInheritanceMissing && diagnostics[0].Path.String() != "/jobs/0/extends" {
|
||||
t.Fatalf("missing-parent path = %s", diagnostics[0].Path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentifiedInheritanceGraphFindsSelfAndLongerCycles(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`- id: self
|
||||
tenant: acme
|
||||
extends: {id: self, tenant: acme}
|
||||
`,
|
||||
`- id: alpha
|
||||
tenant: acme
|
||||
extends: {id: beta, tenant: acme}
|
||||
- id: beta
|
||||
tenant: acme
|
||||
extends: {id: gamma, tenant: acme}
|
||||
- id: gamma
|
||||
tenant: acme
|
||||
extends: {id: alpha, tenant: acme}
|
||||
`,
|
||||
} {
|
||||
graph, diagnostics := analyzeIdentifiedInheritanceGraph(parseIdentifiedInheritanceSequence(t, "cycle", input), Keys("jobs"), []string{"id", "tenant"})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("analyze cycle graph: %#v", diagnostics)
|
||||
}
|
||||
_, diagnostics = graph.schedule()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeInheritanceCycle || diagnostics[0].Path.String() == "" {
|
||||
t.Fatalf("cycle diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "cycle" {
|
||||
t.Fatalf("cycle primary location = %#v", diagnostics[0].Primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentifiedInheritanceScopesCannotReferenceOtherCollections(t *testing.T) {
|
||||
other := parseIdentifiedInheritanceSequence(t, "other-collection", `- id: parent
|
||||
tenant: acme
|
||||
`)
|
||||
_, diagnostics := analyzeIdentifiedInheritanceGraph(other, Keys("other"), []string{"id", "tenant"})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
first := parseIdentifiedInheritanceSequence(t, "first-collection", `- id: child
|
||||
tenant: acme
|
||||
extends: {id: parent, tenant: acme}
|
||||
`)
|
||||
_, diagnostics = analyzeIdentifiedInheritanceGraph(first, Keys("first"), []string{"id", "tenant"})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeInheritanceMissing {
|
||||
t.Fatalf("cross-collection reference diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentifiedInheritanceAcceptsStrictlyComposedParentReference(t *testing.T) {
|
||||
left := parseCompositionDocument(t, "reference-left", "extends: {id: parent}\n")
|
||||
right := parseCompositionDocument(t, "reference-right", "extends: {tenant: acme}\n")
|
||||
reference, diagnostics := composeDocuments(left, right)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose disjoint reference helpers: %#v", diagnostics)
|
||||
}
|
||||
child, diagnostics := composeDocuments(parseCompositionDocument(t, "child-fields", "id: child\ntenant: acme\n"), reference.root)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("compose child fields and reference: %#v", diagnostics)
|
||||
}
|
||||
parent := parseCompositionDocument(t, "parent", "id: parent\ntenant: acme\n")
|
||||
sequence := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{child.root, parent}}
|
||||
graph, diagnostics := analyzeIdentifiedInheritanceGraph(sequence, Keys("jobs"), []string{"id", "tenant"})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("analyze composed reference: %#v", diagnostics)
|
||||
}
|
||||
if len(graph.edges) != 1 || graph.edges[0] != (dependencyEdge{prerequisite: 1, dependent: 0}) {
|
||||
t.Fatalf("composed reference edge = %#v", graph.edges)
|
||||
}
|
||||
|
||||
_, diagnostics = composeDocuments(
|
||||
parseCompositionDocument(t, "overlap-a", "extends: {id: first}\n"),
|
||||
parseCompositionDocument(t, "overlap-b", "extends: {id: second}\n"),
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict {
|
||||
t.Fatalf("overlapping reference fields should conflict during strict composition: %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentifiedInheritanceValidatesOnlySurvivingRelationships(t *testing.T) {
|
||||
lower := parseIdentifiedInheritanceSequence(t, "superseded", `- id: shared
|
||||
extends: {id: absent, tenant: acme}
|
||||
`)
|
||||
higher := parseIdentifiedInheritanceSequence(t, "cancelled", `- id: shared
|
||||
extends: null
|
||||
`)
|
||||
resolution := collectionPolicyResolution{
|
||||
operation: sequenceModeUpsert, effectiveIdentityFields: []string{"id"},
|
||||
}
|
||||
combined, diagnostics := combineSequence(lower, higher, resolution, sequenceCombinationContext{destination: Keys("jobs")})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("combine surviving reference: %#v", diagnostics)
|
||||
}
|
||||
graph, diagnostics := analyzeIdentifiedInheritanceGraph(combined.value, Keys("jobs"), []string{"id"})
|
||||
if len(diagnostics) != 0 || len(graph.edges) != 0 || !graph.nodes[0].cancelled {
|
||||
t.Fatalf("superseded missing reference was validated: graph=%#v diagnostics=%#v", graph, diagnostics)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user