Implement shared sequence operations
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Sequence Identity And Combination Implementation Plan
|
# Sequence Identity And Combination Implementation Plan
|
||||||
|
|
||||||
Status: implementation in progress; Stages 1–2 complete.
|
Status: implementation in progress; Stages 1–6 complete.
|
||||||
|
|
||||||
Audience: a Luna coding agent implementing one numbered stage per prompt, in
|
Audience: a Luna coding agent implementing one numbered stage per prompt, in
|
||||||
order. This plan starts at commit
|
order. This plan starts at commit
|
||||||
@@ -312,6 +312,9 @@ for overlays and inheritance.
|
|||||||
Validation: focused combination tests and the everyday check. Do not change the
|
Validation: focused combination tests and the everyday check. Do not change the
|
||||||
ordinary recursive mapping rule or add unkeyed append.
|
ordinary recursive mapping rule or add unkeyed append.
|
||||||
|
|
||||||
|
**Completion:** Implemented and verified with focused sequence-combination tests
|
||||||
|
and `./scripts/check.sh`.
|
||||||
|
|
||||||
## Stage 7 — Extend Sequence History Representation And Replay
|
## Stage 7 — Extend Sequence History Representation And Replay
|
||||||
|
|
||||||
Scope: represent and replay sequence operations without yet changing all metadata
|
Scope: represent and replay sequence operations without yet changing all metadata
|
||||||
|
|||||||
256
internal/engine/sequence_combine.go
Normal file
256
internal/engine/sequence_combine.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
|
||||||
|
type sequenceCombinationContext struct {
|
||||||
|
destination Path
|
||||||
|
}
|
||||||
|
|
||||||
|
type sequenceItemProjection struct {
|
||||||
|
inputIndex int
|
||||||
|
outputIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
type sequenceItemReplacement struct {
|
||||||
|
lowerIndex int
|
||||||
|
higherIndex int
|
||||||
|
outputIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
type sequenceCombinationResult struct {
|
||||||
|
value *document.Node
|
||||||
|
lowerItems []sequenceItemProjection
|
||||||
|
higherItems []sequenceItemProjection
|
||||||
|
replacements []sequenceItemReplacement
|
||||||
|
appendedHigher []int
|
||||||
|
discardedLower []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func combineSequence(
|
||||||
|
lower, higher *document.Node,
|
||||||
|
policy collectionPolicyResolution,
|
||||||
|
context sequenceCombinationContext,
|
||||||
|
) (sequenceCombinationResult, []Diagnostic) {
|
||||||
|
if higher == nil {
|
||||||
|
return unchangedSequenceValue(lower), nil
|
||||||
|
}
|
||||||
|
switch policy.operation {
|
||||||
|
case sequenceModeReplace:
|
||||||
|
return replaceSequence(lower, higher, policy.effectiveIdentityFields, context)
|
||||||
|
case sequenceModeAppend, sequenceModeUpsert:
|
||||||
|
return combineIdentifiedSequences(lower, higher, policy.operation, policy.effectiveIdentityFields, context)
|
||||||
|
default:
|
||||||
|
return sequenceCombinationResult{}, []Diagnostic{sequenceCombinationDiagnostic(
|
||||||
|
context.destination, "sequence operation is invalid", higher,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unchangedSequenceValue(lower *document.Node) sequenceCombinationResult {
|
||||||
|
result := sequenceCombinationResult{value: document.Clone(lower)}
|
||||||
|
if lower != nil && lower.Kind == document.Sequence {
|
||||||
|
result.lowerItems = sequenceItemProjections(len(lower.Sequence), 0)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceSequence(
|
||||||
|
lower, higher *document.Node,
|
||||||
|
identityFields []string,
|
||||||
|
context sequenceCombinationContext,
|
||||||
|
) (sequenceCombinationResult, []Diagnostic) {
|
||||||
|
if higher.Kind == document.Sequence && len(identityFields) != 0 {
|
||||||
|
if _, diagnostics := validateSequenceIdentity(higher, context.destination, identityFields, sequenceCombinationIdentityCodes); len(diagnostics) != 0 {
|
||||||
|
return sequenceCombinationResult{}, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := sequenceCombinationResult{value: document.Clone(higher)}
|
||||||
|
if higher.Kind == document.Sequence {
|
||||||
|
result.higherItems = sequenceItemProjections(len(higher.Sequence), 0)
|
||||||
|
}
|
||||||
|
if lower != nil && lower.Kind == document.Sequence {
|
||||||
|
result.discardedLower = sequenceIndexes(len(lower.Sequence))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func combineIdentifiedSequences(
|
||||||
|
lower, higher *document.Node,
|
||||||
|
operation sequenceMode,
|
||||||
|
identityFields []string,
|
||||||
|
context sequenceCombinationContext,
|
||||||
|
) (sequenceCombinationResult, []Diagnostic) {
|
||||||
|
if higher.Kind != document.Sequence {
|
||||||
|
return sequenceCombinationResult{}, []Diagnostic{sequenceCombinationDiagnostic(
|
||||||
|
context.destination, "append and upsert require a sequence contribution", higher,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
if lower != nil && lower.Kind != document.Sequence {
|
||||||
|
return sequenceCombinationResult{}, []Diagnostic{sequenceCombinationDiagnostic(
|
||||||
|
context.destination, "append and upsert require a lower sequence or an absent lower value", lower,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
if len(identityFields) == 0 {
|
||||||
|
return sequenceCombinationResult{}, []Diagnostic{sequenceCombinationDiagnostic(
|
||||||
|
context.destination, "append and upsert require an identity definition", higher,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
|
||||||
|
higherIndex, diagnostics := validateSequenceIdentity(higher, context.destination, identityFields, sequenceCombinationIdentityCodes)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return sequenceCombinationResult{}, diagnostics
|
||||||
|
}
|
||||||
|
var lowerIndex map[string]sequenceIdentityRecord
|
||||||
|
if lower != nil {
|
||||||
|
lowerIndex, diagnostics = validateSequenceIdentity(lower, context.destination, identityFields, sequenceCombinationIdentityCodes)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return sequenceCombinationResult{}, diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if operation == sequenceModeAppend {
|
||||||
|
if duplicate := duplicateAcrossSequenceContributions(lowerIndex, higherIndex, context.destination); duplicate != nil {
|
||||||
|
return sequenceCombinationResult{}, []Diagnostic{*duplicate}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if operation == sequenceModeAppend {
|
||||||
|
return appendSequenceItems(lower, higher), nil
|
||||||
|
}
|
||||||
|
if operation == sequenceModeUpsert {
|
||||||
|
return upsertSequenceItems(lower, higher, lowerIndex, higherIndex), nil
|
||||||
|
}
|
||||||
|
return sequenceCombinationResult{}, []Diagnostic{sequenceCombinationDiagnostic(
|
||||||
|
context.destination, "sequence operation is invalid", higher,
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendSequenceItems(lower, higher *document.Node) sequenceCombinationResult {
|
||||||
|
lowerCount := 0
|
||||||
|
if lower != nil {
|
||||||
|
lowerCount = len(lower.Sequence)
|
||||||
|
}
|
||||||
|
items := make([]*document.Node, 0, lowerCount+len(higher.Sequence))
|
||||||
|
if lower != nil {
|
||||||
|
items = append(items, lower.Sequence...)
|
||||||
|
}
|
||||||
|
items = append(items, higher.Sequence...)
|
||||||
|
return sequenceCombinationResult{
|
||||||
|
value: cloneSequenceWithItems(higher, items),
|
||||||
|
lowerItems: sequenceItemProjections(lowerCount, 0),
|
||||||
|
higherItems: sequenceItemProjections(len(higher.Sequence), lowerCount),
|
||||||
|
appendedHigher: sequenceIndexes(len(higher.Sequence)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertSequenceItems(
|
||||||
|
lower, higher *document.Node,
|
||||||
|
lowerIndex, higherIndex map[string]sequenceIdentityRecord,
|
||||||
|
) sequenceCombinationResult {
|
||||||
|
lowerCount := 0
|
||||||
|
var lowerItems []*document.Node
|
||||||
|
if lower != nil {
|
||||||
|
lowerCount = len(lower.Sequence)
|
||||||
|
lowerItems = lower.Sequence
|
||||||
|
}
|
||||||
|
matchedLower := make(map[int]sequenceIdentityRecord, len(higherIndex))
|
||||||
|
matchedLowerByHigher := make([]int, len(higher.Sequence))
|
||||||
|
for index := range matchedLowerByHigher {
|
||||||
|
matchedLowerByHigher[index] = -1
|
||||||
|
}
|
||||||
|
for tuple, incoming := range higherIndex {
|
||||||
|
if retained, exists := lowerIndex[tuple]; exists {
|
||||||
|
matchedLower[retained.index] = incoming
|
||||||
|
matchedLowerByHigher[incoming.index] = retained.index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items := make([]*document.Node, lowerCount, lowerCount+len(higher.Sequence))
|
||||||
|
result := sequenceCombinationResult{
|
||||||
|
lowerItems: make([]sequenceItemProjection, 0, lowerCount-len(matchedLower)),
|
||||||
|
higherItems: sequenceItemProjections(len(higher.Sequence), 0),
|
||||||
|
}
|
||||||
|
for lowerPosition, item := range lowerItems {
|
||||||
|
if incoming, replaced := matchedLower[lowerPosition]; replaced {
|
||||||
|
items[lowerPosition] = higher.Sequence[incoming.index]
|
||||||
|
result.higherItems[incoming.index].outputIndex = lowerPosition
|
||||||
|
result.replacements = append(result.replacements, sequenceItemReplacement{
|
||||||
|
lowerIndex: lowerPosition, higherIndex: incoming.index, outputIndex: lowerPosition,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items[lowerPosition] = item
|
||||||
|
result.lowerItems = append(result.lowerItems, sequenceItemProjection{inputIndex: lowerPosition, outputIndex: lowerPosition})
|
||||||
|
}
|
||||||
|
for higherPosition := range higher.Sequence {
|
||||||
|
if matchedLowerByHigher[higherPosition] >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outputIndex := len(items)
|
||||||
|
items = append(items, higher.Sequence[higherPosition])
|
||||||
|
result.appendedHigher = append(result.appendedHigher, higherPosition)
|
||||||
|
result.higherItems[higherPosition].outputIndex = outputIndex
|
||||||
|
}
|
||||||
|
result.value = cloneSequenceWithItems(higher, items)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateAcrossSequenceContributions(
|
||||||
|
lowerIndex, higherIndex map[string]sequenceIdentityRecord,
|
||||||
|
path Path,
|
||||||
|
) *Diagnostic {
|
||||||
|
higherTuples := make([]string, len(higherIndex))
|
||||||
|
for tuple, record := range higherIndex {
|
||||||
|
higherTuples[record.index] = tuple
|
||||||
|
}
|
||||||
|
for _, tuple := range higherTuples {
|
||||||
|
if lower, exists := lowerIndex[tuple]; exists {
|
||||||
|
higher := higherIndex[tuple]
|
||||||
|
primary := publicLocation(lower.location)
|
||||||
|
related := publicLocation(higher.location)
|
||||||
|
return &Diagnostic{
|
||||||
|
Code: CodeSequenceConflict, Severity: SeverityError, Path: path.clone(),
|
||||||
|
Message: "sequence contributions contain a duplicate identity",
|
||||||
|
Primary: &primary, Related: []Location{related},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSequenceWithItems(template *document.Node, items []*document.Node) *document.Node {
|
||||||
|
cloned := *template
|
||||||
|
cloned.Mapping = nil
|
||||||
|
cloned.Sequence = make([]*document.Node, len(items))
|
||||||
|
for index, item := range items {
|
||||||
|
cloned.Sequence[index] = document.Clone(item)
|
||||||
|
}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func sequenceItemProjections(length, outputOffset int) []sequenceItemProjection {
|
||||||
|
projections := make([]sequenceItemProjection, length)
|
||||||
|
for index := range projections {
|
||||||
|
projections[index] = sequenceItemProjection{inputIndex: index, outputIndex: index + outputOffset}
|
||||||
|
}
|
||||||
|
return projections
|
||||||
|
}
|
||||||
|
|
||||||
|
func sequenceIndexes(length int) []int {
|
||||||
|
indexes := make([]int, length)
|
||||||
|
for index := range indexes {
|
||||||
|
indexes[index] = index
|
||||||
|
}
|
||||||
|
return indexes
|
||||||
|
}
|
||||||
|
|
||||||
|
func sequenceCombinationDiagnostic(path Path, message string, node *document.Node) Diagnostic {
|
||||||
|
var primary *Location
|
||||||
|
if node != nil {
|
||||||
|
location := publicLocation(node.Location)
|
||||||
|
primary = &location
|
||||||
|
}
|
||||||
|
return Diagnostic{Code: CodeSequenceInvalid, Severity: SeverityError, Path: path.clone(), Message: message, Primary: primary}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sequenceCombinationIdentityCodes = sequenceIdentityDiagnosticCodes{
|
||||||
|
invalid: CodeSequenceInvalid, conflict: CodeSequenceConflict,
|
||||||
|
}
|
||||||
302
internal/engine/sequence_combine_test.go
Normal file
302
internal/engine/sequence_combine_test.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCombineSequenceOperationsAndItemEvidence(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
lower string
|
||||||
|
higher string
|
||||||
|
operation sequenceMode
|
||||||
|
want []string
|
||||||
|
wantLower []sequenceItemProjection
|
||||||
|
wantHigher []sequenceItemProjection
|
||||||
|
wantReplacements []sequenceItemReplacement
|
||||||
|
wantAppended []int
|
||||||
|
wantDiscarded []int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "replace uses only higher records",
|
||||||
|
lower: "items: [{id: a}, {id: b}]\n", higher: "items: [{id: b, text: revised}]\n",
|
||||||
|
operation: sequenceModeReplace, want: []string{"b"},
|
||||||
|
wantHigher: []sequenceItemProjection{{inputIndex: 0, outputIndex: 0}}, wantDiscarded: []int{0, 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "append retains then appends",
|
||||||
|
lower: "items: [{id: a}, {id: b}]\n", higher: "items: [{id: c}]\n",
|
||||||
|
operation: sequenceModeAppend, want: []string{"a", "b", "c"},
|
||||||
|
wantLower: []sequenceItemProjection{{inputIndex: 0, outputIndex: 0}, {inputIndex: 1, outputIndex: 1}},
|
||||||
|
wantHigher: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}}, wantAppended: []int{0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upsert replaces whole records in place and appends new records",
|
||||||
|
lower: "items: [{id: a, text: old}, {id: b}, {id: c, remove: true}]\n",
|
||||||
|
higher: "items: [{id: c, text: revised}, {id: a, text: revised}, {id: d}]\n",
|
||||||
|
operation: sequenceModeUpsert, want: []string{"a", "b", "c", "d"},
|
||||||
|
wantLower: []sequenceItemProjection{{inputIndex: 1, outputIndex: 1}},
|
||||||
|
wantHigher: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}, {inputIndex: 1, outputIndex: 0}, {inputIndex: 2, outputIndex: 3}},
|
||||||
|
wantReplacements: []sequenceItemReplacement{{lowerIndex: 0, higherIndex: 1, outputIndex: 0}, {lowerIndex: 2, higherIndex: 0, outputIndex: 2}},
|
||||||
|
wantAppended: []int{2},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
lower := sequenceFromYAML(t, test.lower)
|
||||||
|
higher := sequenceFromYAML(t, test.higher)
|
||||||
|
policy := sequenceCombinationPolicy(test.operation, "id")
|
||||||
|
result, diagnostics := combineSequence(lower, higher, policy, sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("combination diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if got := sequenceIdentityValues(result.value, "id"); !reflect.DeepEqual(got, test.want) {
|
||||||
|
t.Fatalf("combined IDs = %#v, want %#v", got, test.want)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(result.lowerItems, test.wantLower) || !reflect.DeepEqual(result.higherItems, test.wantHigher) || !reflect.DeepEqual(result.replacements, test.wantReplacements) || !reflect.DeepEqual(result.appendedHigher, test.wantAppended) || !reflect.DeepEqual(result.discardedLower, test.wantDiscarded) {
|
||||||
|
t.Fatalf("item evidence = %#v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineSequenceDistinguishesAbsenceFromNullAndKindMismatch(t *testing.T) {
|
||||||
|
policy := sequenceCombinationPolicy(sequenceModeAppend, "id")
|
||||||
|
retained := sequenceFromYAML(t, "items: [{id: lower}]\n")
|
||||||
|
unchanged, diagnostics := combineSequence(retained, nil, policy, sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(unchanged.value, "id"), []string{"lower"}) || len(unchanged.lowerItems) != 1 {
|
||||||
|
t.Fatalf("absent higher value changed lower sequence: %#v, %#v", unchanged, diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
lower string
|
||||||
|
higher string
|
||||||
|
}{
|
||||||
|
{name: "null higher value", lower: "items: [{id: lower}]\n", higher: "items: null\n"},
|
||||||
|
{name: "mapping higher value", lower: "items: [{id: lower}]\n", higher: "items: {id: higher}\n"},
|
||||||
|
{name: "null lower value", lower: "items: null\n", higher: "items: []\n"},
|
||||||
|
{name: "scalar lower value", lower: "items: blocked\n", higher: "items: []\n"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, issues := combineSequence(sequenceFromYAML(t, test.lower), sequenceFromYAML(t, test.higher), policy, sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if result.value != nil || len(issues) != 1 || issues[0].Code != CodeSequenceInvalid || issues[0].Path.String() != "/items" {
|
||||||
|
t.Fatalf("kind mismatch result = %#v, %#v", result, issues)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, operation := range []sequenceMode{sequenceModeAppend, sequenceModeUpsert} {
|
||||||
|
result, issues := combineSequence(nil, sequenceFromYAML(t, "items: null\n"), sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if result.value != nil || len(issues) != 1 || issues[0].Code != CodeSequenceInvalid {
|
||||||
|
t.Fatalf("null incoming value under operation %d = %#v, %#v", operation, result, issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceSequenceAllowsNullAndNonSequenceValues(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
higher string
|
||||||
|
wantKind document.Kind
|
||||||
|
}{
|
||||||
|
{name: "null", higher: "items: null\n", wantKind: document.Null},
|
||||||
|
{name: "mapping", higher: "items: {value: replacement}\n", wantKind: document.Mapping},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
lower := sequenceFromYAML(t, "items: [{id: lower}]\n")
|
||||||
|
higher := sequenceFromYAML(t, test.higher)
|
||||||
|
result, diagnostics := combineSequence(lower, higher, sequenceCombinationPolicy(sequenceModeReplace), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 0 || result.value == nil || result.value.Kind != test.wantKind || !reflect.DeepEqual(result.discardedLower, []int{0}) {
|
||||||
|
t.Fatalf("replacement result = %#v, %#v", result, diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
result, diagnostics := combineSequence(sequenceFromYAML(t, "items: null\n"), sequenceFromYAML(t, "items: [{id: new}]\n"), sequenceCombinationPolicy(sequenceModeReplace), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "id"), []string{"new"}) {
|
||||||
|
t.Fatalf("replacement over explicit lower null = %#v, %#v", result, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineSequenceRequiresIdentityForAppendAndUpsertEvenWhenEmpty(t *testing.T) {
|
||||||
|
for _, operation := range []sequenceMode{sequenceModeAppend, sequenceModeUpsert} {
|
||||||
|
result, diagnostics := combineSequence(nil, sequenceFromYAML(t, "items: []\n"), sequenceCombinationPolicy(operation), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if result.value != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid {
|
||||||
|
t.Fatalf("empty input bypassed identity requirement for operation %d: %#v, %#v", operation, result, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, operation := range []sequenceMode{sequenceModeAppend, sequenceModeUpsert} {
|
||||||
|
result, diagnostics := combineSequence(nil, sequenceFromYAML(t, "items: [{name: no-id}]\n"), sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if result.value != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceInvalid || diagnostics[0].Path.String() != "/items/0/id" {
|
||||||
|
t.Fatalf("missing identity for operation %d = %#v, %#v", operation, result, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineSequenceValidatesDuplicatesWithinEachContributionInEveryMode(t *testing.T) {
|
||||||
|
duplicate := "items: [{id: same}, {id: same}]\n"
|
||||||
|
for _, operation := range []sequenceMode{sequenceModeReplace, sequenceModeAppend, sequenceModeUpsert} {
|
||||||
|
_, diagnostics := combineSequence(nil, sequenceFromYAML(t, duplicate), sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict {
|
||||||
|
t.Fatalf("higher duplicate under operation %d = %#v", operation, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, operation := range []sequenceMode{sequenceModeAppend, sequenceModeUpsert} {
|
||||||
|
_, diagnostics := combineSequence(sequenceFromYAML(t, duplicate), sequenceFromYAML(t, "items: [{id: other}]\n"), sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict {
|
||||||
|
t.Fatalf("lower duplicate under operation %d = %#v", operation, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendSequenceRejectsDuplicatesAcrossContributions(t *testing.T) {
|
||||||
|
const identityCanary = "identity-canary-64"
|
||||||
|
lower := sequenceFromYAML(t, "items: [{id: "+identityCanary+"}]\n")
|
||||||
|
higher := sequenceFromYAML(t, "items: [{id: "+identityCanary+"}]\n")
|
||||||
|
_, diagnostics := combineSequence(lower, higher, sequenceCombinationPolicy(sequenceModeAppend, "id"), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/items" || diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
|
||||||
|
t.Fatalf("cross-contribution duplicate = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
if strings.Contains(diagnostics[0].Message, identityCanary) {
|
||||||
|
t.Fatalf("identity value leaked in diagnostic: %#v", diagnostics[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineSequenceReplacementResetsLowerIdentity(t *testing.T) {
|
||||||
|
lower := sequenceFromYAML(t, "items: [{id: old}]\n")
|
||||||
|
higher := sequenceFromYAML(t, "items: [{name: new}]\n")
|
||||||
|
policy := sequenceCombinationPolicy(sequenceModeReplace, "name")
|
||||||
|
result, diagnostics := combineSequence(lower, higher, policy, sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "name"), []string{"new"}) || !reflect.DeepEqual(result.discardedLower, []int{0}) {
|
||||||
|
t.Fatalf("replacement applied obsolete identity: %#v, %#v", result, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineSequenceDifferentIdentityDeclarationsConflict(t *testing.T) {
|
||||||
|
declarations := extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [items]
|
||||||
|
key: id
|
||||||
|
- destination: [items]
|
||||||
|
key: name
|
||||||
|
`, rootDirectiveContextSource).sequences
|
||||||
|
if _, diagnostics := resolveCollectionPolicy(Keys("items"), declarations, nil, nil); len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict {
|
||||||
|
t.Fatalf("different collection key declarations = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineSequenceEmptyInputsAndStableOrder(t *testing.T) {
|
||||||
|
for _, operation := range []sequenceMode{sequenceModeAppend, sequenceModeUpsert} {
|
||||||
|
lower := sequenceFromYAML(t, "items: [{id: a}, {id: b}]\n")
|
||||||
|
empty := sequenceFromYAML(t, "items: []\n")
|
||||||
|
result, diagnostics := combineSequence(lower, empty, sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "id"), []string{"a", "b"}) {
|
||||||
|
t.Fatalf("empty higher sequence under operation %d = %#v, %#v", operation, result, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, diagnostics := combineSequence(nil, sequenceFromYAML(t, "items: []\n"), sequenceCombinationPolicy(sequenceModeReplace, "id"), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 0 || result.value == nil || result.value.Kind != document.Sequence || len(result.value.Sequence) != 0 {
|
||||||
|
t.Fatalf("explicit replacement with empty input = %#v, %#v", result, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineSequenceInitializesAbsentLowerCollection(t *testing.T) {
|
||||||
|
higher := sequenceFromYAML(t, "items: [{id: first}, {id: second}]\n")
|
||||||
|
for _, operation := range []sequenceMode{sequenceModeReplace, sequenceModeAppend, sequenceModeUpsert} {
|
||||||
|
result, diagnostics := combineSequence(nil, higher, sequenceCombinationPolicy(operation, "id"), sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "id"), []string{"first", "second"}) {
|
||||||
|
t.Fatalf("absent lower collection under operation %d = %#v, %#v", operation, result, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineSequenceClonesInputsAndDoesNotMutatePolicy(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
operation sequenceMode
|
||||||
|
}{
|
||||||
|
{name: "replace", operation: sequenceModeReplace},
|
||||||
|
{name: "append", operation: sequenceModeAppend},
|
||||||
|
{name: "upsert", operation: sequenceModeUpsert},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
lower := sequenceFromYAML(t, "items: [{id: a, text: original}]\n")
|
||||||
|
higherID := "b"
|
||||||
|
if test.operation == sequenceModeUpsert {
|
||||||
|
higherID = "a"
|
||||||
|
}
|
||||||
|
higher := sequenceFromYAML(t, "items: [{id: "+higherID+", text: incoming}]\n")
|
||||||
|
policy := sequenceCombinationPolicy(test.operation, "id")
|
||||||
|
policyCopy := append([]string(nil), policy.effectiveIdentityFields...)
|
||||||
|
result, diagnostics := combineSequence(lower, higher, policy, sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
for _, item := range result.value.Sequence {
|
||||||
|
for entryIndex := range item.Mapping {
|
||||||
|
if item.Mapping[entryIndex].Key == "text" {
|
||||||
|
item.Mapping[entryIndex].Value.Text = "changed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lower.Sequence[0].Mapping[1].Value.Text != "original" || higher.Sequence[0].Mapping[1].Value.Text != "incoming" {
|
||||||
|
t.Fatal("combined result aliases a mutable input node")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(policy.effectiveIdentityFields, policyCopy) {
|
||||||
|
t.Fatal("combination mutated the resolved policy")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombineSequenceEquivalentIdentityFieldSets(t *testing.T) {
|
||||||
|
lower := sequenceFromYAML(t, "items: [{id: a, namespace: one}]\n")
|
||||||
|
higher := sequenceFromYAML(t, "items: [{namespace: one, id: b}]\n")
|
||||||
|
policy, diagnostics := resolveCollectionPolicy(Keys("items"), extractSequenceDeclarationsYAML(t, `configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [items]
|
||||||
|
key: [namespace, id, id]
|
||||||
|
`, rootDirectiveContextSource).sequences, nil, nil)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
result, diagnostics := combineSequence(lower, higher, policy, sequenceCombinationContext{destination: Keys("items")})
|
||||||
|
if len(diagnostics) != 0 || !reflect.DeepEqual(sequenceIdentityValues(result.value, "id"), []string{"a", "b"}) {
|
||||||
|
t.Fatalf("equivalent identity policy failed: %#v, %#v", result, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sequenceCombinationPolicy(operation sequenceMode, fields ...string) collectionPolicyResolution {
|
||||||
|
normalized, _ := normalizeSequenceIdentityFields(fields)
|
||||||
|
return collectionPolicyResolution{operation: operation, effectiveIdentityFields: normalized}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sequenceFromYAML(t *testing.T, input string) *document.Node {
|
||||||
|
t.Helper()
|
||||||
|
parsed := parsePolicyNode(t, input)
|
||||||
|
if len(parsed.Mapping) != 1 || parsed.Mapping[0].Key != "items" {
|
||||||
|
t.Fatalf("sequence fixture root = %#v", parsed.Mapping)
|
||||||
|
}
|
||||||
|
return parsed.Mapping[0].Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func sequenceIdentityValues(sequence *document.Node, field string) []string {
|
||||||
|
if sequence == nil || sequence.Kind != document.Sequence {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
values := make([]string, len(sequence.Sequence))
|
||||||
|
for index, item := range sequence.Sequence {
|
||||||
|
for _, entry := range item.Mapping {
|
||||||
|
if entry.Key == field {
|
||||||
|
values[index] = entry.Value.Text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user