Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
163 changes: 121 additions & 42 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"reflect"
"strconv"
"strings"
)

// Builder allows constructing a Patch[T] manually with on-the-fly type validation.
Expand Down Expand Up @@ -44,7 +43,8 @@ func (b *Builder[T]) Root() *Node {
update: func(p diffPatch) {
b.patch = p
},
current: b.patch,
current: b.patch,
fullPath: "",
}
}

Expand All @@ -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))
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 := &copyPatch{
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
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}

Expand All @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
}

Expand Down
Loading