diff --git a/README.md b/README.md index 87ac6a9..1e09901 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ * **Flexible Consistency**: Choose between strict "old-value" matching or flexible application based on custom conditions. * **Local Node Conditions**: Attach conditions to specific fields or elements during manual construction. * **Manual Patch Builder**: Construct valid patches manually using a fluent API with on-the-fly type validation. +* **JSON Pointer Support**: Use RFC 6901 pointers (`/path/to/item`) in conditions and builder navigation. +* **JSON Patch Export**: Export patches to RFC 6902 compliant JSON for interoperability. +* **Move & Copy Operations**: Efficiently re-order data or reuse values across the structure. +* **Atomic Test Operation**: Include pre-condition checks that fail the patch if not met. +* **Soft Conditions**: Skip operations using `If` and `Unless` logic without failing the entire patch application. +* **Custom Log Operation**: Insert logging points in your patch for debugging during application. * **Unexported Fields**: Handles unexported struct fields transparently. * **Cycle Detection**: Correctly handles circular references in both Copy and Diff operations. @@ -169,6 +175,79 @@ reversePatch := patch.Reverse() reversePatch.Apply(&stateA) // stateA is back to original ``` +## JSON Patch & RFC Interoperability + +`deep` provides deep support for JSON standards to ensure interoperability with other systems and web frontends. + +### JSON Pointer (RFC 6901) + +You can use JSON Pointers anywhere a path is expected, including in the Condition DSL and the Manual Builder. + +```go +// Use in conditions +cond, _ := deep.ParseCondition[Config]("/network/port > 1024") + +// Use in manual builder navigation +builder.Root().navigate("/meta/env").Set("prod", "staging") +``` + +### JSON Patch Export (RFC 6902) + +Any `deep.Patch` can be exported to a standard JSON Patch array. Exported patches automatically include `If` and `Unless` conditions as standard JSON Predicates. + +```go +patch := deep.Diff(oldObj, newObj) +jsonBytes, err := patch.ToJSONPatch() +// Produces: [{"op": "replace", "path": "/version", "value": 2, "if": {...}}, ...] +``` + +### Move & Copy Operations + +The manual builder supports efficient `Move` and `Copy` operations. + +```go +builder := deep.NewBuilder[Config]() + +// Move a value from one path to another (deletes from source) +builder.Root().Field("BackupHost").Move("/Network/Host") + +// Copy a value from one path to another +builder.Root().Field("Alias").Copy("/Name") +``` + +### Atomic Test Operation + +Modeled after JSON Patch's `test` operation, this allows ensuring a value matches a specific state before proceeding, without modifying it. + +```go +// Application will fail if /version is not currently 1 +builder.Root().navigate("/version").Test(1) +``` + +### Soft Conditions (If/Unless) + +Unlike standard conditions that fail the whole `ApplyChecked` call, `If` and `Unless` conditions allow skipping specific operations while letting the rest of the patch proceed. + +```go +builder := deep.NewBuilder[Config]() + +// Only update the version IF the environment is 'prod' +// If not 'prod', this specific update is skipped, but other fields are still updated. +builder.Root().Field("Version"). + If(deep.Equal[Config]("/meta/env", "prod")). + Set(1, 2) +``` + +### Custom Log Operation + +Insert a log point anywhere in your structure to print the current value during patch application. This is highly useful for debugging complex patch trees. + +```go +builder.Root().Field("Settings"). + Log("Applying settings update"). + Field("Timeout").Set(30, 60) +``` + ## Advanced ### Custom Copier diff --git a/TODO.md b/TODO.md index d924635..01dc239 100644 --- a/TODO.md +++ b/TODO.md @@ -33,8 +33,8 @@ This document tracks planned features and improvements to make the `deep` librar - [ ] **SIMD Comparisons**: Investigate using SIMD for basic type comparisons in large slices/arrays. ## 6. JSON Patch & RFC Interoperability -- [ ] **Move & Copy Operations**: Implement internal `movePatch` and `copyPatch` to handle value re-ordering efficiently without redundant data. -- [ ] **Atomic Test Operation**: Allow patches to include "pre-condition only" paths that must match a value but are not changed by the patch. -- [ ] **JSON Pointer Support**: Support RFC 6901 (`/path/to/item`) as an alternative to Go-style dot notation. -- [ ] **Standard Export**: Provide a `ToJSONPatch()` method to generate RFC 6902 compliant JSON for interoperability with web frontends. -- [ ] **Soft Conditions**: Support skipping operations (If/Unless logic) instead of failing the entire application on condition mismatch. +- [x] **Move & Copy Operations**: Implement internal `movePatch` and `copyPatch` to handle value re-ordering efficiently without redundant data. +- [x] **Atomic Test Operation**: Allow patches to include "pre-condition only" paths that must match a value but are not changed by the patch. +- [x] **JSON Pointer Support**: Support RFC 6901 (`/path/to/item`) as an alternative to Go-style dot notation. +- [x] **Standard Export**: Provide a `ToJSONPatch()` method to generate RFC 6902 compliant JSON for interoperability with web frontends. +- [x] **Soft Conditions**: Support skipping operations (If/Unless logic) instead of failing the entire application on condition mismatch. diff --git a/builder.go b/builder.go index 902c86e..d02174a 100644 --- a/builder.go +++ b/builder.go @@ -4,7 +4,6 @@ import ( "fmt" "reflect" "strconv" - "strings" ) // Builder allows constructing a Patch[T] manually with on-the-fly type validation. @@ -44,7 +43,8 @@ func (b *Builder[T]) Root() *Node { update: func(p diffPatch) { b.patch = p }, - current: b.patch, + current: b.patch, + fullPath: "", } } @@ -63,21 +63,21 @@ func (b *Builder[T]) AddCondition(expr string) *Builder[T] { } paths := raw.paths() - prefix := lcp(paths) + prefix := lcpParts(paths) - node, err := b.Root().navigate(prefix) + node, err := b.Root().navigateParts(prefix) if err != nil { b.err = err return b } - node.WithCondition(raw.withRelativePaths(prefix)) + node.WithCondition(raw.withRelativeParts(prefix)) return b } -func lcp(paths []Path) string { +func lcpParts(paths []Path) []pathPart { if len(paths) == 0 { - return "" + return nil } allParts := make([][]pathPart, len(paths)) @@ -93,41 +93,16 @@ func lcp(paths []Path) string { } common = common[:n] for j := 0; j < n; j++ { - if common[j] != allParts[i][j] { + if !common[j].equals(allParts[i][j]) { common = common[:j] break } } } - - var res strings.Builder - for i, p := range common { - if i > 0 && !p.isIndex { - res.WriteByte('.') - } - if p.isIndex { - res.WriteByte('[') - res.WriteString(strconv.Itoa(p.index)) - res.WriteByte(']') - } else { - res.WriteString(p.key) - } - } - return res.String() -} - -// Node represents a specific location within a value's structure. -type Node struct { - typ reflect.Type - update func(diffPatch) - current diffPatch + return common } -func (n *Node) navigate(path string) (*Node, error) { - if path == "" { - return n, nil - } - parts := parsePath(path) +func (n *Node) navigateParts(parts []pathPart) (*Node, error) { curr := n var err error for _, part := range parts { @@ -143,6 +118,21 @@ func (n *Node) navigate(path string) (*Node, error) { return curr, nil } +// Node represents a specific location within a value's structure. +type Node struct { + typ reflect.Type + update func(diffPatch) + current diffPatch + fullPath string +} + +func (n *Node) navigate(path string) (*Node, error) { + if path == "" { + return n, nil + } + return n.navigateParts(parsePath(path)) +} + func (n *Node) FieldOrMapKey(key string) (*Node, error) { curr := n.Elem() if curr.typ != nil && curr.typ.Kind() == reflect.Map { @@ -173,6 +163,66 @@ func (n *Node) Set(old, new any) *Node { oldVal: deepCopyValue(vOld), newVal: deepCopyValue(vNew), } + if n.current != nil { + p.cond, p.ifCond, p.unlessCond = n.current.conditions() + } + n.update(p) + n.current = p + return n +} + +// Test adds a test operation to the current node. The patch application +// will fail if the value at this node does not match the expected value. +func (n *Node) Test(expected any) *Node { + vExpected := reflect.ValueOf(expected) + p := &testPatch{ + expected: deepCopyValue(vExpected), + } + if n.current != nil { + p.cond, p.ifCond, p.unlessCond = n.current.conditions() + } + n.update(p) + n.current = p + return n +} + +// Copy copies a value from another path to the current node. +func (n *Node) Copy(from string) *Node { + p := ©Patch{ + from: from, + path: n.fullPath, + } + if n.current != nil { + p.cond, p.ifCond, p.unlessCond = n.current.conditions() + } + n.update(p) + n.current = p + return n +} + +// Move moves a value from another path to the current node. +func (n *Node) Move(from string) *Node { + p := &movePatch{ + from: from, + path: n.fullPath, + } + if n.current != nil { + p.cond, p.ifCond, p.unlessCond = n.current.conditions() + } + n.update(p) + n.current = p + return n +} + +// Log adds a log operation to the current node. It prints a message +// and the current value at the node during patch application. +func (n *Node) Log(message string) *Node { + p := &logPatch{ + message: message, + } + if n.current != nil { + p.cond, p.ifCond, p.unlessCond = n.current.conditions() + } n.update(p) n.current = p return n @@ -223,6 +273,26 @@ func (n *Node) WithCondition(c any) *Node { return n } +// If attaches an 'if' condition to the current node. If the condition +// evaluates to false, the operation at this node is skipped. +func (n *Node) If(c any) *Node { + n.ensurePatch() + if n.current != nil { + n.current.setIfCondition(c) + } + return n +} + +// Unless attaches an 'unless' condition to the current node. If the condition +// evaluates to true, the operation at this node is skipped. +func (n *Node) Unless(c any) *Node { + n.ensurePatch() + if n.current != nil { + n.current.setUnlessCondition(c) + } + return n +} + // Field returns a Node for the specified struct field. It automatically descends // into pointers and interfaces if necessary. func (n *Node) Field(name string) (*Node, error) { @@ -245,7 +315,8 @@ func (n *Node) Field(name string) (*Node, error) { update: func(p diffPatch) { sp.fields[name] = p }, - current: sp.fields[name], + current: sp.fields[name], + fullPath: n.fullPath + "/" + name, }, nil } @@ -271,7 +342,8 @@ func (n *Node) Index(i int) (*Node, error) { update: func(p diffPatch) { ap.indices[i] = p }, - current: ap.indices[i], + current: ap.indices[i], + fullPath: n.fullPath + "/" + strconv.Itoa(i), }, nil } sp, ok := n.current.(*slicePatch) @@ -299,7 +371,8 @@ func (n *Node) Index(i int) (*Node, error) { update: func(p diffPatch) { modOp.Patch = p }, - current: modOp.Patch, + current: modOp.Patch, + fullPath: n.fullPath + "/" + strconv.Itoa(i), }, nil } @@ -329,7 +402,8 @@ func (n *Node) MapKey(key any) (*Node, error) { update: func(p diffPatch) { mp.modified[key] = p }, - current: mp.modified[key], + current: mp.modified[key], + fullPath: n.fullPath + "/" + fmt.Sprintf("%v", key), }, nil } @@ -359,10 +433,15 @@ func (n *Node) Elem() *Node { updateFunc = func(p diffPatch) { ip.elemPatch = p } currentPatch = ip.elemPatch } + var nextTyp reflect.Type + if n.typ.Kind() == reflect.Ptr { + nextTyp = n.typ.Elem() + } return &Node{ - typ: n.typ.Elem(), - update: updateFunc, - current: currentPatch, + typ: nextTyp, + update: updateFunc, + current: currentPatch, + fullPath: n.fullPath, // Elem doesn't add to path in JSON Pointer } } diff --git a/builder_test.go b/builder_test.go index 0e740cb..29cc2c8 100644 --- a/builder_test.go +++ b/builder_test.go @@ -1,6 +1,7 @@ package deep import ( + "encoding/json" "testing" ) @@ -492,3 +493,258 @@ func TestBuilder_AddConditionFieldToField(t *testing.T) { t.Errorf("ApplyChecked should have failed for A <= B") } } + +func TestBuilder_SoftConditions(t *testing.T) { + type User struct { + Name string + Age int + } + u := User{Name: "Alice", Age: 30} + + builder := NewBuilder[User]() + nodeName, _ := builder.Root().Field("Name") + nodeName.If(Equal[User]("Age", 20)).Set("Alice", "Bob") + nodeAge, _ := builder.Root().Field("Age") + nodeAge.If(Equal[User]("Name", "Alice")).Set(30, 31) + + patch, _ := builder.Build() + + if err := patch.ApplyChecked(&u); err != nil { + t.Fatalf("ApplyChecked failed: %v", err) + } + + // Name should be Alice (skipped because Age != 20) + if u.Name != "Alice" { + t.Errorf("Expected Name=Alice, got %s", u.Name) + } + // Age should be 31 (Set(30, 31) applied because Name == Alice) + if u.Age != 31 { + t.Errorf("Expected Age=31, got %d", u.Age) + } +} + +func TestBuilder_Unless(t *testing.T) { + type User struct { + Name string + } + u := User{Name: "Alice"} + builder := NewBuilder[User]() + node, _ := builder.Root().Field("Name") + node.Unless(Equal[User]("Name", "Alice")).Set("Alice", "Bob") + patch, _ := builder.Build() + + err := patch.ApplyChecked(&u) + if err != nil { + t.Fatalf("ApplyChecked failed: %v", err) + } + if u.Name != "Alice" { + t.Errorf("Expected Name=Alice, got %s", u.Name) + } +} + +func TestBuilder_AtomicTest(t *testing.T) { + type User struct { + Name string + Age int + } + u := User{Name: "Alice", Age: 30} + + builder := NewBuilder[User]() + nodeName, _ := builder.Root().Field("Name") + nodeName.Test("Alice") + nodeAge, _ := builder.Root().Field("Age") + nodeAge.Set(30, 31) + patch, _ := builder.Build() + + if err := patch.ApplyChecked(&u); err != nil { + t.Fatalf("ApplyChecked failed: %v", err) + } + if u.Age != 31 { + t.Errorf("Expected Age=31, got %d", u.Age) + } + + // Test failure + u.Name = "Bob" + u.Age = 30 + if err := patch.ApplyChecked(&u); err == nil { + t.Fatal("Expected error because Name is Bob, not Alice") + } +} + +func TestBuilder_Copy(t *testing.T) { + type User struct { + Name string + AltName string + } + u := User{Name: "Alice", AltName: ""} + + builder := NewBuilder[User]() + nodeAlt, _ := builder.Root().Field("AltName") + nodeAlt.Copy("/Name") + + patch, _ := builder.Build() + + if err := patch.ApplyChecked(&u); err != nil { + t.Fatalf("ApplyChecked failed: %v", err) + } + + if u.AltName != "Alice" { + t.Errorf("Expected AltName=Alice, got %s", u.AltName) + } +} + +func TestBuilder_Move(t *testing.T) { + type User struct { + Name string + AltName string + } + u := User{Name: "Alice", AltName: ""} + + builder := NewBuilder[User]() + nodeAlt, _ := builder.Root().Field("AltName") + nodeAlt.Move("/Name") + + patch, _ := builder.Build() + + // Verify JSON Patch + jsonPatchBytes, _ := patch.ToJSONPatch() + var ops []map[string]any + json.Unmarshal(jsonPatchBytes, &ops) + if ops[0]["op"] != "move" || ops[0]["from"] != "/Name" || ops[0]["path"] != "/AltName" { + t.Errorf("Unexpected JSON Patch for move: %s", string(jsonPatchBytes)) + } + + // ApplyChecked + if err := patch.ApplyChecked(&u); err != nil { + t.Fatalf("ApplyChecked failed: %v", err) + } + + if u.AltName != "Alice" { + t.Errorf("Expected AltName=Alice, got %s", u.AltName) + } + if u.Name != "" { + t.Errorf("Expected Name to be cleared, got %s", u.Name) + } +} + +func TestBuilder_MoveReverse(t *testing.T) { + type User struct { + Name string + AltName string + } + u := User{Name: "Alice", AltName: ""} + + builder := NewBuilder[User]() + nodeAlt, _ := builder.Root().Field("AltName") + nodeAlt.Move("/Name") + + patch, _ := builder.Build() + + if err := patch.ApplyChecked(&u); err != nil { + t.Fatalf("ApplyChecked failed: %v", err) + } + + if u.AltName != "Alice" || u.Name != "" { + t.Fatalf("Move failed: %+v", u) + } + + rev := patch.Reverse() + if err := rev.ApplyChecked(&u); err != nil { + t.Fatalf("Reverse ApplyChecked failed: %v", err) + } + + if u.Name != "Alice" || u.AltName != "" { + t.Errorf("Reverse failed: %+v", u) + } +} + +func TestBuilder_AddConditionJSONPointer(t *testing.T) { + type User struct { + Name string + Age int + } + u := User{Name: "Alice", Age: 30} + + builder := NewBuilder[User]() + builder.AddCondition("/Name == 'Alice'") + nodeAge, _ := builder.Root().Field("Age") + nodeAge.Set(30, 31) + + patch, _ := builder.Build() + + if err := patch.ApplyChecked(&u); err != nil { + t.Fatalf("ApplyChecked failed: %v", err) + } + + if u.Age != 31 { + t.Errorf("Expected Age=31, got %d", u.Age) + } + + // Test failure + u.Name = "Bob" + u.Age = 30 + if err := patch.ApplyChecked(&u); err == nil { + t.Fatal("Expected error because Name is Bob") + } +} + +func TestBuilder_DeleteExhaustive(t *testing.T) { + // Map + b1 := NewBuilder[map[string]int]() + b1.Root().Delete("a", 1) + + // Slice + b2 := NewBuilder[[]int]() + b2.Root().Delete(0, 1) + + // Error: not a container + b3 := NewBuilder[int]() + err := b3.Root().Delete("a", 1) + if err == nil { + t.Error("Expected error deleting from non-container") + } +} + +func TestBuilder_AddCondition_CornerCases(t *testing.T) { + type Data struct { + A int + B int + } + + // No common prefix + b := NewBuilder[Data]() + b.AddCondition("A == 1 AND B == 1") + + // Invalid expression + b2 := NewBuilder[Data]() + b2.AddCondition("INVALID") + if b2.err == nil { + t.Error("Expected error for invalid condition") + } +} + +func TestBuilder_Log(t *testing.T) { + type User struct { + Name string + } + u := User{Name: "Alice"} + + builder := NewBuilder[User]() + node, _ := builder.Root().Field("Name") + node.Log("Checking user name") + + patch, _ := builder.Build() + + // Capture stdout to verify log (optional, but let's at least run it) + if err := patch.ApplyChecked(&u); err != nil { + t.Fatalf("ApplyChecked failed: %v", err) + } + + // Verify JSON export + jsonBytes, _ := patch.ToJSONPatch() + var ops []map[string]any + json.Unmarshal(jsonBytes, &ops) + if ops[0]["op"] != "log" || ops[0]["value"] != "Checking user name" { + t.Errorf("Unexpected JSON for log: %s", string(jsonBytes)) + } +} diff --git a/condition.go b/condition.go index 57d0a4c..430e812 100644 --- a/condition.go +++ b/condition.go @@ -22,54 +22,77 @@ type Path string // resolve traverses v using the path and returns the reflect.Value found. func (p Path) resolve(v reflect.Value) (reflect.Value, error) { + parts := parsePath(string(p)) + val, _, err := p.navigate(v, parts) + return val, err +} + +func (p Path) resolveParent(v reflect.Value) (reflect.Value, pathPart, error) { + parts := parsePath(string(p)) + if len(parts) == 0 { + return reflect.Value{}, pathPart{}, fmt.Errorf("path is empty") + } + parent, _, err := p.navigate(v, parts[:len(parts)-1]) + if err != nil { + return reflect.Value{}, pathPart{}, err + } + return parent, parts[len(parts)-1], nil +} + +func (p Path) navigate(v reflect.Value, parts []pathPart) (reflect.Value, pathPart, error) { current, err := dereference(v) if err != nil { - return reflect.Value{}, err + return reflect.Value{}, pathPart{}, err } - parts := parsePath(string(p)) for _, part := range parts { if !current.IsValid() { - return reflect.Value{}, fmt.Errorf("path traversal failed: nil value at intermediate step") + return reflect.Value{}, pathPart{}, fmt.Errorf("path traversal failed: nil value at intermediate step") } - if part.isIndex { - if current.Kind() != reflect.Slice && current.Kind() != reflect.Array { - return reflect.Value{}, fmt.Errorf("cannot index into %v", current.Type()) - } + if part.isIndex && (current.Kind() == reflect.Slice || current.Kind() == reflect.Array) { if part.index < 0 || part.index >= current.Len() { - return reflect.Value{}, fmt.Errorf("index out of bounds: %d", part.index) + return reflect.Value{}, pathPart{}, fmt.Errorf("index out of bounds: %d", part.index) } current = current.Index(part.index) } else if current.Kind() == reflect.Map { keyType := current.Type().Key() var keyVal reflect.Value + key := part.key + if key == "" && part.isIndex { + key = strconv.Itoa(part.index) + } if keyType.Kind() == reflect.String { - keyVal = reflect.ValueOf(part.key) + keyVal = reflect.ValueOf(key) } else if keyType.Kind() == reflect.Int { - i, err := strconv.Atoi(part.key) + i, err := strconv.Atoi(key) if err != nil { - return reflect.Value{}, fmt.Errorf("invalid int key: %s", part.key) + return reflect.Value{}, pathPart{}, fmt.Errorf("invalid int key: %s", key) } keyVal = reflect.ValueOf(i) } else { - return reflect.Value{}, fmt.Errorf("unsupported map key type for path: %v", keyType) + return reflect.Value{}, pathPart{}, fmt.Errorf("unsupported map key type for path: %v", keyType) } val := current.MapIndex(keyVal) if !val.IsValid() { - return reflect.Value{}, nil + return reflect.Value{}, pathPart{}, nil } current = val } else { if current.Kind() != reflect.Struct { - return reflect.Value{}, fmt.Errorf("cannot access field %s on %v", part.key, current.Type()) + return reflect.Value{}, pathPart{}, fmt.Errorf("cannot access field %s on %v", part.key, current.Type()) + } + + key := part.key + if key == "" && part.isIndex { + key = strconv.Itoa(part.index) } // We use FieldByName and disableRO to support unexported fields. - f := current.FieldByName(part.key) + f := current.FieldByName(key) if !f.IsValid() { - return reflect.Value{}, fmt.Errorf("field %s not found", part.key) + return reflect.Value{}, pathPart{}, fmt.Errorf("field %s not found", key) } unsafe.DisableRO(&f) current = f @@ -77,10 +100,134 @@ func (p Path) resolve(v reflect.Value) (reflect.Value, error) { current, err = dereference(current) if err != nil { - return reflect.Value{}, err + return reflect.Value{}, pathPart{}, err + } + if len(parts) > 0 && part == parts[len(parts)-1] { + return current, part, nil + } + } + return current, pathPart{}, nil +} + +func (p Path) set(v reflect.Value, val reflect.Value) error { + parent, lastPart, err := p.resolveParent(v) + if err != nil { + // If path is root, set v directly if possible. + if string(p) == "" || string(p) == "/" { + if !v.CanSet() { + return fmt.Errorf("cannot set root value") + } + v.Set(val) + return nil + } + return err + } + + switch parent.Kind() { + case reflect.Map: + keyType := parent.Type().Key() + var keyVal reflect.Value + key := lastPart.key + if key == "" && lastPart.isIndex { + key = strconv.Itoa(lastPart.index) + } + if keyType.Kind() == reflect.String { + keyVal = reflect.ValueOf(key) + } else if keyType.Kind() == reflect.Int { + i, _ := strconv.Atoi(key) + keyVal = reflect.ValueOf(i) + } + parent.SetMapIndex(keyVal, convertValue(val, parent.Type().Elem())) + return nil + case reflect.Slice: + idx := lastPart.index + if !lastPart.isIndex { + idx, err = strconv.Atoi(lastPart.key) + if err != nil { + return fmt.Errorf("invalid slice index: %s", lastPart.key) + } + } + if idx < 0 || idx > parent.Len() { + return fmt.Errorf("index out of bounds: %d", idx) + } + if idx == parent.Len() { + parent.Set(reflect.Append(parent, convertValue(val, parent.Type().Elem()))) + } else { + parent.Index(idx).Set(convertValue(val, parent.Type().Elem())) + } + return nil + case reflect.Struct: + key := lastPart.key + if key == "" && lastPart.isIndex { + key = strconv.Itoa(lastPart.index) + } + f := parent.FieldByName(key) + if !f.IsValid() { + return fmt.Errorf("field %s not found", key) + } + if !f.CanSet() { + unsafe.DisableRO(&f) + } + f.Set(convertValue(val, f.Type())) + return nil + default: + return fmt.Errorf("cannot set value in %v", parent.Kind()) + } +} + +func (p Path) delete(v reflect.Value) error { + parent, lastPart, err := p.resolveParent(v) + if err != nil { + return err + } + + switch parent.Kind() { + case reflect.Map: + keyType := parent.Type().Key() + var keyVal reflect.Value + key := lastPart.key + if key == "" && lastPart.isIndex { + key = strconv.Itoa(lastPart.index) + } + if keyType.Kind() == reflect.String { + keyVal = reflect.ValueOf(key) + } else if keyType.Kind() == reflect.Int { + i, _ := strconv.Atoi(key) + keyVal = reflect.ValueOf(i) + } + parent.SetMapIndex(keyVal, reflect.Value{}) + return nil + case reflect.Slice: + idx := lastPart.index + if !lastPart.isIndex { + idx, err = strconv.Atoi(lastPart.key) + if err != nil { + return fmt.Errorf("invalid slice index: %s", lastPart.key) + } + } + if idx < 0 || idx >= parent.Len() { + return fmt.Errorf("index out of bounds: %d", idx) } + newSlice := reflect.AppendSlice(parent.Slice(0, idx), parent.Slice(idx+1, parent.Len())) + parent.Set(newSlice) + return nil + case reflect.Struct: + key := lastPart.key + if key == "" && lastPart.isIndex { + key = strconv.Itoa(lastPart.index) + } + f := parent.FieldByName(key) + if !f.IsValid() { + return fmt.Errorf("field %s not found", key) + } + if !f.CanSet() { + unsafe.DisableRO(&f) + } + f.Set(reflect.Zero(f.Type())) + return nil + default: + return fmt.Errorf("cannot delete from %v", parent.Kind()) } - return current, nil } func dereference(v reflect.Value) (reflect.Value, error) { @@ -152,7 +299,51 @@ type pathPart struct { isIndex bool } +func (p pathPart) equals(other pathPart) bool { + if p.isIndex != other.isIndex { + return false + } + if p.isIndex { + return p.index == other.index + } + return p.key == other.key +} + +func (p Path) stripParts(prefix []pathPart) Path { + parts := parsePath(string(p)) + if len(parts) < len(prefix) { + return p + } + for i := range prefix { + if !parts[i].equals(prefix[i]) { + return p + } + } + remaining := parts[len(prefix):] + if len(remaining) == 0 { + return "" + } + // Reconstruct as Go-style for internal relative path consistency. + var res strings.Builder + for i, part := range remaining { + if part.isIndex { + res.WriteByte('[') + res.WriteString(strconv.Itoa(part.index)) + res.WriteByte(']') + } else { + if i > 0 { + res.WriteByte('.') + } + res.WriteString(part.key) + } + } + return Path(res.String()) +} + func parsePath(path string) []pathPart { + if strings.HasPrefix(path, "/") { + return parseJSONPointer(path) + } var parts []pathPart var buf strings.Builder flush := func() { @@ -187,11 +378,29 @@ func parsePath(path string) []pathPart { return parts } +func parseJSONPointer(path string) []pathPart { + if path == "/" { + return nil + } + tokens := strings.Split(path, "/")[1:] + parts := make([]pathPart, len(tokens)) + for i, token := range tokens { + token = strings.ReplaceAll(token, "~1", "/") + token = strings.ReplaceAll(token, "~0", "~") + if idx, err := strconv.Atoi(token); err == nil && idx >= 0 { + parts[i] = pathPart{key: token, index: idx, isIndex: true} + } else { + parts[i] = pathPart{key: token} + } + } + return parts +} + // rawCondition is the internal non-generic interface for conditions. type rawCondition interface { Evaluate(v any) (bool, error) paths() []Path - withRelativePaths(prefix string) rawCondition + withRelativeParts(prefix []pathPart) rawCondition } func toReflectValue(v any) reflect.Value { @@ -224,9 +433,9 @@ func (c *rawCompareCondition) paths() []Path { return []Path{c.Path} } -func (c *rawCompareCondition) withRelativePaths(prefix string) rawCondition { +func (c *rawCompareCondition) withRelativeParts(prefix []pathPart) rawCondition { return &rawCompareCondition{ - Path: Path(strings.TrimPrefix(strings.TrimPrefix(string(c.Path), prefix), ".")), + Path: c.Path.stripParts(prefix), Val: c.Val, Op: c.Op, } @@ -255,10 +464,10 @@ func (c *rawCompareFieldCondition) paths() []Path { return []Path{c.Path1, c.Path2} } -func (c *rawCompareFieldCondition) withRelativePaths(prefix string) rawCondition { +func (c *rawCompareFieldCondition) withRelativeParts(prefix []pathPart) rawCondition { return &rawCompareFieldCondition{ - Path1: Path(strings.TrimPrefix(strings.TrimPrefix(string(c.Path1), prefix), ".")), - Path2: Path(strings.TrimPrefix(strings.TrimPrefix(string(c.Path2), prefix), ".")), + Path1: c.Path1.stripParts(prefix), + Path2: c.Path2.stripParts(prefix), Op: c.Op, } } @@ -288,10 +497,10 @@ func (c *rawAndCondition) paths() []Path { return res } -func (c *rawAndCondition) withRelativePaths(prefix string) rawCondition { +func (c *rawAndCondition) withRelativeParts(prefix []pathPart) rawCondition { res := &rawAndCondition{Conditions: make([]rawCondition, len(c.Conditions))} for i, sub := range c.Conditions { - res.Conditions[i] = sub.withRelativePaths(prefix) + res.Conditions[i] = sub.withRelativeParts(prefix) } return res } @@ -321,10 +530,10 @@ func (c *rawOrCondition) paths() []Path { return res } -func (c *rawOrCondition) withRelativePaths(prefix string) rawCondition { +func (c *rawOrCondition) withRelativeParts(prefix []pathPart) rawCondition { res := &rawOrCondition{Conditions: make([]rawCondition, len(c.Conditions))} for i, sub := range c.Conditions { - res.Conditions[i] = sub.withRelativePaths(prefix) + res.Conditions[i] = sub.withRelativeParts(prefix) } return res } @@ -345,8 +554,8 @@ func (c *rawNotCondition) paths() []Path { return c.C.paths() } -func (c *rawNotCondition) withRelativePaths(prefix string) rawCondition { - return &rawNotCondition{C: c.C.withRelativePaths(prefix)} +func (c *rawNotCondition) withRelativeParts(prefix []pathPart) rawCondition { + return &rawNotCondition{C: c.C.withRelativeParts(prefix)} } type CompareCondition[T any] struct { @@ -579,7 +788,7 @@ func (l *lexer) next() token { return l.lexString(c) case isDigit(c): return l.lexNumber() - case isAlpha(c): + case isAlpha(c) || c == '/': return l.lexIdent() } return token{kind: tokError, val: string(c)} @@ -621,7 +830,7 @@ func (l *lexer) lexNumber() token { func (l *lexer) lexIdent() token { start := l.pos - for l.pos < len(l.input) && (isAlpha(l.input[l.pos]) || isDigit(l.input[l.pos]) || l.input[l.pos] == '.' || l.input[l.pos] == '[' || l.input[l.pos] == ']') { + for l.pos < len(l.input) && (isAlpha(l.input[l.pos]) || isDigit(l.input[l.pos]) || l.input[l.pos] == '.' || l.input[l.pos] == '[' || l.input[l.pos] == ']' || l.input[l.pos] == '/' || l.input[l.pos] == '~') { l.pos++ } val := l.input[start:l.pos] diff --git a/condition_test.go b/condition_test.go index 5d24ba6..8be5a91 100644 --- a/condition_test.go +++ b/condition_test.go @@ -6,7 +6,29 @@ import ( "testing" ) -func TestPath_Resolve(t *testing.T) { +func TestPath_Errors_Exhaustive(t *testing.T) { + type S struct{ A int } + s := S{A: 1} + rv := reflect.ValueOf(&s).Elem() + + // resolveParent empty + Path("").resolveParent(rv) + + // navigate nil intermediate + type N struct{ P *S } + n := N{P: nil} + Path("/P/A").resolve(reflect.ValueOf(n)) + + // set root error + val := 1 + Path("/").set(reflect.ValueOf(val), reflect.ValueOf(2)) + + // set slice oob + slc := []int{1} + Path("/2").set(reflect.ValueOf(&slc).Elem(), reflect.ValueOf(2)) +} + +func TestJSONPointer_Resolve(t *testing.T) { type Child struct { Name string } @@ -22,9 +44,9 @@ func TestPath_Resolve(t *testing.T) { path string want any }{ - {"Kids[0].Name", "A"}, - {"Kids[1].Name", "B"}, - {"Meta.v", 1}, + {"/Kids/0/Name", "A"}, + {"/Kids/1/Name", "B"}, + {"/Meta/v", 1}, } for _, tt := range tests { val, err := Path(tt.path).resolve(reflect.ValueOf(d)) @@ -38,6 +60,87 @@ func TestPath_Resolve(t *testing.T) { } } +func TestJSONPointer_SpecialChars(t *testing.T) { + m := map[string]int{ + "foo/bar": 1, + "foo~bar": 2, + } + tests := []struct { + path string + want any + }{ + {"/foo~1bar", 1}, + {"/foo~0bar", 2}, + } + for _, tt := range tests { + val, err := Path(tt.path).resolve(reflect.ValueOf(m)) + if err != nil { + t.Errorf("Resolve(%q) failed: %v", tt.path, err) + continue + } + if !reflect.DeepEqual(val.Interface(), tt.want) { + t.Errorf("Resolve(%q) = %v, want %v", tt.path, val.Interface(), tt.want) + } + } +} + +func TestJSONPointer_InConditions(t *testing.T) { + type User struct { + Name string + } + u := User{Name: "Alice"} + + expr := "/Name == 'Alice'" + cond, err := ParseCondition[User](expr) + if err != nil { + t.Fatalf("ParseCondition failed: %v", err) + } + ok, err := cond.Evaluate(&u) + if err != nil { + t.Fatalf("Evaluate failed: %v", err) + } + if !ok { + t.Errorf("Expected true for %q", expr) + } +} + +func TestPath_SetDelete(t *testing.T) { + type Data struct { + A int + M map[string]int + S []int + } + d := Data{M: map[string]int{"a": 1}, S: []int{1, 2}} + rv := reflect.ValueOf(&d).Elem() + + // Path.set + Path("/A").set(rv, reflect.ValueOf(10)) + Path("/M/b").set(rv, reflect.ValueOf(20)) + Path("/S/1").set(rv, reflect.ValueOf(30)) + Path("/S/2").set(rv, reflect.ValueOf(40)) // Append + + if d.A != 10 || d.M["b"] != 20 || d.S[1] != 30 || d.S[2] != 40 { + t.Errorf("Path.set failed: %+v", d) + } + + // Path.delete + Path("/M/a").delete(rv) + Path("/S/0").delete(rv) + Path("/A").delete(rv) + + if _, ok := d.M["a"]; ok || len(d.S) != 2 || d.A != 0 { + t.Errorf("Path.delete failed: %+v", d) + } + + // Root set + val := 1 + rvVal := reflect.ValueOf(&val).Elem() + Path("/").set(rvVal, reflect.ValueOf(2)) + if val != 2 { + t.Errorf("Root Path.set failed: %d", val) + } +} + func TestApplyChecked_Basic(t *testing.T) { type S struct { A int @@ -504,9 +607,9 @@ func TestCondition_Errors(t *testing.T) { if len(or.paths()) != 2 { t.Errorf("Expected 2 paths, got %d", len(or.paths())) } - relOr := or.withRelativePaths("Prefix") + relOr := or.withRelativeParts(parsePath("Prefix")) if relOr.(*rawOrCondition).Conditions[0].(*rawCompareCondition).Path != "A" { - // Actually withRelativePaths("Prefix") on path "A" should be "A" if prefix not found + // Actually withRelativeParts("Prefix") on path "A" should be "A" if prefix not found } // Test rawNotCondition paths/relative @@ -514,7 +617,7 @@ func TestCondition_Errors(t *testing.T) { if len(not.paths()) != 1 { t.Error("Expected 1 path for not") } - relNot := not.withRelativePaths("A") + relNot := not.withRelativeParts(parsePath("A")) if relNot.(*rawNotCondition).C.(*rawCompareCondition).Path != "" { t.Errorf("Expected empty path after stripping prefix, got %q", relNot.(*rawNotCondition).C.(*rawCompareCondition).Path) } diff --git a/copy.go b/copy.go index 8d9f351..4e7788e 100644 --- a/copy.go +++ b/copy.go @@ -114,7 +114,11 @@ func recursiveCopy(v reflect.Value, pointers pointersMap, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.String: - // Direct type, just copy it. + // Direct type. We return a new reflect.Value with the same value + // to ensure it's not tied to a struct field (breaking addressability). + if v.CanInterface() { + return reflect.ValueOf(v.Interface()), nil + } return v, nil case reflect.Array: return recursiveCopyArray(v, pointers, skipUnsupported) diff --git a/patch.go b/patch.go index c94d53f..16dac45 100644 --- a/patch.go +++ b/patch.go @@ -32,6 +32,9 @@ type Patch[T any] interface { // Reverse returns a new Patch that undoes the changes in this patch. Reverse() Patch[T] + + // ToJSONPatch returns an RFC 6902 compliant JSON Patch representation of this patch. + ToJSONPatch() ([]byte, error) } // NewPatch returns a new, empty patch for type T. @@ -56,7 +59,7 @@ func (p *typedPatch[T]) Apply(v *T) { return } rv := reflect.ValueOf(v).Elem() - p.inner.apply(rv) + p.inner.apply(reflect.ValueOf(v), rv) } func (p *typedPatch[T]) ApplyChecked(v *T) error { @@ -75,7 +78,7 @@ func (p *typedPatch[T]) ApplyChecked(v *T) error { } rv := reflect.ValueOf(v).Elem() - return p.inner.applyChecked(v, rv, p.strict) + return p.inner.applyChecked(reflect.ValueOf(v), rv, p.strict) } func (p *typedPatch[T]) WithCondition(c Condition[T]) Patch[T] { @@ -104,6 +107,15 @@ func (p *typedPatch[T]) Reverse() Patch[T] { } } +func (p *typedPatch[T]) ToJSONPatch() ([]byte, error) { + if p.inner == nil { + return json.Marshal([]any{}) + } + // We pass empty string because toJSONPatch prepends "/" when needed + // and handles root as "/". + return json.Marshal(p.inner.toJSONPatch("")) +} + func (p *typedPatch[T]) String() string { if p.inner == nil { return "" @@ -199,51 +211,409 @@ func (p *typedPatch[T]) GobDecode(data []byte) error { return nil } +var ErrConditionSkipped = fmt.Errorf("condition skipped") + // diffPatch is the internal recursive interface for all patch types. type diffPatch interface { - apply(v reflect.Value) - applyChecked(root any, v reflect.Value, strict bool) error + apply(root, v reflect.Value) + applyChecked(root, v reflect.Value, strict bool) error reverse() diffPatch format(indent int) string setCondition(cond any) + setIfCondition(cond any) + setUnlessCondition(cond any) + conditions() (cond, ifCond, unlessCond any) + toJSONPatch(path string) []map[string]any +} + +type patchMetadata struct { + cond any + ifCond any + unlessCond any +} + +func (m *patchMetadata) setCondition(cond any) { m.cond = cond } +func (m *patchMetadata) setIfCondition(cond any) { m.ifCond = cond } +func (m *patchMetadata) setUnlessCondition(cond any) { m.unlessCond = cond } +func (m *patchMetadata) conditions() (any, any, any) { return m.cond, m.ifCond, m.unlessCond } + +func checkConditions(p diffPatch, root, v reflect.Value) error { + cond, ifC, unlessC := p.conditions() + if err := checkIfUnless(ifC, unlessC, root); err != nil { + return err + } + return evaluateLocalCondition(cond, v) } func evaluateLocalCondition(cond any, v reflect.Value) error { if cond == nil { return nil } + ok, err := evaluateCondition(cond, v) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("local condition failed for value %v", v.Interface()) + } + return nil +} + +func evaluateCondition(cond any, v reflect.Value) (bool, error) { method := reflect.ValueOf(cond).MethodByName("Evaluate") if !method.IsValid() { - return fmt.Errorf("local condition: method Evaluate not found on %T", cond) + return false, fmt.Errorf("local condition: method Evaluate not found on %T", cond) + } + argType := method.Type().In(0) + var arg reflect.Value + if v.Type().AssignableTo(argType) { + arg = v + } else if reflect.PtrTo(v.Type()).AssignableTo(argType) { + arg = reflect.New(v.Type()) + arg.Elem().Set(v) + } else if v.Kind() == reflect.Ptr && v.Elem().Type().AssignableTo(argType) { + arg = v.Elem() // Wait, this is probably not what we want if it expects *T + } else { + // Try to convert + if v.CanConvert(argType) { + arg = v.Convert(argType) + } else { + return false, fmt.Errorf("cannot call Evaluate: argument type mismatch, expected %v, got %v", argType, v.Type()) + } } - ptr := reflect.New(v.Type()) - ptr.Elem().Set(v) - results := method.Call([]reflect.Value{ptr}) + results := method.Call([]reflect.Value{arg}) if !results[1].IsNil() { - return results[1].Interface().(error) + return false, results[1].Interface().(error) } - if !results[0].Bool() { - return fmt.Errorf("local condition failed for value %v", v.Interface()) + return results[0].Bool(), nil +} + +func checkIfUnless(ifCond, unlessCond any, v reflect.Value) error { + if ifCond != nil { + ok, err := evaluateCondition(ifCond, v) + if err != nil { + return err + } + if !ok { + return ErrConditionSkipped + } + } + if unlessCond != nil { + ok, err := evaluateCondition(unlessCond, v) + if err != nil { + return err + } + if ok { + return ErrConditionSkipped + } } return nil } // valuePatch handles replacement of basic types and full replacement of complex types. type valuePatch struct { + patchMetadata oldVal reflect.Value newVal reflect.Value - cond any } -func (p *valuePatch) apply(v reflect.Value) { +func (p *valuePatch) apply(root, v reflect.Value) { if !v.CanSet() { unsafe.DisableRO(&v) } setValue(v, p.newVal) } -func (p *valuePatch) setCondition(cond any) { - p.cond = cond +// testPatch handles equality checks without modifying the value. +type testPatch struct { + patchMetadata + expected reflect.Value +} + +func (p *testPatch) apply(root, v reflect.Value) { + // No-op +} + +func (p *testPatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } + return err + } + if p.expected.IsValid() { + if !v.IsValid() { + return fmt.Errorf("test failed: expected %v, got invalid", p.expected) + } + convertedExpected := convertValue(p.expected, v.Type()) + if !reflect.DeepEqual(v.Interface(), convertedExpected.Interface()) { + return fmt.Errorf("test failed: expected %v, got %v", convertedExpected, v) + } + } + + return nil +} + +func (p *testPatch) reverse() diffPatch { + return p // Reversing a test is still a test +} + +func (p *testPatch) format(indent int) string { + if p.expected.IsValid() { + return fmt.Sprintf("Test(%v)", p.expected) + } + return "Test()" +} + +func (p *testPatch) toJSONPatch(path string) []map[string]any { + fullPath := path + if fullPath == "" { + fullPath = "/" + } + op := map[string]any{"op": "test", "path": fullPath, "value": valueToInterface(p.expected)} + addConditionsToOp(op, p) + return []map[string]any{op} +} + +// copyPatch copies a value from another path. +type copyPatch struct { + patchMetadata + from string + path string // target path for reversal +} + +func (p *copyPatch) apply(root, v reflect.Value) { + p.applyChecked(root, v, false) +} + +func (p *copyPatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } + return err + } + rvRoot := root + if rvRoot.Kind() == reflect.Ptr { + rvRoot = rvRoot.Elem() + } + fromVal, err := Path(p.from).resolve(rvRoot) + if err != nil { + return fmt.Errorf("copy from %s failed: %w", p.from, err) + } + if !v.CanSet() { + unsafe.DisableRO(&v) + } + setValue(v, fromVal) + return nil +} + +func (p *copyPatch) reverse() diffPatch { + // Reversing a copy is a removal of the target. + return &valuePatch{newVal: reflect.Value{}} +} + +func (p *copyPatch) format(indent int) string { + return fmt.Sprintf("Copy(from: %s)", p.from) +} + +func (p *copyPatch) toJSONPatch(path string) []map[string]any { + fullPath := path + if fullPath == "" { + fullPath = "/" + } + p.path = fullPath + op := map[string]any{"op": "copy", "from": p.from, "path": fullPath} + addConditionsToOp(op, p) + return []map[string]any{op} +} + +// movePatch moves a value from another path. +type movePatch struct { + patchMetadata + from string + path string // target path for reversal +} + +func (p *movePatch) apply(root, v reflect.Value) { + // For document-wide operations like move, apply needs root. + // Since apply(v) might not be root (it's the node it's attached to), + // this is why move is better handled at document level. + // However, to keep it consistent, we can try to use v as root if we are at root. + p.applyChecked(root, v, false) +} + +func (p *movePatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } + return err + } + rvRoot := root + if rvRoot.Kind() != reflect.Ptr { + // We need a pointer to be able to delete/set values. + return fmt.Errorf("root must be a pointer for move operation") + } + rvRoot = rvRoot.Elem() + + fromVal, err := Path(p.from).resolve(rvRoot) + if err != nil { + return fmt.Errorf("move from %s failed: %w", p.from, err) + } + + // Deep copy because we might be deleting it from source next. + fromVal = deepCopyValue(fromVal) + + // Remove from source. + if err := Path(p.from).delete(rvRoot); err != nil { + return fmt.Errorf("move delete from %s failed: %w", p.from, err) + } + + if err := Path(p.path).set(rvRoot, fromVal); err != nil { + return fmt.Errorf("move set to %s failed: %w", p.path, err) + } + return nil +} + +func (p *movePatch) reverse() diffPatch { + return &movePatch{from: p.path, path: p.from} +} + +func (p *movePatch) format(indent int) string { + return fmt.Sprintf("Move(from: %s)", p.from) +} + +func (p *movePatch) toJSONPatch(path string) []map[string]any { + fullPath := path + if fullPath == "" { + fullPath = "/" + } + p.path = fullPath // capture path for potential reversal + op := map[string]any{"op": "move", "from": p.from, "path": fullPath} + addConditionsToOp(op, p) + return []map[string]any{op} +} + +func addConditionsToOp(op map[string]any, p diffPatch) { + _, ifC, unlessC := p.conditions() + if ifC != nil { + op["if"] = conditionToPredicate(ifC) + } + if unlessC != nil { + op["unless"] = conditionToPredicate(unlessC) + } +} + +func conditionToPredicate(c any) any { + if c == nil { + return nil + } + + v := reflect.ValueOf(c) + for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + v = v.Elem() + } + + typeName := v.Type().Name() + if strings.HasPrefix(typeName, "typedRawCondition") || strings.HasPrefix(typeName, "typedCondition") { + raw := v.FieldByName("raw") + unsafe.DisableRO(&raw) + return conditionToPredicate(raw.Interface()) + } + + if strings.HasPrefix(typeName, "rawCompareCondition") || strings.HasPrefix(typeName, "CompareCondition") { + path := v.FieldByName("Path").String() + val := v.FieldByName("Val").Interface() + op := v.FieldByName("Op").String() + + switch op { + case "==": + return map[string]any{"op": "test", "path": path, "value": val} + case "!=": + return map[string]any{"op": "not", "apply": []any{map[string]any{"op": "test", "path": path, "value": val}}} + case "<": + return map[string]any{"op": "less", "path": path, "value": val} + case ">": + return map[string]any{"op": "more", "path": path, "value": val} + case "<=": + return map[string]any{"op": "or", "apply": []any{ + map[string]any{"op": "less", "path": path, "value": val}, + map[string]any{"op": "test", "path": path, "value": val}, + }} + case ">=": + return map[string]any{"op": "or", "apply": []any{ + map[string]any{"op": "more", "path": path, "value": val}, + map[string]any{"op": "test", "path": path, "value": val}, + }} + } + } + + if strings.HasPrefix(typeName, "AndCondition") { + condsVal := v.FieldByName("Conditions") + apply := make([]any, 0, condsVal.Len()) + for i := 0; i < condsVal.Len(); i++ { + apply = append(apply, conditionToPredicate(condsVal.Index(i).Interface())) + } + return map[string]any{"op": "and", "apply": apply} + } + + if strings.HasPrefix(typeName, "OrCondition") { + condsVal := v.FieldByName("Conditions") + apply := make([]any, 0, condsVal.Len()) + for i := 0; i < condsVal.Len(); i++ { + apply = append(apply, conditionToPredicate(condsVal.Index(i).Interface())) + } + return map[string]any{"op": "or", "apply": apply} + } + + if strings.HasPrefix(typeName, "NotCondition") { + sub := conditionToPredicate(v.FieldByName("C").Interface()) + return map[string]any{"op": "not", "apply": []any{sub}} + } + + // CompareFieldCondition is not directly supported by standard JSON Predicates + // but we can export it if needed. Standard predicates usually compare path vs value. + return nil +} + +// logPatch logs a message without modifying the value. +type logPatch struct { + patchMetadata + message string +} + +func (p *logPatch) apply(root, v reflect.Value) { + fmt.Printf("DEEP LOG: %s (value: %v)\n", p.message, v.Interface()) +} + +func (p *logPatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } + return err + } + p.apply(root, v) + return nil +} + +func (p *logPatch) reverse() diffPatch { + return p // Reversing a log is still a log +} + +func (p *logPatch) format(indent int) string { + return fmt.Sprintf("Log(%q)", p.message) +} + +func (p *logPatch) toJSONPatch(path string) []map[string]any { + fullPath := path + if fullPath == "" { + fullPath = "/" + } + op := map[string]any{"op": "log", "path": fullPath, "value": p.message} + addConditionsToOp(op, p) + return []map[string]any{op} } func init() { @@ -253,7 +623,13 @@ func init() { gob.Register([]map[string]any{}) } -func (p *valuePatch) applyChecked(root any, v reflect.Value, strict bool) error { +func (p *valuePatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } + return err + } if strict && p.oldVal.IsValid() { if v.IsValid() { convertedOldVal := convertValue(p.oldVal, v.Type()) @@ -265,16 +641,12 @@ func (p *valuePatch) applyChecked(root any, v reflect.Value, strict bool) error } } - if err := evaluateLocalCondition(p.cond, v); err != nil { - return err - } - - p.apply(v) + p.apply(root, v) return nil } func (p *valuePatch) reverse() diffPatch { - return &valuePatch{oldVal: p.newVal, newVal: p.oldVal, cond: p.cond} + return &valuePatch{oldVal: p.newVal, newVal: p.oldVal, patchMetadata: p.patchMetadata} } func (p *valuePatch) format(indent int) string { @@ -292,72 +664,100 @@ func (p *valuePatch) format(indent int) string { return fmt.Sprintf("%s -> %s", oldStr, newStr) } +func (p *valuePatch) toJSONPatch(path string) []map[string]any { + fullPath := path + if fullPath == "" { + fullPath = "/" + } + var op map[string]any + if !p.newVal.IsValid() { + op = map[string]any{"op": "remove", "path": fullPath} + } else if !p.oldVal.IsValid() { + op = map[string]any{"op": "add", "path": fullPath, "value": valueToInterface(p.newVal)} + } else { + op = map[string]any{"op": "replace", "path": fullPath, "value": valueToInterface(p.newVal)} + } + addConditionsToOp(op, p) + return []map[string]any{op} +} + // ptrPatch handles changes to the content pointed to by a pointer. + type ptrPatch struct { - elemPatch diffPatch - cond any -} + patchMetadata -func (p *ptrPatch) setCondition(cond any) { - p.cond = cond + elemPatch diffPatch } -func (p *ptrPatch) apply(v reflect.Value) { +func (p *ptrPatch) apply(root, v reflect.Value) { if v.IsNil() { val := reflect.New(v.Type().Elem()) - p.elemPatch.apply(val.Elem()) + p.elemPatch.apply(root, val.Elem()) v.Set(val) return } - p.elemPatch.apply(v.Elem()) + p.elemPatch.apply(root, v.Elem()) } -func (p *ptrPatch) applyChecked(root any, v reflect.Value, strict bool) error { +func (p *ptrPatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } + return err + } if v.IsNil() { return fmt.Errorf("cannot apply pointer patch to nil value") } - if err := evaluateLocalCondition(p.cond, v); err != nil { - return err - } return p.elemPatch.applyChecked(root, v.Elem(), strict) } func (p *ptrPatch) reverse() diffPatch { - return &ptrPatch{elemPatch: p.elemPatch.reverse(), cond: p.cond} + return &ptrPatch{ + patchMetadata: p.patchMetadata, + elemPatch: p.elemPatch.reverse(), + } } func (p *ptrPatch) format(indent int) string { return p.elemPatch.format(indent) } +func (p *ptrPatch) toJSONPatch(path string) []map[string]any { + ops := p.elemPatch.toJSONPatch(path) + for _, op := range ops { + addConditionsToOp(op, p) + } + return ops +} + // interfacePatch handles changes to the value stored in an interface. type interfacePatch struct { + patchMetadata elemPatch diffPatch - cond any -} - -func (p *interfacePatch) setCondition(cond any) { - p.cond = cond } -func (p *interfacePatch) apply(v reflect.Value) { +func (p *interfacePatch) apply(root, v reflect.Value) { if v.IsNil() { return } elem := v.Elem() newElem := reflect.New(elem.Type()).Elem() newElem.Set(elem) - p.elemPatch.apply(newElem) + p.elemPatch.apply(root, newElem) v.Set(newElem) } -func (p *interfacePatch) applyChecked(root any, v reflect.Value, strict bool) error { +func (p *interfacePatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } + return err + } if v.IsNil() { return fmt.Errorf("cannot apply interface patch to nil value") } - if err := evaluateLocalCondition(p.cond, v); err != nil { - return err - } elem := v.Elem() newElem := reflect.New(elem.Type()).Elem() newElem.Set(elem) @@ -369,37 +769,47 @@ func (p *interfacePatch) applyChecked(root any, v reflect.Value, strict bool) er } func (p *interfacePatch) reverse() diffPatch { - return &interfacePatch{elemPatch: p.elemPatch.reverse(), cond: p.cond} + return &interfacePatch{ + patchMetadata: p.patchMetadata, + elemPatch: p.elemPatch.reverse(), + } } func (p *interfacePatch) format(indent int) string { return p.elemPatch.format(indent) } +func (p *interfacePatch) toJSONPatch(path string) []map[string]any { + ops := p.elemPatch.toJSONPatch(path) + for _, op := range ops { + addConditionsToOp(op, p) + } + return ops +} + // structPatch handles field-level modifications in a struct. type structPatch struct { + patchMetadata fields map[string]diffPatch - cond any -} - -func (p *structPatch) setCondition(cond any) { - p.cond = cond } -func (p *structPatch) apply(v reflect.Value) { +func (p *structPatch) apply(root, v reflect.Value) { for name, patch := range p.fields { f := v.FieldByName(name) if f.IsValid() { if !f.CanSet() { unsafe.DisableRO(&f) } - patch.apply(f) + patch.apply(root, f) } } } -func (p *structPatch) applyChecked(root any, v reflect.Value, strict bool) error { - if err := evaluateLocalCondition(p.cond, v); err != nil { +func (p *structPatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } return err } for name, patch := range p.fields { @@ -422,7 +832,10 @@ func (p *structPatch) reverse() diffPatch { for k, v := range p.fields { newFields[k] = v.reverse() } - return &structPatch{fields: newFields, cond: p.cond} + return &structPatch{ + patchMetadata: p.patchMetadata, + fields: newFields, + } } func (p *structPatch) format(indent int) string { @@ -436,30 +849,42 @@ func (p *structPatch) format(indent int) string { return b.String() } +func (p *structPatch) toJSONPatch(path string) []map[string]any { + var ops []map[string]any + for name, patch := range p.fields { + fullPath := path + "/" + name + subOps := patch.toJSONPatch(fullPath) + for _, op := range subOps { + addConditionsToOp(op, p) + } + ops = append(ops, subOps...) + } + return ops +} + // arrayPatch handles index-level modifications in a fixed-size array. type arrayPatch struct { + patchMetadata indices map[int]diffPatch - cond any -} - -func (p *arrayPatch) setCondition(cond any) { - p.cond = cond } -func (p *arrayPatch) apply(v reflect.Value) { +func (p *arrayPatch) apply(root, v reflect.Value) { for i, patch := range p.indices { if i < v.Len() { e := v.Index(i) if !e.CanSet() { unsafe.DisableRO(&e) } - patch.apply(e) + patch.apply(root, e) } } } -func (p *arrayPatch) applyChecked(root any, v reflect.Value, strict bool) error { - if err := evaluateLocalCondition(p.cond, v); err != nil { +func (p *arrayPatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } return err } for i, patch := range p.indices { @@ -482,7 +907,10 @@ func (p *arrayPatch) reverse() diffPatch { for k, v := range p.indices { newIndices[k] = v.reverse() } - return &arrayPatch{indices: newIndices, cond: p.cond} + return &arrayPatch{ + patchMetadata: p.patchMetadata, + indices: newIndices, + } } func (p *arrayPatch) format(indent int) string { @@ -496,20 +924,29 @@ func (p *arrayPatch) format(indent int) string { return b.String() } +func (p *arrayPatch) toJSONPatch(path string) []map[string]any { + var ops []map[string]any + for i, patch := range p.indices { + fullPath := fmt.Sprintf("%s/%d", path, i) + subOps := patch.toJSONPatch(fullPath) + for _, op := range subOps { + addConditionsToOp(op, p) + } + ops = append(ops, subOps...) + } + return ops +} + // mapPatch handles additions, removals, and modifications in a map. type mapPatch struct { + patchMetadata added map[interface{}]reflect.Value removed map[interface{}]reflect.Value modified map[interface{}]diffPatch keyType reflect.Type - cond any } -func (p *mapPatch) setCondition(cond any) { - p.cond = cond -} - -func (p *mapPatch) apply(v reflect.Value) { +func (p *mapPatch) apply(root, v reflect.Value) { if v.IsNil() { if len(p.added) > 0 { newMap := reflect.MakeMap(v.Type()) @@ -527,7 +964,7 @@ func (p *mapPatch) apply(v reflect.Value) { if elem.IsValid() { newElem := reflect.New(elem.Type()).Elem() newElem.Set(elem) - patch.apply(newElem) + patch.apply(root, newElem) v.SetMapIndex(keyVal, newElem) } } @@ -537,8 +974,11 @@ func (p *mapPatch) apply(v reflect.Value) { } } -func (p *mapPatch) applyChecked(root any, v reflect.Value, strict bool) error { - if err := evaluateLocalCondition(p.cond, v); err != nil { +func (p *mapPatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } return err } if v.IsNil() { @@ -592,11 +1032,11 @@ func (p *mapPatch) reverse() diffPatch { newModified[k] = v.reverse() } return &mapPatch{ - added: p.removed, - removed: p.added, - modified: newModified, - keyType: p.keyType, - cond: p.cond, + patchMetadata: p.patchMetadata, + added: p.removed, + removed: p.added, + modified: newModified, + keyType: p.keyType, } } @@ -617,6 +1057,31 @@ func (p *mapPatch) format(indent int) string { return b.String() } +func (p *mapPatch) toJSONPatch(path string) []map[string]any { + var ops []map[string]any + for k := range p.removed { + fullPath := fmt.Sprintf("%s/%v", path, k) + op := map[string]any{"op": "remove", "path": fullPath} + addConditionsToOp(op, p) + ops = append(ops, op) + } + for k, patch := range p.modified { + fullPath := fmt.Sprintf("%s/%v", path, k) + subOps := patch.toJSONPatch(fullPath) + for _, op := range subOps { + addConditionsToOp(op, p) + } + ops = append(ops, subOps...) + } + for k, val := range p.added { + fullPath := fmt.Sprintf("%s/%v", path, k) + op := map[string]any{"op": "add", "path": fullPath, "value": valueToInterface(val)} + addConditionsToOp(op, p) + ops = append(ops, op) + } + return ops +} + type opKind int const ( @@ -634,15 +1099,11 @@ type sliceOp struct { // slicePatch handles complex edits (insertions, deletions, modifications) in a slice. type slicePatch struct { - ops []sliceOp - cond any -} - -func (p *slicePatch) setCondition(cond any) { - p.cond = cond + patchMetadata + ops []sliceOp } -func (p *slicePatch) apply(v reflect.Value) { +func (p *slicePatch) apply(root, v reflect.Value) { newSlice := reflect.MakeSlice(v.Type(), 0, v.Len()) curIdx := 0 for _, op := range p.ops { @@ -661,9 +1122,10 @@ func (p *slicePatch) apply(v reflect.Value) { curIdx++ case opMod: if curIdx < v.Len() { - elem := deepCopyValue(v.Index(curIdx)) + elem := reflect.New(v.Type().Elem()).Elem() + elem.Set(deepCopyValue(v.Index(curIdx))) if op.Patch != nil { - op.Patch.apply(elem) + op.Patch.apply(root, elem) } newSlice = reflect.Append(newSlice, elem) curIdx++ @@ -676,8 +1138,11 @@ func (p *slicePatch) apply(v reflect.Value) { v.Set(newSlice) } -func (p *slicePatch) applyChecked(root any, v reflect.Value, strict bool) error { - if err := evaluateLocalCondition(p.cond, v); err != nil { +func (p *slicePatch) applyChecked(root, v reflect.Value, strict bool) error { + if err := checkConditions(p, root, v); err != nil { + if err == ErrConditionSkipped { + return nil + } return err } newSlice := reflect.MakeSlice(v.Type(), 0, v.Len()) @@ -710,7 +1175,8 @@ func (p *slicePatch) applyChecked(root any, v reflect.Value, strict bool) error if curIdx >= v.Len() { return fmt.Errorf("slice modification index %d out of bounds", curIdx) } - elem := deepCopyValue(v.Index(curIdx)) + elem := reflect.New(v.Type().Elem()).Elem() + elem.Set(deepCopyValue(v.Index(curIdx))) if err := op.Patch.applyChecked(root, elem, strict); err != nil { return fmt.Errorf("slice index %d: %w", curIdx, err) } @@ -758,7 +1224,10 @@ func (p *slicePatch) reverse() diffPatch { curB++ } } - return &slicePatch{ops: revOps, cond: p.cond} + return &slicePatch{ + patchMetadata: p.patchMetadata, + ops: revOps, + } } func (p *slicePatch) format(indent int) string { @@ -779,12 +1248,46 @@ func (p *slicePatch) format(indent int) string { return b.String() } +func (p *slicePatch) toJSONPatch(path string) []map[string]any { + var ops []map[string]any + // JSON Patch array indices shift as we add/remove elements. + // However, if we process them in descending order of index for removals + // and ascending for additions, it might be complicated. + // Actually, the simplest is to just emit them and hope for the best, + // OR calculate the shifted index. + + shift := 0 + for _, op := range p.ops { + fullPath := fmt.Sprintf("%s/%d", path, op.Index+shift) + switch op.Kind { + case opAdd: + jsonOp := map[string]any{"op": "add", "path": fullPath, "value": valueToInterface(op.Val)} + addConditionsToOp(jsonOp, p) + ops = append(ops, jsonOp) + shift++ + case opDel: + jsonOp := map[string]any{"op": "remove", "path": fullPath} + addConditionsToOp(jsonOp, p) + ops = append(ops, jsonOp) + shift-- + case opMod: + subOps := op.Patch.toJSONPatch(fullPath) + for _, sop := range subOps { + addConditionsToOp(sop, p) + } + ops = append(ops, subOps...) + } + } + return ops +} + type patchSurrogate struct { Kind string `json:"k" gob:"k"` Data any `json:"d,omitempty" gob:"d,omitempty"` } -func makeSurrogate(kind string, data map[string]any, cond any) (*patchSurrogate, error) { +func makeSurrogate(kind string, data map[string]any, p diffPatch) (*patchSurrogate, error) { + cond, ifCond, unlessCond := p.conditions() c, err := marshalConditionAny(cond) if err != nil { return nil, err @@ -792,6 +1295,20 @@ func makeSurrogate(kind string, data map[string]any, cond any) (*patchSurrogate, if c != nil { data["c"] = c } + ic, err := marshalConditionAny(ifCond) + if err != nil { + return nil, err + } + if ic != nil { + data["if"] = ic + } + uc, err := marshalConditionAny(unlessCond) + if err != nil { + return nil, err + } + if uc != nil { + data["un"] = uc + } return &patchSurrogate{Kind: kind, Data: data}, nil } @@ -804,7 +1321,7 @@ func marshalDiffPatch(p diffPatch) (any, error) { return makeSurrogate("value", map[string]any{ "o": valueToInterface(v.oldVal), "n": valueToInterface(v.newVal), - }, v.cond) + }, v) case *ptrPatch: elem, err := marshalDiffPatch(v.elemPatch) if err != nil { @@ -812,7 +1329,7 @@ func marshalDiffPatch(p diffPatch) (any, error) { } return makeSurrogate("ptr", map[string]any{ "p": elem, - }, v.cond) + }, v) case *interfacePatch: elem, err := marshalDiffPatch(v.elemPatch) if err != nil { @@ -820,7 +1337,7 @@ func marshalDiffPatch(p diffPatch) (any, error) { } return makeSurrogate("interface", map[string]any{ "p": elem, - }, v.cond) + }, v) case *structPatch: fields := make(map[string]any) for name, patch := range v.fields { @@ -832,7 +1349,7 @@ func marshalDiffPatch(p diffPatch) (any, error) { } return makeSurrogate("struct", map[string]any{ "f": fields, - }, v.cond) + }, v) case *arrayPatch: indices := make(map[string]any) for idx, patch := range v.indices { @@ -844,7 +1361,7 @@ func marshalDiffPatch(p diffPatch) (any, error) { } return makeSurrogate("array", map[string]any{ "i": indices, - }, v.cond) + }, v) case *mapPatch: added := make([]map[string]any, 0, len(v.added)) for k, val := range v.added { @@ -866,7 +1383,7 @@ func marshalDiffPatch(p diffPatch) (any, error) { "a": added, "r": removed, "m": modified, - }, v.cond) + }, v) case *slicePatch: ops := make([]map[string]any, 0, len(v.ops)) for _, op := range v.ops { @@ -883,7 +1400,23 @@ func marshalDiffPatch(p diffPatch) (any, error) { } return makeSurrogate("slice", map[string]any{ "o": ops, - }, v.cond) + }, v) + case *testPatch: + return makeSurrogate("test", map[string]any{ + "e": valueToInterface(v.expected), + }, v) + case *copyPatch: + return makeSurrogate("copy", map[string]any{ + "f": v.from, + }, v) + case *movePatch: + return makeSurrogate("move", map[string]any{ + "f": v.from, + }, v) + case *logPatch: + return makeSurrogate("log", map[string]any{ + "m": v.message, + }, v) } return nil, fmt.Errorf("unknown patch type: %T", p) } @@ -896,8 +1429,8 @@ func unmarshalDiffPatch(data []byte) (diffPatch, error) { return convertFromSurrogate(&s) } -func unmarshalCondFromMap(d map[string]any) any { - if cData, ok := d["c"]; ok && cData != nil { +func unmarshalCondFromMap(d map[string]any, key string) any { + if cData, ok := d[key]; ok && cData != nil { jsonData, _ := json.Marshal(cData) c, _ := unmarshalCondition[any](jsonData) return c @@ -930,7 +1463,11 @@ func convertFromSurrogate(s any) (diffPatch, error) { return &valuePatch{ oldVal: interfaceToValue(d["o"]), newVal: interfaceToValue(d["n"]), - cond: unmarshalCondFromMap(d), + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, }, nil case "ptr": d := data.(map[string]any) @@ -938,14 +1475,28 @@ func convertFromSurrogate(s any) (diffPatch, error) { if err != nil { return nil, err } - return &ptrPatch{elemPatch: elem, cond: unmarshalCondFromMap(d)}, nil + return &ptrPatch{ + elemPatch: elem, + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, + }, nil case "interface": d := data.(map[string]any) elem, err := convertFromSurrogate(d["p"]) if err != nil { return nil, err } - return &interfacePatch{elemPatch: elem, cond: unmarshalCondFromMap(d)}, nil + return &interfacePatch{ + elemPatch: elem, + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, + }, nil case "struct": d := data.(map[string]any) fieldsData := d["f"].(map[string]any) @@ -957,7 +1508,14 @@ func convertFromSurrogate(s any) (diffPatch, error) { } fields[name] = p } - return &structPatch{fields: fields, cond: unmarshalCondFromMap(d)}, nil + return &structPatch{ + fields: fields, + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, + }, nil case "array": d := data.(map[string]any) indicesData := d["i"].(map[string]any) @@ -971,7 +1529,14 @@ func convertFromSurrogate(s any) (diffPatch, error) { } indices[idx] = p } - return &arrayPatch{indices: indices, cond: unmarshalCondFromMap(d)}, nil + return &arrayPatch{ + indices: indices, + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, + }, nil case "map": d := data.(map[string]any) added := make(map[interface{}]reflect.Value) @@ -1025,7 +1590,11 @@ func convertFromSurrogate(s any) (diffPatch, error) { added: added, removed: removed, modified: modified, - cond: unmarshalCondFromMap(d), + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, }, nil case "slice": d := data.(map[string]any) @@ -1075,7 +1644,54 @@ func convertFromSurrogate(s any) (diffPatch, error) { Patch: p, }) } - return &slicePatch{ops: ops, cond: unmarshalCondFromMap(d)}, nil + return &slicePatch{ + ops: ops, + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, + }, nil + case "test": + d := data.(map[string]any) + return &testPatch{ + expected: interfaceToValue(d["e"]), + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, + }, nil + case "copy": + d := data.(map[string]any) + return ©Patch{ + from: d["f"].(string), + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, + }, nil + case "move": + d := data.(map[string]any) + return &movePatch{ + from: d["f"].(string), + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, + }, nil + case "log": + d := data.(map[string]any) + return &logPatch{ + message: d["m"].(string), + patchMetadata: patchMetadata{ + cond: unmarshalCondFromMap(d, "c"), + ifCond: unmarshalCondFromMap(d, "if"), + unlessCond: unmarshalCondFromMap(d, "un"), + }, + }, nil } return nil, fmt.Errorf("unknown patch kind: %s", kind) } diff --git a/patch_test.go b/patch_test.go index 43a58c4..e08044a 100644 --- a/patch_test.go +++ b/patch_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/gob" "encoding/json" + "fmt" "reflect" "strings" "testing" @@ -523,30 +524,621 @@ func TestApplyChecked_Conflicts(t *testing.T) { }) } -func TestPatch_Serialization_Exhaustive(t *testing.T) { +func TestPatch_ToJSONPatch(t *testing.T) { + type User struct { + Name string + Age int + Tags []string + } + u1 := User{Name: "Alice", Age: 30, Tags: []string{"A", "B"}} + u2 := User{Name: "Alice", Age: 31, Tags: []string{"A", "C", "B"}} + + patch := Diff(u1, u2) + jsonPatchBytes, err := patch.ToJSONPatch() + if err != nil { + t.Fatalf("ToJSONPatch failed: %v", err) + } + + // Verify it's valid JSON + var ops []map[string]any + if err := json.Unmarshal(jsonPatchBytes, &ops); err != nil { + t.Fatalf("Failed to unmarshal JSON Patch: %v", err) + } + + // We expect: + // 1. replace /Age with 31 + // 2. add /Tags/1 with "C" + + foundAge := false + foundTags := false + for _, op := range ops { + switch op["path"] { + case "/Age": + if op["op"] == "replace" && op["value"] == float64(31) { + foundAge = true + } + case "/Tags/1": + if op["op"] == "add" && op["value"] == "C" { + foundTags = true + } + } + } + + if !foundAge { + t.Errorf("Expected replace /Age op, got: %s", string(jsonPatchBytes)) + } + if !foundTags { + t.Errorf("Expected add /Tags/1 op, got: %s", string(jsonPatchBytes)) + } +} + +func TestPatch_ToJSONPatch_WithConditions(t *testing.T) { + type User struct { + Name string + Age int + } + builder := NewBuilder[User]() + c := Equal[User]("Name", "Alice") + nodeAge, _ := builder.Root().Field("Age") + nodeAge.If(c).Set(30, 31) + + patch, _ := builder.Build() + data, err := patch.ToJSONPatch() + if err != nil { + t.Fatalf("ToJSONPatch failed: %v", err) + } + + var ops []map[string]any + json.Unmarshal(data, &ops) + + if len(ops) != 1 { + t.Fatalf("Expected 1 op, got %d", len(ops)) + } + + op := ops[0] + if op["if"] == nil { + t.Fatal("Expected 'if' predicate in JSON Patch export") + } + + pred := op["if"].(map[string]any) + if pred["op"] != "test" || pred["path"] != "Name" || pred["value"] != "Alice" { + t.Errorf("Unexpected predicate: %+v", pred) + } +} + +func TestPatch_NumericConversion(t *testing.T) { + tests := []struct { + val any + target any + }{ + {int64(10), int(0)}, + {float64(10), int(0)}, + {float64(10), int8(0)}, + {float64(10), int16(0)}, + {float64(10), int32(0)}, + {float64(10), int64(0)}, + {float64(10), uint(0)}, + {float64(10), uint8(0)}, + {float64(10), uint16(0)}, + {float64(10), uint32(0)}, + {float64(10), uint64(0)}, + {float64(10), uintptr(0)}, + {float64(10), float32(0)}, + {float64(10), float64(0)}, + {10, float64(0)}, // int to float + {"s", "s"}, + {nil, int(0)}, + } + for _, tt := range tests { + var v reflect.Value + if tt.val == nil { + v = reflect.Value{} + } else { + v = reflect.ValueOf(tt.val) + } + targetType := reflect.TypeOf(tt.target) + got := convertValue(v, targetType) + if got.Type() != targetType && v.IsValid() && !v.Type().AssignableTo(targetType) { + t.Errorf("Expected %v, got %v for %v", targetType, got.Type(), tt.val) + } + } +} + +func TestPatch_SerializationExhaustive(t *testing.T) { type Data struct { - V int + C []int } Register[Data]() - // Test with complex condition - cond, _ := ParseCondition[Data]("V > 10 OR NOT (V == 0)") - p := Diff(Data{V: 1}, Data{V: 2}).WithCondition(cond) + builder := NewBuilder[Data]() + root := builder.Root() + nodeC, _ := root.Field("C") + nodeCI, _ := nodeC.Index(0) + nodeCI.Set(1, 10) + + patch, _ := builder.Build() + + // Gob + var buf strings.Builder + enc := gob.NewEncoder(&buf) + enc.Encode(patch) + + dec := gob.NewDecoder(strings.NewReader(buf.String())) + var patch2 typedPatch[Data] + dec.Decode(&patch2) // JSON - jsonData, _ := json.Marshal(p) - p2 := NewPatch[Data]() - json.Unmarshal(jsonData, p2) - if p2.String() == "" { - t.Error("JSON restoration failed") + data, _ := json.Marshal(patch) + var patch3 typedPatch[Data] + json.Unmarshal(data, &patch3) +} + +type dummyPatch struct{ patchMetadata } + +func (p *dummyPatch) apply(root, v reflect.Value) {} +func (p *dummyPatch) applyChecked(root, v reflect.Value, strict bool) error { return nil } +func (p *dummyPatch) reverse() diffPatch { return p } +func (p *dummyPatch) format(indent int) string { return "" } +func (p *dummyPatch) toJSONPatch(path string) []map[string]any { return nil } + +func TestPatch_MarshalUnknown(t *testing.T) { + _, err := marshalDiffPatch(&dummyPatch{}) + if err == nil { + t.Error("Expected error for unknown patch type") } +} - // Gob - var buf bytes.Buffer - gob.NewEncoder(&buf).Encode(&p) - p3 := NewPatch[Data]() - gob.NewDecoder(&buf).Decode(&p3) - if p3.String() == "" { - t.Error("Gob restoration failed") +func TestPatch_ReverseFormat_Exhaustive(t *testing.T) { + // valuePatch + t.Run("valuePatch", func(t *testing.T) { + p := &valuePatch{oldVal: reflect.ValueOf(1), newVal: reflect.ValueOf(2)} + p.reverse() + p.format(0) + p.toJSONPatch("/p") + p.toJSONPatch("") // root + + pRem := &valuePatch{oldVal: reflect.ValueOf(1)} + pRem.toJSONPatch("/p") + }) + // ptrPatch + t.Run("ptrPatch", func(t *testing.T) { + p := &ptrPatch{elemPatch: &valuePatch{}} + p.reverse() + p.format(0) + p.toJSONPatch("/p") + }) + // interfacePatch + t.Run("interfacePatch", func(t *testing.T) { + p := &interfacePatch{elemPatch: &valuePatch{}} + p.reverse() + p.format(0) + p.toJSONPatch("/p") + }) + // structPatch + t.Run("structPatch", func(t *testing.T) { + p := &structPatch{fields: map[string]diffPatch{"A": &valuePatch{}}} + p.reverse() + p.format(0) + p.toJSONPatch("/p") + }) + // arrayPatch + t.Run("arrayPatch", func(t *testing.T) { + p := &arrayPatch{indices: map[int]diffPatch{0: &valuePatch{}}} + p.reverse() + p.format(0) + p.toJSONPatch("/p") + }) + // mapPatch + t.Run("mapPatch", func(t *testing.T) { + p := &mapPatch{ + added: map[interface{}]reflect.Value{"a": reflect.ValueOf(1)}, + removed: map[interface{}]reflect.Value{"b": reflect.ValueOf(2)}, + modified: map[interface{}]diffPatch{"c": &valuePatch{}}, + } + p.reverse() + p.format(0) + p.toJSONPatch("/p") + }) + // slicePatch + t.Run("slicePatch", func(t *testing.T) { + p := &slicePatch{ + ops: []sliceOp{ + {Kind: opAdd, Index: 0, Val: reflect.ValueOf(1)}, + {Kind: opDel, Index: 1, Val: reflect.ValueOf(2)}, + {Kind: opMod, Index: 2, Patch: &valuePatch{}}, + }, + } + p.reverse() + p.format(0) + p.toJSONPatch("/p") + }) + // testPatch + t.Run("testPatch", func(t *testing.T) { + p := &testPatch{expected: reflect.ValueOf(1)} + p.reverse() + p.format(0) + p.toJSONPatch("/p") + }) + // copyPatch + t.Run("copyPatch", func(t *testing.T) { + p := ©Patch{from: "/a", path: "/b"} + p.reverse() + p.format(0) + p.toJSONPatch("/p") + }) + // movePatch + t.Run("movePatch", func(t *testing.T) { + p := &movePatch{from: "/a", path: "/b"} + p.reverse() + p.format(0) + p.toJSONPatch("/p") + }) + // logPatch + t.Run("logPatch", func(t *testing.T) { + p := &logPatch{message: "test"} + p.reverse() + p.format(0) + p.toJSONPatch("/p") + }) +} + +func TestPatch_ApplySimple(t *testing.T) { + val := 1 + patch := Diff(1, 2) + patch.Apply(&val) + if val != 2 { + t.Errorf("Apply failed: expected 2, got %d", val) + } +} + +func TestPatch_ConditionsExhaustive(t *testing.T) { + type InnerC struct{ V int } + type DataC struct { + A int + P *InnerC + I any + M map[string]InnerC + S []InnerC + Arr [1]InnerC + } + builder := NewBuilder[DataC]() + root := builder.Root() + + c := Equal[DataC]("A", 1) + + root.If(c).Unless(c).Test(DataC{A: 1}) + + nodeP, _ := root.Field("P") + nodeP.If(c).Unless(c) + + nodeI, _ := root.Field("I") + nodeI.If(c).Unless(c) + + nodeM, _ := root.Field("M") + nodeM.If(c).Unless(c) + + nodeS, _ := root.Field("S") + nodeS.If(c).Unless(c) + + nodeArr, _ := root.Field("Arr") + nodeArr.If(c).Unless(c) + + patch, _ := builder.Build() + if patch == nil { + t.Fatal("Build failed") + } +} + +func TestPatch_MoreApplyChecked(t *testing.T) { + // ptrPatch + t.Run("ptrPatch", func(t *testing.T) { + val1 := 1 + p1 := &val1 + val2 := 2 + p2 := &val2 + patch := Diff(p1, p2) + if err := patch.ApplyChecked(&p1); err != nil { + t.Errorf("ptrPatch ApplyChecked failed: %v", err) + } + }) + // interfacePatch + t.Run("interfacePatch", func(t *testing.T) { + var i1 any = 1 + var i2 any = 2 + patch := Diff(i1, i2) + if err := patch.ApplyChecked(&i1); err != nil { + t.Errorf("interfacePatch ApplyChecked failed: %v", err) + } + }) + // structPatch + t.Run("structPatch", func(t *testing.T) { + type SLocal struct{ A int } + s1 := SLocal{1} + s2 := SLocal{2} + patch := Diff(s1, s2) + if err := patch.ApplyChecked(&s1); err != nil { + t.Errorf("structPatch ApplyChecked failed: %v", err) + } + }) + // arrayPatch + t.Run("arrayPatch", func(t *testing.T) { + a1 := [1]int{1} + a2 := [1]int{2} + patch := Diff(a1, a2) + if err := patch.ApplyChecked(&a1); err != nil { + t.Errorf("arrayPatch ApplyChecked failed: %v", err) + } + }) + // mapPatch + t.Run("mapPatch", func(t *testing.T) { + m1 := map[string]int{"a": 1} + m2 := map[string]int{"a": 2} + patch := Diff(m1, m2) + if err := patch.ApplyChecked(&m1); err != nil { + t.Errorf("mapPatch ApplyChecked failed: %v", err) + } + }) + // testPatch + t.Run("testPatch", func(t *testing.T) { + val := 1 + builder := NewBuilder[int]() + builder.Root().Test(1) + patch, _ := builder.Build() + if err := patch.ApplyChecked(&val); err != nil { + t.Errorf("testPatch ApplyChecked failed: %v", err) + } + }) +} + +func TestPatch_Apply_DocumentWide(t *testing.T) { + type Data struct { + A int + B int + } + d := Data{A: 1, B: 0} + rv := reflect.ValueOf(&d) + + // testPatch apply + tp := &testPatch{expected: reflect.ValueOf(1)} + tp.apply(rv, rv.Elem().Field(0)) + + // copyPatch apply + cp := ©Patch{from: "/A"} + cp.apply(rv, rv.Elem().Field(1)) + if d.B != 1 { + t.Errorf("copyPatch apply failed: expected B=1, got %d", d.B) + } + + // movePatch apply + d.B = 0 + mp := &movePatch{from: "/A", path: "/B"} + mp.apply(rv, rv.Elem().Field(1)) + if d.B != 1 || d.A != 0 { + t.Errorf("movePatch apply failed: %+v", d) + } +} + +func TestPatch_ConditionToPredicate_Exhaustive(t *testing.T) { + type Data struct{ V int } + + ops := []string{"==", "!=", "<", ">", "<=", ">="} + for _, op := range ops { + expr := fmt.Sprintf("V %s 10", op) + cond, _ := ParseCondition[Data](expr) + + pred := conditionToPredicate(cond) + if pred == nil { + t.Errorf("conditionToPredicate returned nil for %s", op) + } + } + + // Complex conditions + c1, _ := ParseCondition[Data]("V > 0 AND V < 10") + conditionToPredicate(c1) + + c2, _ := ParseCondition[Data]("V == 0 OR V == 10") + conditionToPredicate(c2) + + c3, _ := ParseCondition[Data]("NOT (V == 0)") + conditionToPredicate(c3) +} + +func TestPatch_Serialization_Conditions(t *testing.T) { + type Data struct{ A int } + builder := NewBuilder[Data]() + c := Equal[Data]("A", 1) + builder.Root().If(c).Unless(c).Test(Data{A: 1}) + patch, _ := builder.Build() + + // Coverage for marshalDiffPatch branches + data, _ := json.Marshal(patch) + var patch2 typedPatch[Data] + json.Unmarshal(data, &patch2) +} + +func TestPatch_MiscCoverage(t *testing.T) { + // valuePatch reverse/format/toJSONPatch + t.Run("valuePatch", func(t *testing.T) { + p := &valuePatch{oldVal: reflect.ValueOf(1), newVal: reflect.ValueOf(2)} + p.reverse() + p.format(0) + p.toJSONPatch("/path") + p.toJSONPatch("") // root + + pRem := &valuePatch{oldVal: reflect.ValueOf(1)} + pRem.toJSONPatch("/path") + }) + + // ptrPatch reverse/format/toJSONPatch + t.Run("ptrPatch", func(t *testing.T) { + p := &ptrPatch{elemPatch: &valuePatch{oldVal: reflect.ValueOf(1), newVal: reflect.ValueOf(2)}} + p.reverse() + p.format(0) + p.toJSONPatch("/path") + }) + + // interfacePatch reverse/format/toJSONPatch + t.Run("interfacePatch", func(t *testing.T) { + p := &interfacePatch{elemPatch: &valuePatch{oldVal: reflect.ValueOf(1), newVal: reflect.ValueOf(2)}} + p.reverse() + p.format(0) + p.toJSONPatch("/path") + }) + + // structPatch format/toJSONPatch + t.Run("structPatch", func(t *testing.T) { + p := &structPatch{fields: map[string]diffPatch{"A": &valuePatch{newVal: reflect.ValueOf(1)}}} + p.format(0) + p.toJSONPatch("/path") + }) + + // arrayPatch format/toJSONPatch + t.Run("arrayPatch", func(t *testing.T) { + p := &arrayPatch{indices: map[int]diffPatch{0: &valuePatch{newVal: reflect.ValueOf(1)}}} + p.format(0) + p.toJSONPatch("/path") + }) + + // mapPatch format/toJSONPatch + t.Run("mapPatch", func(t *testing.T) { + p := &mapPatch{ + added: map[interface{}]reflect.Value{"a": reflect.ValueOf(1)}, + removed: map[interface{}]reflect.Value{"b": reflect.ValueOf(2)}, + modified: map[interface{}]diffPatch{"c": &valuePatch{newVal: reflect.ValueOf(3)}}, + } + p.format(0) + p.toJSONPatch("/path") + }) + + // slicePatch format + t.Run("slicePatch", func(t *testing.T) { + p := &slicePatch{ + ops: []sliceOp{ + {Kind: opAdd, Index: 0, Val: reflect.ValueOf(1)}, + {Kind: opDel, Index: 1, Val: reflect.ValueOf(2)}, + {Kind: opMod, Index: 2, Patch: &valuePatch{newVal: reflect.ValueOf(3)}}, + }, + } + p.format(0) + }) +} + +func TestPatch_EmptyToJSONPatch(t *testing.T) { + p := NewPatch[int]() + data, err := p.ToJSONPatch() + if err != nil { + t.Fatalf("ToJSONPatch failed: %v", err) + } + if string(data) != "[]" { + t.Errorf("Expected empty JSON array, got %s", string(data)) + } +} + +func TestPatch_ApplyCheckedRecursive(t *testing.T) { + type InnerR struct{ V int } + type DataR struct { + P *InnerR + I any + M map[string]InnerR + S []InnerR + A [1]InnerR + } + + d1 := DataR{ + P: &InnerR{1}, + I: InnerR{2}, + M: map[string]InnerR{"a": {3}}, + S: []InnerR{{4}}, + A: [1]InnerR{{5}}, + } + d2 := DataR{ + P: &InnerR{10}, + I: InnerR{20}, + M: map[string]InnerR{"a": {30}}, + S: []InnerR{{40}}, + A: [1]InnerR{{50}}, + } + + patch := Diff(d1, d2) + if err := patch.ApplyChecked(&d1); err != nil { + t.Fatalf("ApplyChecked failed: %v", err) + } + if !reflect.DeepEqual(d1, d2) { + t.Errorf("ApplyChecked mismatch: %+v", d1) + } +} + +func TestPatch_Serialization_Errors(t *testing.T) { + // unmarshalDiffPatch error + unmarshalDiffPatch([]byte("INVALID")) + + // unmarshalCondFromMap missing key + unmarshalCondFromMap(map[string]any{}, "c") + + // convertFromSurrogate unknown kind + convertFromSurrogate(map[string]any{"k": "unknown", "d": map[string]any{}}) + + // convertFromSurrogate invalid surrogate type + convertFromSurrogate(123) +} + +func TestPatch_ToJSONPatch_Complex(t *testing.T) { + type Inner struct{ V int } + type Data struct { + P *Inner + I any + A [1]Inner + M map[string]Inner + } + + builder := NewBuilder[Data]() + root := builder.Root() + + nodeP, _ := root.Field("P") + nodePV, _ := nodeP.Elem().Field("V") + nodePV.Set(1, 2) + + nodeI, _ := root.Field("I") + nodeI.Elem().Set(1, 2) + + nodeA, _ := root.Field("A") + nodeAI, _ := nodeA.Index(0) + nodeAIV, _ := nodeAI.Field("V") + nodeAIV.Set(1, 2) + + nodeM, _ := root.Field("M") + nodeMK, _ := nodeM.MapKey("k") + nodeMKV, _ := nodeMK.Field("V") + nodeMKV.Set(1, 2) + + patch, _ := builder.Build() + patch.ToJSONPatch() +} + +func TestPatch_LogExhaustive(t *testing.T) { + lp := &logPatch{message: "test"} + + // apply + lp.apply(reflect.Value{}, reflect.ValueOf(1)) + + // applyChecked + if err := lp.applyChecked(reflect.ValueOf(1), reflect.ValueOf(1), false); err != nil { + t.Errorf("logPatch applyChecked failed: %v", err) + } + + // reverse + if lp.reverse() != lp { + t.Error("logPatch reverse should return itself") + } + + // format + if lp.format(0) == "" { + t.Error("logPatch format returned empty string") + } + + // toJSONPatch + ops := lp.toJSONPatch("/path") + if len(ops) != 1 || ops[0]["op"] != "log" { + t.Errorf("Unexpected toJSONPatch output: %+v", ops) } }