Match registered collection endpoints for resolution
This commit is contained in:
@@ -100,6 +100,8 @@ implementation completion. No current-behavior reference is updated yet.
|
||||
|
||||
## Stage 2 — Build endpoint collection correspondence
|
||||
|
||||
**Status:** Complete. Focused correspondence tests cover identity matching, fallback, and dynamic selectors.
|
||||
|
||||
**Scope:** private, pure matching logic, preferably in
|
||||
`internal/engine/resolution_correspondence.go`.
|
||||
|
||||
|
||||
115
internal/engine/resolution_correspondence.go
Normal file
115
internal/engine/resolution_correspondence.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package engine
|
||||
|
||||
import "go.maximumdirect.net/configkit/internal/document"
|
||||
|
||||
// collectionCorrespondence describes matching at one concrete registered path.
|
||||
// A negative index denotes an identity present only at the other endpoint.
|
||||
type collectionCorrespondence struct {
|
||||
path Path
|
||||
eligible bool
|
||||
structural bool
|
||||
entryToFinal []int
|
||||
finalToEntry []int
|
||||
}
|
||||
|
||||
type resolutionCorrespondence struct {
|
||||
collections map[string]collectionCorrespondence
|
||||
}
|
||||
|
||||
func buildResolutionCorrespondence(entry, final *document.Node, policy sequencePolicy) resolutionCorrespondence {
|
||||
result := resolutionCorrespondence{collections: make(map[string]collectionCorrespondence)}
|
||||
var indexes mappingIndex
|
||||
var visit func(*document.Node, *document.Node, Path)
|
||||
visit = func(before, after *document.Node, path Path) {
|
||||
if fields, registered := policy.identityFieldsAt(path); registered &&
|
||||
((before != nil && before.Kind == document.Sequence) || (after != nil && after.Kind == document.Sequence)) {
|
||||
result.collections[path.String()] = matchResolutionCollection(before, after, path, fields)
|
||||
return
|
||||
}
|
||||
if before != nil && before.Kind == document.Mapping {
|
||||
for _, item := range before.Mapping {
|
||||
var next *document.Node
|
||||
if after != nil && after.Kind == document.Mapping {
|
||||
if position := indexes.find(after, item.Key); position >= 0 {
|
||||
next = after.Mapping[position].Value
|
||||
}
|
||||
}
|
||||
visit(item.Value, next, path.Key(item.Key))
|
||||
}
|
||||
}
|
||||
if after != nil && after.Kind == document.Mapping {
|
||||
for _, item := range after.Mapping {
|
||||
if before == nil || before.Kind != document.Mapping || indexes.find(before, item.Key) < 0 {
|
||||
visit(nil, item.Value, path.Key(item.Key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(entry, final, Path{})
|
||||
return result
|
||||
}
|
||||
|
||||
func matchResolutionCollection(before, after *document.Node, path Path, fields []string) collectionCorrespondence {
|
||||
result := collectionCorrespondence{path: path.clone()}
|
||||
prior, priorIssues := validateNormalizedSequenceIdentityFrom(before, path, fields, sequenceIdentityDiagnosticCodes{}, 0, sequencePayloadRecord)
|
||||
later, laterIssues := validateNormalizedSequenceIdentityFrom(after, path, fields, sequenceIdentityDiagnosticCodes{}, 0, sequencePayloadRecord)
|
||||
if len(priorIssues) != 0 || len(laterIssues) != 0 {
|
||||
return result
|
||||
}
|
||||
result.eligible = true
|
||||
result.entryToFinal = make([]int, len(before.Sequence))
|
||||
result.finalToEntry = make([]int, len(after.Sequence))
|
||||
for i := range result.entryToFinal {
|
||||
result.entryToFinal[i] = -1
|
||||
}
|
||||
for i := range result.finalToEntry {
|
||||
result.finalToEntry[i] = -1
|
||||
}
|
||||
result.structural = len(prior) != len(later)
|
||||
for tuple, previous := range prior {
|
||||
if current, found := later[tuple]; found {
|
||||
result.entryToFinal[previous.index] = current.index
|
||||
result.finalToEntry[current.index] = previous.index
|
||||
if previous.index != current.index {
|
||||
result.structural = true
|
||||
}
|
||||
} else {
|
||||
result.structural = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// entryPath translates a surviving final record prefix; additions have no entry.
|
||||
// Paths outside eligible collections and wholly absent final positions stay positional.
|
||||
func (c resolutionCorrespondence) entryPath(path Path) (Path, bool) {
|
||||
if !path.valid() {
|
||||
return Path{}, false
|
||||
}
|
||||
for depth := 0; depth < len(path.segments); depth++ {
|
||||
prefix := Path{segments: path.segments[:depth]}
|
||||
collection, found := c.collections[prefix.String()]
|
||||
if !found || !collection.eligible {
|
||||
continue
|
||||
}
|
||||
compatible := true
|
||||
for i, segment := range collection.path.segments {
|
||||
if segment.kind == pathSegmentKey && path.segments[i].kind == pathSegmentIndex {
|
||||
compatible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
index, ok := schemaIndex(path.segments[depth])
|
||||
if !compatible || !ok || index >= len(collection.finalToEntry) {
|
||||
continue
|
||||
}
|
||||
previous := collection.finalToEntry[index]
|
||||
if previous < 0 {
|
||||
return Path{}, false
|
||||
}
|
||||
translated := path.clone()
|
||||
translated.segments[depth] = pathSegment{kind: pathSegmentIndex, index: previous}
|
||||
return translated, true
|
||||
}
|
||||
return path.clone(), true
|
||||
}
|
||||
65
internal/engine/resolution_correspondence_test.go
Normal file
65
internal/engine/resolution_correspondence_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package engine
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolutionCollectionCorrespondence(t *testing.T) {
|
||||
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
|
||||
selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"},
|
||||
}})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name, before, after string
|
||||
eligible, structural bool
|
||||
finalIndex, entryIndex int
|
||||
matched bool
|
||||
}{
|
||||
{"swap", "items: [{id: a}, {id: b}]", "items: [{id: b}, {id: a}]", true, true, 0, 1, true},
|
||||
{"insert", "items: [{id: a}]", "items: [{id: b}, {id: a}]", true, true, 0, 0, false},
|
||||
{"remove", "items: [{id: a}, {id: b}]", "items: [{id: b}]", true, true, 0, 1, true},
|
||||
{"same", "items: [{id: a}]", "items: [{id: a}]", true, false, 0, 0, true},
|
||||
{"duplicate", "items: [{id: a}, {id: a}]", "items: [{id: a}]", false, false, 0, 0, true},
|
||||
{"missing", "items: [{name: a}]", "items: [{id: a}]", false, false, 0, 0, true},
|
||||
{"identity replacement", "items: [{id: a}]", "items: [{id: b}]", true, true, 0, 0, false},
|
||||
{"invalid final", "items: [{id: a}]", "items: [bad]", false, false, 0, 0, true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
before := parseCompositionDocument(t, "before", test.before)
|
||||
after := parseCompositionDocument(t, "after", test.after)
|
||||
matching := buildResolutionCorrespondence(before, after, policy)
|
||||
collection := matching.collections["/items"]
|
||||
if collection.eligible != test.eligible || collection.structural != test.structural {
|
||||
t.Fatalf("collection = %#v", collection)
|
||||
}
|
||||
entry, ok := matching.entryPath(Keys("items").Index(test.finalIndex).Key("value"))
|
||||
if ok != test.matched || (ok && entry.String() != Keys("items").Index(test.entryIndex).Key("value").String()) {
|
||||
t.Fatalf("entry = %s, matched = %t", entry.String(), ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolutionCorrespondenceDynamicCompoundIdentities(t *testing.T) {
|
||||
policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{
|
||||
selector: sequenceIdentitySelector{literalSequenceSelector("groups"), anyMappingSequenceSelector(), literalSequenceSelector("items")},
|
||||
identityFields: []string{"id", "kind"},
|
||||
}})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
before := parseCompositionDocument(t, "before", "groups: {alpha: {items: [{id: 1, kind: x}, {id: '1', kind: x}]}, empty: {items: []}}")
|
||||
after := parseCompositionDocument(t, "after", "groups: {alpha: {items: [{id: '1', kind: x}, {id: 1.0, kind: x}]}, empty: {items: []}}")
|
||||
matching := buildResolutionCorrespondence(before, after, policy)
|
||||
if empty := matching.collections["/groups/empty/items"]; !empty.eligible || empty.structural {
|
||||
t.Fatalf("empty = %#v", empty)
|
||||
}
|
||||
path, ok := matching.entryPath(Keys("groups", "alpha", "items").Index(0).Key("missing"))
|
||||
if !ok || path.String() != "/groups/alpha/items/1/missing" {
|
||||
t.Fatalf("entry = %s, %t", path.String(), ok)
|
||||
}
|
||||
wrongShape, ok := matching.entryPath(Keys("groups").Index(0).Key("items").Index(0))
|
||||
if !ok || wrongShape.String() != "/groups/0/items/0" {
|
||||
t.Fatalf("mapping shape = %s, %t", wrongShape.String(), ok)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user