diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7838eb1..58dfdb4 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,6 +1,3 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - name: Go on: @@ -10,19 +7,22 @@ on: branches: [ "main" ] jobs: - build: runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 'stable' - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.22.1' + - name: Build + run: go build -v ./... - - name: Build - run: go build -v ./... + - name: Vet + run: go vet ./... - - name: Test - run: go test -v ./... + - name: Test + run: go test -race -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f96b552 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Editor swap files +*.swp +*.swo + +# macOS +.DS_Store + +# Test binaries and coverage +*.test +coverage.out + +# Compiled generator binary +/deep-gen diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d72c87e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,88 @@ +# Changelog + +## v5.0.0 (in development) + +Major rewrite. Breaking changes from v4. + +### Architecture + +- **Flat operation model**: `Patch[T]` is now a plain `[]Operation` rather than a recursive tree. Operations have `Kind`, `Path` (JSON Pointer), `Old`, `New`, `If`, and `Unless` fields. +- **Code generation**: `cmd/deep-gen` produces `*_deep.go` files with reflection-free `Patch`, `Diff`, `Equal`, and `Clone` methods — typically 10–15x faster than the reflection fallback. +- **Reflection fallback**: Types without generated code fall through to the v4-based internal engine automatically. + +### New API (`github.com/brunoga/deep/v5`) + +| Function | Description | +|---|---| +| `Diff[T](a, b T) (Patch[T], error)` | Compare two values; returns error for unsupported types | +| `Apply[T](*T, Patch[T], ...ApplyOption) error` | Apply a patch; returns `*ApplyError` with `Unwrap() []error` | +| `Equal[T](a, b T) bool` | Deep equality | +| `Clone[T](v T) T` | Deep copy (formerly `Copy`) | +| `Set[T,V](Path[T,V], V) Op` | Typed replace operation constructor | +| `Add[T,V](Path[T,V], V) Op` | Typed add operation constructor | +| `Remove[T,V](Path[T,V]) Op` | Typed remove operation constructor | +| `Move[T,V](from, to Path[T,V]) Op` | Typed move operation constructor | +| `Copy[T,V](from, to Path[T,V]) Op` | Typed copy operation constructor | +| `Edit[T](*T) *Builder[T]` | Returns a fluent patch builder | +| `Merge[T](base, other, resolver)` | Deduplicate ops by path; resolver called on conflicts, otherwise other wins | +| `Field[T,V](selector)` | Type-safe path from a selector function | +| `At[T,S,E](Path[T,S], int) Path[T,E]` | Extend a slice-field path to an element by index | +| `MapKey[T,M,K,V](Path[T,M], K) Path[T,V]` | Extend a map-field path to a value by key | +| `WithLogger(*slog.Logger) ApplyOption` | Pass a logger to a single Apply call | +| `ParseJSONPatch[T]([]byte) (Patch[T], error)` | Parse RFC 6902 + deep extensions back into a Patch | +| `ConflictResolver` (interface) | Implement `Resolve(path string, local, remote any) any` to customize `Merge` | + +**`Patch[T]` methods:** + +| Method | Description | +|---|---| +| `Patch.IsEmpty() bool` | Reports whether the patch has no operations | +| `Patch.AsStrict() Patch[T]` | Returns a copy with strict Old-value verification enabled | +| `Patch.WithGuard(*Condition) Patch[T]` | Returns a copy with a global guard condition set | +| `Patch.Reverse() Patch[T]` | Returns the inverse patch (undo) | +| `Patch.ToJSONPatch() ([]byte, error)` | Serialize to RFC 6902 JSON Patch with deep extensions | +| `Patch.String() string` | Human-readable summary of operations | + +### `condition` package (`github.com/brunoga/deep/v5/condition`) + +Public package used directly by generated `*_deep.go` files. Most callers will not need to import it directly. + +- `Condition` — Serializable predicate struct (`Op`, `Path`, `Value`, `Sub`). +- `Evaluate(root reflect.Value, c *Condition) (bool, error)` — Evaluate a condition against a value. +- `CheckType(v any, typeName string) bool` — Runtime type name check (used in generated code). +- `ToPredicate() / FromPredicate()` — Convert `Condition` to/from the JSON Patch wire-format map. +- `Eq`, `Ne`, `Gt`, `Ge`, `Lt`, `Le`, `Exists`, `In`, `Matches`, `Type`, `And`, `Or`, `Not` — Condition operator constants. + +### Condition / Guard system + +- `Condition` struct with `Op`, `Path`, `Value`, `Sub` fields (serializable predicates). +- Patch-level guard set via `Patch.Guard` field or `patch.WithGuard(c)`. +- Per-operation conditions via `Operation.If` / `Operation.Unless`. +- Builder helpers: `Eq`, `Ne`, `Gt`, `Ge`, `Lt`, `Le`, `Exists`, `In`, `Matches`, `Type`, `And`, `Or`, `Not`. +- Per-op conditions attached to `Op` values via `Op.If` / `Op.Unless`; passed to the builder via `Builder.With`. + +### CRDTs (`github.com/brunoga/deep/v5/crdt`) + +- `CRDT[T]` — Concurrency-safe CRDT wrapper. Create with `NewCRDT(initial, nodeID)`. Key methods: `Edit(fn)`, `ApplyDelta(delta)`, `Merge(other)`, `View()`. JSON-serializable. +- `Delta[T]` — A timestamped set of changes produced by `CRDT.Edit`; send to peers and apply with `CRDT.ApplyDelta`. +- `LWW[T]` — Embeddable Last-Write-Wins register. Update with `Set(v, ts)`; accepts write only if `ts` is strictly newer. +- `Text` (`[]TextRun`) — Convergent collaborative text. Merge concurrent edits with `MergeTextRuns(a, b)`. + +**`github.com/brunoga/deep/v5/crdt/hlc`** + +- `Clock` — Per-node HLC state. Create with `NewClock(nodeID)`. Methods: `Now()`, `Update(remote)`, `Reserve(n)`. +- `HLC` — Timestamp struct (wall time + logical counter + node ID). Total ordering via `Compare` / `After`. + +### Breaking changes from v4 + +- Import path: `github.com/brunoga/deep/v4` → `github.com/brunoga/deep/v5` +- `Diff` now returns `(Patch[T], error)` instead of `Patch[T]`. +- `Patch` is now generic (`Patch[T]`); patches are not cross-type compatible. +- `Patch.Condition` renamed to `Patch.Guard`; `WithCondition` → `WithGuard`. +- Global `Logger`/`SetLogger` removed; pass `WithLogger(l)` as an `Apply` option for per-call logging. +- `cond/` package removed; conditions live in `github.com/brunoga/deep/v5/condition`. +- `deep-gen` now writes output to `{type}_deep.go` by default instead of stdout. +- `OpAdd` on slices sets by index rather than inserting; true insertion is not supported for unkeyed slices. +- `Copy[T](v T) T` renamed to `Clone[T](v T) T`; `Copy` is now the patch-op constructor `Copy[T,V](from, to Path[T,V]) Op`. +- `Builder.Set/Add/Remove/Move/Copy` methods removed; use `Builder.With(deep.Set(...), ...)` instead. +- `Builder.If/Unless` methods removed; attach per-op conditions on the `Op` value before passing to `With`. diff --git a/README.md b/README.md index 46c2413..803c697 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,182 @@ -# Deep: High-Performance Data Manipulation for Go +# Deep v5: The High-Performance Type-Safe Synchronization Toolkit -`deep` is a high-performance, reflection-based engine for manipulating complex Go data structures. It provides recursive deep copying, semantic equality checks, and structural diffing to produce optimized patches. +`deep` is a comprehensive Go library for comparing, cloning, and synchronizing complex data structures. Deep introduces a revolutionary architecture centered on **Code Generation** and **Type-Safe Selectors**, delivering up to **15x** performance improvements over traditional reflection-based libraries. -V4 focuses on API ergonomics with a fluent patch builder and advanced conflict resolution for distributed systems. +## Key Features -## Installation +- **Extreme Performance**: Reflection-free operations via `deep-gen` (10x-20x faster than v4). +- **Compile-Time Safety**: Type-safe field selectors replace brittle string paths. +- **Data-Oriented**: Patches are pure, flat data structures, natively serializable to JSON. +- **Integrated Causality**: Native support for HLC (Hybrid Logical Clocks) and LWW (Last-Write-Wins). +- **First-Class CRDTs**: Built-in support for `Text` and `LWW[T]` convergent registers. +- **Standard Compliant**: Export to RFC 6902 JSON Patch with advanced predicate extensions. +- **Hybrid Architecture**: Optimized generated paths with a robust reflection safety net. -```bash -go get github.com/brunoga/deep/v4 -``` +## Performance Comparison (Deep Generated vs v4 Reflection) + +Benchmarks performed on typical struct models (`User` with IDs, Names, Slices): ---- +| Operation | v4 (Reflection) | Deep (Generated) | Speedup | +| :--- | :--- | :--- | :--- | +| **Apply Patch** | 726 ns/op | **50 ns/op** | **14.5x** | +| **Diff + Apply** | 2,391 ns/op | **270 ns/op** | **8.8x** | +| **Clone** | 1,872 ns/op | **290 ns/op** | **6.4x** | +| **Equality** | 202 ns/op | **84 ns/op** | **2.4x** | -## Core Features +Run `go test -bench=. ./...` to reproduce. `BenchmarkApplyGenerated` uses generated code; +`BenchmarkApplyReflection` uses the fallback path on a type with no generated code. -### 1. Deep Copy -**Justification:** Standard assignment in Go performing shallow copies. `deep.Copy` creates a completely decoupled clone, correctly handling pointers, slices, maps, and private fields (via unsafe). +## Quick Start + +### 1. Define your models ```go -dst, err := deep.Copy(src) +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Roles []string `json:"roles"` + Score map[string]int `json:"score"` +} ``` -* **Recursive**: Clones the entire object graph. -* **Cycle Detection**: Safely handles self-referencing structures. -* **Unexported Fields**: Optionally clones private struct fields. -* **Example**: [Config Management](./examples/config_manager/main.go) -### 2. Semantic Equality (`Equal[T]`) -**Justification:** `reflect.DeepEqual` is slow and lacks control. `deep.Equal` is a tag-aware, cache-optimized replacement that is up to 30% faster and respects library-specific struct tags. +### 2. Generate optimized code + +Add a `go:generate` directive to your source file: ```go -if deep.Equal(objA, objB) { - // Logically equal, respecting deep:"-" tags -} +//go:generate go run github.com/brunoga/deep/v5/cmd/deep-gen -type=User . ``` -* **Tag Awareness**: Skips fields marked with `deep:"-"`. -* **Short-Circuiting**: Immediately returns true for identical pointer addresses. -* **Performance**: Uses a global reflection cache to minimize lookup overhead. -### 3. Structural Diff & Patch -**Justification:** Efficiently synchronizing state between nodes or auditing changes requires knowing *what* changed, not just that *something* changed. `deep.Diff` produces a semantic `Patch` representing the minimum set of operations to transform one value into another. +Then run: + +```bash +go generate ./... +``` + +This writes `user_deep.go` in the same directory. Commit it alongside your source. + +### 3. Use the Type-Safe API ```go -// Generate patch -patch, err := deep.Diff(oldState, newState) +import deep "github.com/brunoga/deep/v5" + +u1 := User{ID: 1, Name: "Alice", Roles: []string{"user"}} +u2 := User{ID: 1, Name: "Bob", Roles: []string{"user", "admin"}} -// Inspect changes -fmt.Println(patch.Summary()) +// State-based Diffing +patch, err := deep.Diff(u1, u2) +if err != nil { + log.Fatal(err) +} + +// Operation-based Building (Fluent, Type-Safe API) +namePath := deep.Field(func(u *User) *string { return &u.Name }) +scorePath := deep.Field(func(u *User) *map[string]int { return &u.Score }) -// Apply to target -err := patch.ApplyChecked(&oldState) +patch2 := deep.Edit(&u1). + With( + deep.Set(namePath, "Alice Smith"), + deep.Add(deep.MapKey(scorePath, "power"), 100), + ). + Build() + +// Application +if err := deep.Apply(&u1, patch); err != nil { + log.Fatal(err) +} ``` -* **Move & Copy Detection**: Identifies relocated values to minimize patch size. -* **Three-Way Merge**: Merges independent patches with conflict detection. -* **JSON Standard**: Native export to RFC 6902 (JSON Patch). -* **Examples**: [Move Detection](./examples/move_detection/main.go), [Three-Way Merge](./examples/three_way_merge/main.go) ---- +## Advanced Features -## Advanced Capabilities +### Integrated CRDTs -### Fluent Patch Builder -V4 introduces a fluent API for manual patch construction, allowing for intuitive navigation and modification of data structures without manual path management. +Convert any field into a convergent register: ```go -builder := deep.NewPatchBuilder[MyStruct]() -builder.Field("Profile").Field("Age").Set(30, 31) -builder.Field("Tags").Add(0, "new-tag") -patch, err := builder.Build() +type Document struct { + Title crdt.LWW[string] // Native Last-Write-Wins + Content crdt.Text // Collaborative Text CRDT +} ``` -### Advanced Conflict Resolution -For distributed systems and CRDTs, `deep` allows you to intercept and resolve conflicts dynamically. The resolver has access to both the **current** value at the target path and the **proposed** value. +### Conditional Patching + +Apply changes only if specific business rules are met: ```go -type MyResolver struct{} +namePath := deep.Field(func(u *User) *string { return &u.Name }) +idPath := deep.Field(func(u *User) *int { return &u.ID }) -func (r *MyResolver) Resolve(path string, op deep.OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) { - // Custom logic: e.g., semantic 3-way merge or timestamp-based LWW - return proposed, true -} +patch := deep.Edit(&u). + With(deep.Set(namePath, "New Name").If(deep.Eq(idPath, 1))). + Build() +``` + +Apply a patch only if a global guard condition holds: + +```go +patch = patch.WithGuard(deep.Gt(deep.Field(func(u *User) *int { return &u.ID }), 0)) +``` -err := patch.ApplyResolved(&state, &MyResolver{}) +### Observability + +Embed `OpLog` operations in a patch to emit structured trace messages during `Apply`. +Route them to any `*slog.Logger` — useful for request-scoped loggers, test capture, or +tracing without touching your model types: + +```go +namePath := deep.Field(func(u *User) *string { return &u.Name }) + +patch := deep.Edit(&u). + Log("starting update"). + With(deep.Set(namePath, "Alice Smith")). + Log("update complete"). + Build() + +logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) +deep.Apply(&u, patch, deep.WithLogger(logger)) +// {"level":"INFO","msg":"deep log","message":"starting update","path":"/"} +// {"level":"INFO","msg":"deep log","message":"update complete","path":"/"} ``` -### Struct Tag Control -Fine-grained control over library behavior: -* `deep:"-"`: Completely ignore field. -* `deep:"key"`: Identity field for slice alignment (Myers' Diff). -* `deep:"readonly"`: Field can be diffed but not modified by patches. -* `deep:"atomic"`: Treat complex fields as scalar values. +When no logger is provided, `slog.Default()` is used — so existing `slog.SetDefault` +configuration is respected without any extra wiring. + +### Patch Utilities ---- +```go +// Reverse a patch to produce an undo patch. +undo := patch.Reverse() -## Performance Optimization +// Enable strict mode — Apply verifies Old values match before each operation. +strictPatch := patch.AsStrict() +``` -Built for performance-critical hot paths: -* **Zero-Allocation Engine**: Uses `sync.Pool` for internal transient structures during diffing. -* **Reflection Cache**: Global cache for type metadata to eliminate repetitive lookups. -* **Lazy Allocation**: Maps and slices in patches are only allocated if changes are found. +### Standard Interop ---- +Export your Deep patches to standard RFC 6902 JSON Patch format, and parse them back: -## Version History +```go +jsonData, err := patch.ToJSONPatch() +// Output: [{"op":"replace","path":"/name","value":"Bob"}] -### v4.0.0: Ergonomics & Context (Current) -* **Fluent Patch Builder**: Merged `Node` into `PatchBuilder` for a cleaner, chainable API. -* **Context-Aware Resolution**: `ConflictResolver` now receives both `current` and `proposed` values and can return a merged result. -* **Strict JSON Pointers**: Removed dot-notation support in favor of strict RFC 6901 compliance. -* **Simplified Registry**: Global `RegisterCustom*` functions for easier extension. +restored, err := deep.ParseJSONPatch[User](jsonData) +``` -### v3.0.0: High-Performance Engine -* **Zero-Allocation Engine**: Refactored to use object pooling. -* **`deep.Equal[T]`**: High-performance, tag-aware replacement for `reflect.DeepEqual`. -* **Move & Copy Detection**: Semantic detection of relocated values during `Diff`. +> **JSON deserialization note**: When a patch is JSON-encoded and then decoded, numeric +> values in `Operation.Old` and `Operation.New` are unmarshaled as `float64` (standard +> Go JSON behavior). Generated `Patch` methods handle this automatically with +> numeric coercion. If you use the reflection fallback, be aware of this when inspecting +> `Old`/`New` directly. -### v2.0.0: Synchronization & Standards -* **JSON Pointer (RFC 6901)**: Standardized path navigation. -* **Keyed Slice Alignment**: Integrated identity-based matching into Myers' Diff. -* **HLC & CRDT**: Introduced Hybrid Logical Clocks and LWW conflict resolution. +## Architecture: Why v5? -### v1.0.0: The Foundation -* Initial recursive **Deep Copy** and **Deep Diff** implementation. +v4 used a **Recursive Tree Patch** model. Every field was a nested patch object. While flexible, this caused high memory allocations and made serialization difficult. ---- +Deep uses a **Flat Operation Model**. A patch is a simple slice of `Operations`. This makes patches: +1. **Portable**: Trivially serializable to any format. +2. **Fast**: Iterating a slice is much faster than traversing a tree. +3. **Composable**: Merging two patches is a stateless operation. ## License Apache 2.0 diff --git a/builder.go b/builder.go deleted file mode 100644 index 6bf16c2..0000000 --- a/builder.go +++ /dev/null @@ -1,772 +0,0 @@ -package deep - -import ( - "fmt" - "reflect" - "strconv" - "strings" - - "github.com/brunoga/deep/v4/cond" - "github.com/brunoga/deep/v4/internal/core" -) - -// PatchBuilder allows constructing a Patch[T] manually with on-the-fly type validation. -// It acts as a cursor within the value's structure, allowing for fluent navigation -// and modification. -type PatchBuilder[T any] struct { - state *builderState[T] - - typ reflect.Type - update func(diffPatch) - current diffPatch - fullPath string - - parent *PatchBuilder[T] - key string - index int - isIdx bool -} - -type builderState[T any] struct { - patch diffPatch - err error -} - -// NewPatchBuilder returns a new PatchBuilder for type T, pointing at the root. -func NewPatchBuilder[T any]() *PatchBuilder[T] { - var t T - typ := reflect.TypeOf(t) - b := &PatchBuilder[T]{ - state: &builderState[T]{}, - typ: typ, - } - b.update = func(p diffPatch) { - b.state.patch = p - } - return b -} - -// Build returns the constructed Patch or an error if any operation was invalid. -func (b *PatchBuilder[T]) Build() (Patch[T], error) { - if b.state.err != nil { - return nil, b.state.err - } - if b.state.patch == nil { - return nil, nil - } - return &typedPatch[T]{ - inner: b.state.patch, - strict: true, - }, nil -} - -// AddCondition parses a string expression and attaches it to the appropriate -// node in the patch tree based on the paths used in the expression. -// It finds the longest common prefix of all paths in the expression and -// navigates to that node before attaching the condition. -// The expression is evaluated relative to the current node. -func (b *PatchBuilder[T]) AddCondition(expr string) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - c, err := cond.ParseCondition[T](expr) - if err != nil { - b.state.err = err - return b - } - - paths := c.Paths() - prefix := lcpParts(paths) - - node := b.Navigate(prefix) - if b.state.err != nil { - return b - } - - node.WithCondition(c.WithRelativePath(prefix)) - return b -} - -// Navigate returns a new PatchBuilder for the specified path relative to the current node. -// It supports JSON Pointers ("/Field/Sub"). -func (b *PatchBuilder[T]) Navigate(path string) *PatchBuilder[T] { - if b.state.err != nil || path == "" { - return b - } - return b.navigateParts(core.ParsePath(path)) -} - -func (b *PatchBuilder[T]) navigateParts(parts []core.PathPart) *PatchBuilder[T] { - curr := b - for _, part := range parts { - if part.IsIndex { - curr = curr.Index(part.Index) - } else { - curr = curr.FieldOrMapKey(part.Key) - } - if b.state.err != nil { - return b - } - } - return curr -} - -// Put replaces the value at the current node without requiring the 'old' value. -// Strict consistency checks for this specific value will be disabled. -func (b *PatchBuilder[T]) Put(value any) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - vNew := reflect.ValueOf(value) - p := &valuePatch{ - newVal: core.DeepCopyValue(vNew), - } - if b.current != nil { - p.cond, p.ifCond, p.unlessCond = b.current.conditions() - } - b.update(p) - b.current = p - return b -} - -// Set replaces the value at the current node. It requires the 'old' value -// to enable patch reversibility and strict application checking. -func (b *PatchBuilder[T]) Set(old, new any) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - vOld := reflect.ValueOf(old) - vNew := reflect.ValueOf(new) - p := &valuePatch{ - oldVal: core.DeepCopyValue(vOld), - newVal: core.DeepCopyValue(vNew), - } - if b.current != nil { - p.cond, p.ifCond, p.unlessCond = b.current.conditions() - } - b.update(p) - b.current = p - return b -} - -// 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 (b *PatchBuilder[T]) Test(expected any) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - vExpected := reflect.ValueOf(expected) - p := &testPatch{ - expected: core.DeepCopyValue(vExpected), - } - if b.current != nil { - p.cond, p.ifCond, p.unlessCond = b.current.conditions() - } - b.update(p) - b.current = p - return b -} - -// Copy copies a value from another path to the current node. -func (b *PatchBuilder[T]) Copy(from string) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - absPath := b.fullPath - if absPath == "" { - absPath = "/" - } else if absPath[0] != '/' { - absPath = "/" + absPath - } - - absFrom := from - if absFrom == "" { - absFrom = "/" - } else if absFrom[0] != '/' { - absFrom = "/" + absFrom - } - - p := ©Patch{ - from: absFrom, - path: absPath, - } - if b.current != nil { - p.cond, p.ifCond, p.unlessCond = b.current.conditions() - } - b.update(p) - b.current = p - return b -} - -// Move moves a value from another path to the current node. -func (b *PatchBuilder[T]) Move(from string) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - absPath := b.fullPath - if absPath == "" { - absPath = "/" - } else if absPath[0] != '/' { - absPath = "/" + absPath - } - - absFrom := from - if absFrom == "" { - absFrom = "/" - } else if absFrom[0] != '/' { - absFrom = "/" + absFrom - } - - p := &movePatch{ - from: absFrom, - path: absPath, - } - if b.current != nil { - p.cond, p.ifCond, p.unlessCond = b.current.conditions() - } - b.update(p) - b.current = p - return b -} - -// Log adds a log operation to the current node. It prints a message -// and the current value at the node during patch application. -func (b *PatchBuilder[T]) Log(message string) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - p := &logPatch{ - message: message, - } - if b.current != nil { - p.cond, p.ifCond, p.unlessCond = b.current.conditions() - } - b.update(p) - b.current = p - return b -} - -func (b *PatchBuilder[T]) ensurePatch() { - if b.current != nil { - return - } - var p diffPatch - if b.typ == nil { - p = &valuePatch{} - } else { - switch b.typ.Kind() { - case reflect.Struct: - p = &structPatch{fields: make(map[string]diffPatch)} - case reflect.Slice: - p = &slicePatch{} - case reflect.Map: - p = &mapPatch{ - added: make(map[any]reflect.Value), - removed: make(map[any]reflect.Value), - modified: make(map[any]diffPatch), - originalKeys: make(map[any]any), - keyType: b.typ.Key(), - } - case reflect.Pointer: - p = &ptrPatch{} - case reflect.Interface: - p = &interfacePatch{} - case reflect.Array: - p = &arrayPatch{indices: make(map[int]diffPatch)} - default: - p = &valuePatch{} - } - } - b.current = p - b.update(p) -} - -// WithCondition attaches a local condition to the current node. -// This condition is evaluated against the value at this node during ApplyChecked. -func (b *PatchBuilder[T]) WithCondition(c any) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - b.ensurePatch() - if b.current != nil { - b.current.setCondition(c) - } - return b -} - -// If attaches an 'if' condition to the current node. If the condition -// evaluates to false, the operation at this node is skipped. -func (b *PatchBuilder[T]) If(c any) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - b.ensurePatch() - if b.current != nil { - b.current.setIfCondition(c) - } - return b -} - -// Unless attaches an 'unless' condition to the current node. If the condition -// evaluates to true, the operation at this node is skipped. -func (b *PatchBuilder[T]) Unless(c any) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - b.ensurePatch() - if b.current != nil { - b.current.setUnlessCondition(c) - } - return b -} - -// Field returns a new PatchBuilder for the specified struct field. It automatically -// descends into pointers and interfaces if necessary. -func (b *PatchBuilder[T]) Field(name string) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - curr := b.Elem() - if curr.typ.Kind() != reflect.Struct { - b.state.err = fmt.Errorf("not a struct: %v", curr.typ) - return b - } - field, ok := curr.typ.FieldByName(name) - if !ok { - b.state.err = fmt.Errorf("field not found: %s", name) - return b - } - sp, ok := curr.current.(*structPatch) - if !ok { - sp = &structPatch{fields: make(map[string]diffPatch)} - curr.update(sp) - curr.current = sp - } - return &PatchBuilder[T]{ - state: b.state, - typ: field.Type, - update: func(p diffPatch) { - sp.fields[name] = p - }, - current: sp.fields[name], - fullPath: curr.fullPath + "/" + name, - parent: curr, - key: name, - } -} - -// Index returns a new PatchBuilder for the specified array or slice index. -func (b *PatchBuilder[T]) Index(i int) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - curr := b.Elem() - kind := curr.typ.Kind() - if kind != reflect.Slice && kind != reflect.Array { - b.state.err = fmt.Errorf("not a slice or array: %v", curr.typ) - return b - } - if kind == reflect.Array && (i < 0 || i >= curr.typ.Len()) { - b.state.err = fmt.Errorf("index out of bounds: %d", i) - return b - } - if kind == reflect.Array { - ap, ok := curr.current.(*arrayPatch) - if !ok { - ap = &arrayPatch{indices: make(map[int]diffPatch)} - curr.update(ap) - curr.current = ap - } - return &PatchBuilder[T]{ - state: b.state, - typ: curr.typ.Elem(), - update: func(p diffPatch) { - ap.indices[i] = p - }, - current: ap.indices[i], - fullPath: curr.fullPath + "/" + strconv.Itoa(i), - parent: curr, - index: i, - isIdx: true, - } - } - sp, ok := curr.current.(*slicePatch) - if !ok { - sp = &slicePatch{} - curr.update(sp) - curr.current = sp - } - var modOp *sliceOp - for j := range sp.ops { - if sp.ops[j].Index == i && sp.ops[j].Kind == OpReplace { - modOp = &sp.ops[j] - break - } - } - if modOp == nil { - sp.ops = append(sp.ops, sliceOp{ - Kind: OpReplace, - Index: i, - }) - modOp = &sp.ops[len(sp.ops)-1] - } - return &PatchBuilder[T]{ - state: b.state, - typ: curr.typ.Elem(), - update: func(p diffPatch) { - modOp.Patch = p - }, - current: modOp.Patch, - fullPath: curr.fullPath + "/" + strconv.Itoa(i), - parent: curr, - index: i, - isIdx: true, - } -} - -// MapKey returns a new PatchBuilder for the specified map key. -func (b *PatchBuilder[T]) MapKey(key any) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - curr := b.Elem() - if curr.typ.Kind() != reflect.Map { - b.state.err = fmt.Errorf("not a map: %v", curr.typ) - return b - } - vKey := reflect.ValueOf(key) - if vKey.Type() != curr.typ.Key() { - if _, ok := key.(string); ok { - // Special handling for canonical keys during navigation - } else { - b.state.err = fmt.Errorf("invalid key type: expected %v, got %v", curr.typ.Key(), vKey.Type()) - return b - } - } - mp, ok := curr.current.(*mapPatch) - if !ok { - mp = &mapPatch{ - added: make(map[any]reflect.Value), - removed: make(map[any]reflect.Value), - modified: make(map[any]diffPatch), - keyType: curr.typ.Key(), - } - curr.update(mp) - curr.current = mp - } - return &PatchBuilder[T]{ - state: b.state, - typ: curr.typ.Elem(), - update: func(p diffPatch) { - mp.modified[key] = p - }, - current: mp.modified[key], - fullPath: curr.fullPath + "/" + fmt.Sprintf("%v", key), - parent: curr, - key: fmt.Sprintf("%v", key), - } -} - -// Elem returns a new PatchBuilder for the element type of a pointer or interface. -func (b *PatchBuilder[T]) Elem() *PatchBuilder[T] { - if b.state.err != nil || b.typ == nil || (b.typ.Kind() != reflect.Pointer && b.typ.Kind() != reflect.Interface) { - return b - } - var updateFunc func(diffPatch) - var currentPatch diffPatch - if b.typ.Kind() == reflect.Pointer { - pp, ok := b.current.(*ptrPatch) - if !ok { - pp = &ptrPatch{} - b.update(pp) - b.current = pp - } - updateFunc = func(p diffPatch) { pp.elemPatch = p } - currentPatch = pp.elemPatch - } else { - ip, ok := b.current.(*interfacePatch) - if !ok { - ip = &interfacePatch{} - b.update(ip) - b.current = ip - } - updateFunc = func(p diffPatch) { ip.elemPatch = p } - currentPatch = ip.elemPatch - } - var nextTyp reflect.Type - if b.typ.Kind() == reflect.Pointer { - nextTyp = b.typ.Elem() - } - return &PatchBuilder[T]{ - state: b.state, - typ: nextTyp, - update: updateFunc, - current: currentPatch, - fullPath: b.fullPath, - parent: b.parent, - key: b.key, - index: b.index, - isIdx: b.isIdx, - } -} - -// Add appends an addition operation to a slice or map node. -func (b *PatchBuilder[T]) Add(keyOrIndex, val any) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - if b.typ.Kind() == reflect.Slice { - i, ok := keyOrIndex.(int) - if !ok { - b.state.err = fmt.Errorf("index must be int for slices") - return b - } - var v reflect.Value - if rv, ok := val.(reflect.Value); ok { - v = rv - } else { - v = reflect.ValueOf(val) - } - - if !v.IsValid() { - v = reflect.Zero(b.typ.Elem()) - } - - if v.Type() != b.typ.Elem() { - b.state.err = fmt.Errorf("invalid value type: expected %v, got %v", b.typ.Elem(), v.Type()) - return b - } - sp, ok := b.current.(*slicePatch) - if !ok { - sp = &slicePatch{} - b.update(sp) - b.current = sp - } - sp.ops = append(sp.ops, sliceOp{ - Kind: OpAdd, - Index: i, - Val: core.DeepCopyValue(v), - }) - return b - } - if b.typ.Kind() == reflect.Map { - vKey := reflect.ValueOf(keyOrIndex) - if vKey.Type() != b.typ.Key() { - if s, ok := keyOrIndex.(string); ok { - if b.typ.Key().Kind() == reflect.String { - vKey = reflect.ValueOf(s) - } - } - if vKey.Type() != b.typ.Key() { - b.state.err = fmt.Errorf("invalid key type: expected %v, got %v", b.typ.Key(), vKey.Type()) - return b - } - } - var vVal reflect.Value - if rv, ok := val.(reflect.Value); ok { - vVal = rv - } else { - vVal = reflect.ValueOf(val) - } - - if !vVal.IsValid() { - vVal = reflect.Zero(b.typ.Elem()) - } - - if vVal.Type() != b.typ.Elem() { - b.state.err = fmt.Errorf("invalid value type: expected %v, got %v", b.typ.Elem(), vVal.Type()) - return b - } - mp, ok := b.current.(*mapPatch) - if !ok { - mp = &mapPatch{ - added: make(map[any]reflect.Value), - removed: make(map[any]reflect.Value), - modified: make(map[any]diffPatch), - originalKeys: make(map[any]any), - keyType: b.typ.Key(), - } - b.update(mp) - b.current = mp - } - mp.added[keyOrIndex] = core.DeepCopyValue(vVal) - return b - } - b.state.err = fmt.Errorf("Add only supported on slices and maps, got %v", b.typ.Kind()) - return b -} - -// Delete appends a deletion operation to a slice or map node. -func (b *PatchBuilder[T]) Delete(keyOrIndex any, oldVal any) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - if b.typ.Kind() == reflect.Slice { - i, ok := keyOrIndex.(int) - if !ok { - b.state.err = fmt.Errorf("index must be int for slices") - return b - } - var vOld reflect.Value - if rv, ok := oldVal.(reflect.Value); ok { - vOld = rv - } else { - vOld = reflect.ValueOf(oldVal) - } - - if !vOld.IsValid() { - vOld = reflect.Zero(b.typ.Elem()) - } - - if vOld.Type() != b.typ.Elem() { - b.state.err = fmt.Errorf("invalid old value type: expected %v, got %v", b.typ.Elem(), vOld.Type()) - return b - } - sp, ok := b.current.(*slicePatch) - if !ok { - sp = &slicePatch{} - b.update(sp) - b.current = sp - } - sp.ops = append(sp.ops, sliceOp{ - Kind: OpRemove, - Index: i, - Val: core.DeepCopyValue(vOld), - }) - return b - } - if b.typ.Kind() == reflect.Map { - vKey := reflect.ValueOf(keyOrIndex) - if vKey.Type() != b.typ.Key() { - if s, ok := keyOrIndex.(string); ok { - if b.typ.Key().Kind() == reflect.String { - vKey = reflect.ValueOf(s) - } - } - if vKey.Type() != b.typ.Key() { - b.state.err = fmt.Errorf("invalid key type: expected %v, got %v", b.typ.Key(), vKey.Type()) - return b - } - } - var vOld reflect.Value - if rv, ok := oldVal.(reflect.Value); ok { - vOld = rv - } else { - vOld = reflect.ValueOf(oldVal) - } - - if !vOld.IsValid() { - vOld = reflect.Zero(b.typ.Elem()) - } - - if vOld.Type() != b.typ.Elem() { - b.state.err = fmt.Errorf("invalid old value type: expected %v, got %v", b.typ.Elem(), vOld.Type()) - return b - } - mp, ok := b.current.(*mapPatch) - if !ok { - mp = &mapPatch{ - added: make(map[any]reflect.Value), - removed: make(map[any]reflect.Value), - modified: make(map[any]diffPatch), - originalKeys: make(map[any]any), - keyType: b.typ.Key(), - } - b.update(mp) - b.current = mp - } - mp.removed[keyOrIndex] = core.DeepCopyValue(vOld) - return b - } - b.state.err = fmt.Errorf("Delete only supported on slices and maps, got %v", b.typ) - return b -} - -// Remove removes the current node from its parent. -func (b *PatchBuilder[T]) Remove(oldVal any) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - if b.parent == nil { - b.state.err = fmt.Errorf("cannot remove root node") - return b - } - if b.isIdx { - b.parent.Delete(b.index, oldVal) - } else { - b.parent.Delete(b.key, oldVal) - } - return b -} - -// FieldOrMapKey returns a new PatchBuilder for the specified field or map key. -func (b *PatchBuilder[T]) FieldOrMapKey(key string) *PatchBuilder[T] { - if b.state.err != nil { - return b - } - curr := b.Elem() - if curr.typ != nil && curr.typ.Kind() == reflect.Map { - keyType := curr.typ.Key() - var keyVal any - if keyType.Kind() == reflect.String { - keyVal = key - } else if keyType.Kind() == reflect.Int { - i, err := strconv.Atoi(key) - if err != nil { - b.state.err = fmt.Errorf("invalid int key for map: %s", key) - return b - } - keyVal = i - } else { - return curr.MapKey(key) - } - return curr.MapKey(keyVal) - } - return curr.Field(key) -} - -// lcpParts returns the longest common prefix of the given paths. -func lcpParts(paths []string) string { - if len(paths) == 0 { - return "" - } - - allParts := make([][]core.PathPart, len(paths)) - for i, p := range paths { - allParts[i] = core.ParsePath(p) - } - - common := allParts[0] - for i := 1; i < len(allParts); i++ { - n := len(common) - if len(allParts[i]) < n { - n = len(allParts[i]) - } - common = common[:n] - for j := 0; j < n; j++ { - if !common[j].Equals(allParts[i][j]) { - common = common[:j] - break - } - } - } - - // Convert common parts back to string path - if len(common) == 0 { - return "" - } - var b strings.Builder - for i, p := range common { - if p.IsIndex { - if i == 0 { - // Special case - } - b.WriteByte('/') - b.WriteString(strconv.Itoa(p.Index)) - } else { - b.WriteByte('/') - b.WriteString(p.Key) - } - } - return b.String() -} diff --git a/builder_test.go b/builder_test.go deleted file mode 100644 index 15ac256..0000000 --- a/builder_test.go +++ /dev/null @@ -1,674 +0,0 @@ -package deep - -import ( - "encoding/json" - "testing" - - "github.com/brunoga/deep/v4/cond" -) - -func TestBuilder_Basic(t *testing.T) { - type S struct { - A int - B string - } - b := NewPatchBuilder[S]() - b.Field("A").Set(1, 2) - - patch, err := b.Build() - if err != nil { - t.Fatalf("Build failed: %v", err) - } - val := S{A: 1, B: "test"} - patch.Apply(&val) - if val.A != 2 { - t.Errorf("Expected A=2, got %d", val.A) - } - if val.B != "test" { - t.Errorf("Expected B=test, got %s", val.B) - } -} - -func TestBuilder_Validation(t *testing.T) { - type S struct { - A int - } - b := NewPatchBuilder[S]() - b.Field("NonExistent") - _, err := b.Build() - if err == nil { - t.Error("Expected error for non-existent field") - } - - b2 := NewPatchBuilder[S]() - b2.Field("A").Set("string", 2) - // Set currently doesn't check type compatibility of 'old' value at build time, - // but let's check Add which does. - b2.Field("A").Add(0, 1) - _, err = b2.Build() - if err == nil { - t.Error("Expected error for Add on non-slice node") - } -} - -func TestBuilder_Nested(t *testing.T) { - type Child struct { - Name string - } - type Parent struct { - Kids []Child - } - b := NewPatchBuilder[Parent]() - b.Field("Kids").Add(0, Child{Name: "NewKid"}) - b.Field("Kids").Index(0).Field("Name").Set("Old", "Modified") - - patch, err := b.Build() - if err != nil { - t.Fatalf("Build failed: %v", err) - } - val := Parent{ - Kids: []Child{{Name: "Old"}}, - } - patch.Apply(&val) - if len(val.Kids) != 2 { - t.Fatalf("Expected 2 kids, got %d", len(val.Kids)) - } - if val.Kids[0].Name != "NewKid" { - t.Errorf("Expected first kid to be NewKid, got %s", val.Kids[0].Name) - } - if val.Kids[1].Name != "Modified" { - t.Errorf("Expected second kid to be Modified, got %s", val.Kids[1].Name) - } -} - -func TestBuilder_Map(t *testing.T) { - b := NewPatchBuilder[map[string]int]() - b.Add("new", 100).Delete("old", 50) - b.MapKey("mod").Set(10, 20) - - patch, err := b.Build() - if err != nil { - t.Fatalf("Build failed: %v", err) - } - m := map[string]int{"old": 50, "mod": 10} - patch.Apply(&m) - if m["new"] != 100 { - t.Errorf("Expected new=100") - } - if _, ok := m["old"]; ok { - t.Errorf("Expected old to be deleted") - } - if m["mod"] != 20 { - t.Errorf("Expected mod=20") - } -} - -func TestBuilder_Elem(t *testing.T) { - type S struct { - Val int - } - s := S{Val: 10} - p := &s // Pointer to struct - - b := NewPatchBuilder[*S]() - // Drill down through pointer - b.Elem().Field("Val").Set(10, 20) - - patch, err := b.Build() - if err != nil { - t.Fatalf("Build failed: %v", err) - } - - if err := patch.ApplyChecked(&p); err != nil { - t.Fatalf("ApplyChecked failed: %v", err) - } - - if p.Val != 20 { - t.Errorf("Expected Val=20, got %d", p.Val) - } -} - -func TestBuilder_Array(t *testing.T) { - type Arr [3]int - a := Arr{1, 2, 3} - - b := NewPatchBuilder[Arr]() - // Modify index 1 - b.Index(1).Set(2, 20) - - patch, err := b.Build() - if err != nil { - t.Fatalf("Build failed: %v", err) - } - - if err := patch.ApplyChecked(&a); err != nil { - t.Fatalf("ApplyChecked failed: %v", err) - } - - if a[1] != 20 { - t.Errorf("Expected a[1]=20, got %d", a[1]) - } -} - -func TestBuilder_ErrorPaths(t *testing.T) { - // Delete on non-container - b1 := NewPatchBuilder[int]() - b1.Delete(0, 0) - if _, err := b1.Build(); err == nil { - t.Error("Expected error for Delete on int") - } - - // Delete with wrong index type for slice - b2 := NewPatchBuilder[[]int]() - b2.Delete("string_key", 0) - if _, err := b2.Build(); err == nil { - t.Error("Expected error for non-int delete on slice") - } - - // Add on non-slice - b3 := NewPatchBuilder[map[string]int]() - b3.Add(0, 1) - if _, err := b3.Build(); err == nil { - t.Error("Expected error for Add on map") - } - - // Add on non-map (with string key) - b4 := NewPatchBuilder[[]int]() - b4.Add("k", 1) - if _, err := b4.Build(); err == nil { - t.Error("Expected error for string Add on slice") - } -} - -func TestBuilder_Exhaustive(t *testing.T) { - t.Run("NestedPointer", func(t *testing.T) { - type S struct{ V int } - type P struct{ S *S } - - b := NewPatchBuilder[P]() - b.Field("S").Elem().Field("V").Set(nil, 10) - - p, _ := b.Build() - var target P - p.Apply(&target) - if target.S == nil || target.S.V != 10 { - t.Errorf("Nested pointer application failed: %+v", target.S) - } - }) - - t.Run("MapKeyCreation", func(t *testing.T) { - b := NewPatchBuilder[map[string]int]() - b.Add("a", 1) - - p, _ := b.Build() - var m map[string]int - p.Apply(&m) - if m["a"] != 1 { - t.Errorf("Map key creation failed: %v", m) - } - }) - - t.Run("EmptyPatch", func(t *testing.T) { - b := NewPatchBuilder[int]() - p, err := b.Build() - if err != nil || p != nil { - t.Errorf("Expected nil patch for no operations, got %v, %v", p, err) - } - }) - - t.Run("NavigationErrors", func(t *testing.T) { - type S struct{ A int } - b := NewPatchBuilder[S]() - b.Navigate("/NonExistent") - if _, err := b.Build(); err == nil { - t.Error("Expected error for non-existent field navigation") - } - - type M struct{ Data map[int]int } - b2 := NewPatchBuilder[M]() - b2.Navigate("/Data/not_an_int") - if _, err := b2.Build(); err == nil { - t.Error("Expected error for invalid map key type navigation") - } - }) - - t.Run("DeleteMore", func(t *testing.T) { - b := NewPatchBuilder[map[int]string]() - b.Delete(1, "old") - if _, err := b.Build(); err != nil { - t.Errorf("Delete on map failed: %v", err) - } - - type S struct{ A int } - b2 := NewPatchBuilder[S]() - b2.Delete("A", 1) - if _, err := b2.Build(); err == nil { - t.Error("Expected error for Delete on struct") - } - }) - - t.Run("ElemEdgeCases", func(t *testing.T) { - b := NewPatchBuilder[**int]() - b.Elem().Elem().Set(1, 2) - - p, _ := b.Build() - v := 1 - pv := &v - ppv := &pv - p.Apply(&ppv) - if **ppv != 2 { - t.Errorf("Expected 2, got %d", **ppv) - } - - b2 := NewPatchBuilder[int]() - b2.Elem() - if _, err := b2.Build(); err != nil { - t.Errorf("Elem on non-pointer should not error: %v", err) - } - }) - - t.Run("WithConditionOnAllTypes", func(t *testing.T) { - cond := cond.Eq[int]("", 1) - - b1 := NewPatchBuilder[*int]() - b1.WithCondition(cond) - - b2 := NewPatchBuilder[[]int]() - b2.WithCondition(cond) - - b3 := NewPatchBuilder[map[string]int]() - b3.WithCondition(cond) - - b4 := NewPatchBuilder[[1]int]() - b4.WithCondition(cond) - - type IfaceStruct struct{ I any } - b5 := NewPatchBuilder[IfaceStruct]() - b5.Field("I").WithCondition(cond) - - _, err := b1.Build() - if err != nil { - t.Errorf("Build failed: %v", err) - } - }) -} - -type builderTestConfig struct { - Network builderTestNetworkConfig -} - -type builderTestNetworkConfig struct { - Port int - Host string -} - -func TestBuilder_AddConditionSmart(t *testing.T) { - b := NewPatchBuilder[builderTestConfig]() - b.AddCondition("/Network/Port > 1024") - - p, err := b.Build() - if err != nil { - t.Fatalf("Build failed: %v", err) - } - - c1 := builderTestConfig{Network: builderTestNetworkConfig{Port: 8080}} - if err := p.ApplyChecked(&c1); err != nil { - t.Errorf("ApplyChecked failed for valid port: %v", err) - } - - c2 := builderTestConfig{Network: builderTestNetworkConfig{Port: 80}} - if err := p.ApplyChecked(&c2); err == nil { - t.Errorf("ApplyChecked should have failed for invalid port") - } -} - -func TestBuilder_AddConditionLCP(t *testing.T) { - b := NewPatchBuilder[builderTestConfig]() - b.AddCondition("/Network/Port > 1024 AND /Network/Host == 'localhost'") - - p, err := b.Build() - if err != nil { - t.Fatalf("Build failed: %v", err) - } - - c1 := builderTestConfig{Network: builderTestNetworkConfig{Port: 8080, Host: "localhost"}} - if err := p.ApplyChecked(&c1); err != nil { - t.Errorf("ApplyChecked failed for valid config: %v", err) - } - - c2 := builderTestConfig{Network: builderTestNetworkConfig{Port: 80, Host: "localhost"}} - if err := p.ApplyChecked(&c2); err == nil { - t.Errorf("ApplyChecked should have failed for invalid port") - } - - c3 := builderTestConfig{Network: builderTestNetworkConfig{Port: 8080, Host: "example.com"}} - if err := p.ApplyChecked(&c3); err == nil { - t.Errorf("ApplyChecked should have failed for invalid host") - } -} - -func TestBuilder_AddConditionDeep(t *testing.T) { - type Deep struct { - A struct { - B struct { - C int - } - } - } - - b := NewPatchBuilder[Deep]() - b.AddCondition("/A/B/C == 10") - - p, err := b.Build() - if err != nil { - t.Fatalf("Build failed: %v", err) - } - - d1 := Deep{} - d1.A.B.C = 10 - if err := p.ApplyChecked(&d1); err != nil { - t.Errorf("ApplyChecked failed: %v", err) - } - - d2 := Deep{} - d2.A.B.C = 20 - if err := p.ApplyChecked(&d2); err == nil { - t.Errorf("ApplyChecked should have failed") - } -} - -func TestBuilder_AddConditionMap(t *testing.T) { - type MapStruct struct { - Data map[string]int - } - - b := NewPatchBuilder[MapStruct]() - b.AddCondition("/Data/key1 > 10") - - p, err := b.Build() - if err != nil { - t.Fatalf("Build failed: %v", err) - } - - m1 := MapStruct{Data: map[string]int{"key1": 20}} - if err := p.ApplyChecked(&m1); err != nil { - t.Errorf("ApplyChecked failed: %v", err) - } - - m2 := MapStruct{Data: map[string]int{"key1": 5}} - if err := p.ApplyChecked(&m2); err == nil { - t.Errorf("ApplyChecked should have failed") - } -} - -func TestBuilder_AddConditionFieldToField(t *testing.T) { - type S struct { - A int - B int - } - b := NewPatchBuilder[S]() - b.AddCondition("/A > /B") - - p, err := b.Build() - if err != nil { - t.Fatalf("Build failed: %v", err) - } - - s1 := S{A: 10, B: 5} - if err := p.ApplyChecked(&s1); err != nil { - t.Errorf("ApplyChecked failed for A > B: %v", err) - } - - s2 := S{A: 5, B: 10} - if err := p.ApplyChecked(&s2); err == nil { - 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 := NewPatchBuilder[User]() - builder.Field("Name").If(cond.Eq[User]("/Age", 20)).Set("Alice", "Bob") - builder.Field("Age").If(cond.Eq[User]("/Name", "Alice")).Set(30, 31) - - patch, _ := builder.Build() - - if err := patch.ApplyChecked(&u); err != nil { - t.Fatalf("ApplyChecked failed: %v", err) - } - - if u.Name != "Alice" { - t.Errorf("Expected Name=Alice, got %s", u.Name) - } - 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 := NewPatchBuilder[User]() - builder.Field("Name").Unless(cond.Eq[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 := NewPatchBuilder[User]() - builder.Field("Name").Test("Alice") - builder.Field("Age").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) - } - - 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 := NewPatchBuilder[User]() - builder.Field("AltName").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 := NewPatchBuilder[User]() - builder.Field("AltName").Move("/Name") - - patch, _ := builder.Build() - - 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)) - } - - 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 := NewPatchBuilder[User]() - builder.Field("AltName").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 := NewPatchBuilder[User]() - builder.AddCondition("/Name == 'Alice'") - builder.Field("Age").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) - } - - 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) { - b1 := NewPatchBuilder[map[string]int]() - b1.Delete("a", 1) - - b2 := NewPatchBuilder[[]int]() - b2.Delete(0, 1) - - b3 := NewPatchBuilder[int]() - b3.Delete("a", 1) - if _, err := b3.Build(); err == nil { - t.Error("Expected error deleting from non-container") - } -} - -func TestBuilder_AddCondition_CornerCases(t *testing.T) { - type Data struct { - A int - B int - } - - b := NewPatchBuilder[Data]() - // Valid complex condition - b.AddCondition("/A == 1 AND /B == 1") - - b2 := NewPatchBuilder[Data]() - b2.AddCondition("INVALID") - if _, err := b2.Build(); 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 := NewPatchBuilder[User]() - builder.Field("Name").Log("Checking user name") - - patch, _ := builder.Build() - - if err := patch.ApplyChecked(&u); err != nil { - t.Fatalf("ApplyChecked failed: %v", err) - } - - 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)) - } -} - -func TestBuilder_Put(t *testing.T) { - type State struct { - Data map[string]int - } - b := NewPatchBuilder[State]() - b.Navigate("/Data").Put(map[string]int{"a": 1}) - p, _ := b.Build() - - s := State{Data: make(map[string]int)} - p.Apply(&s) - if s.Data["a"] != 1 { - t.Errorf("expected 1, got %d", s.Data["a"]) - } -} diff --git a/cmd/deep-gen/main.go b/cmd/deep-gen/main.go new file mode 100644 index 0000000..388f2bd --- /dev/null +++ b/cmd/deep-gen/main.go @@ -0,0 +1,928 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "log" + "os" + "path/filepath" + "reflect" + "strings" + "text/template" +) + +var ( + typeNames = flag.String("type", "", "comma-separated list of type names; must be set") + outputFile = flag.String("output", "", "output file name; defaults to stdout") +) + +// FieldInfo describes one struct field for code generation. +type FieldInfo struct { + Name string + JSONName string + Type string + IsStruct bool + IsCollection bool + IsText bool + Ignore bool + ReadOnly bool + Atomic bool +} + +// Generator accumulates generated source for all requested types. +type Generator struct { + pkgName string + pkgPrefix string // "deep." for non-deep packages, "" when generating inside the deep package + buf bytes.Buffer + typeKeys map[string]string // typeName -> keyFieldName (from deep:"key" tag) +} + +// ── template data structs ──────────────────────────────────────────────────── + +type headerData struct { + PkgName string + NeedsRegexp bool + NeedsStrings bool + NeedsCondition bool + NeedsDeep bool + NeedsCrdt bool +} + +type typeData struct { + TypeName string + P string // package prefix + Fields []FieldInfo + TypeKeys map[string]string +} + +// ── helpers used by both templates and FuncMap ─────────────────────────────── + +func isPtr(s string) bool { return strings.HasPrefix(s, "*") } +func mapVal(s string) string { return s[strings.Index(s, "]")+1:] } +func sliceElem(s string) string { return s[2:] } +func isMapStringKey(s string) bool { return strings.HasPrefix(s, "map[string]") } + +func isNumericType(t string) bool { + switch t { + case "int", "int8", "int16", "int32", "int64", + "uint", "uint8", "uint16", "uint32", "uint64", + "float32", "float64": + return true + } + return false +} + +// ── FuncMap functions that return code fragments ───────────────────────────── + +// fieldApplyCase returns the full `case "/name":` block for ApplyOperation. +func fieldApplyCase(f FieldInfo, p string) string { + var b strings.Builder + if f.JSONName != f.Name { + fmt.Fprintf(&b, "\tcase \"/%s\", \"/%s\":\n", f.JSONName, f.Name) + } else { + fmt.Fprintf(&b, "\tcase \"/%s\":\n", f.Name) + } + if f.ReadOnly { + b.WriteString("\t\treturn true, fmt.Errorf(\"field %s is read-only\", op.Path)\n") + return b.String() + } + // OpLog + fmt.Fprintf(&b, "\t\tif op.Kind == %sOpLog {\n", p) + fmt.Fprintf(&b, "\t\t\tlogger.Info(\"deep log\", \"message\", op.New, \"path\", op.Path, \"field\", t.%s)\n", f.Name) + b.WriteString("\t\t\treturn true, nil\n\t\t}\n") + // Strict check + fmt.Fprintf(&b, "\t\tif op.Kind == %sOpReplace && op.Strict {\n", p) + if f.IsStruct || f.IsText || f.IsCollection { + fmt.Fprintf(&b, "\t\t\tif old, ok := op.Old.(%s); !ok || !%sEqual(t.%s, old) {\n", f.Type, p, f.Name) + fmt.Fprintf(&b, "\t\t\t\treturn true, fmt.Errorf(\"strict check failed at %%s: expected %%v, got %%v\", op.Path, op.Old, t.%s)\n", f.Name) + b.WriteString("\t\t\t}\n") + } else if isNumericType(f.Type) { + // Numeric types: op.Old may be float64 after JSON roundtrip. + fmt.Fprintf(&b, "\t\t\t_oldOK := false\n") + fmt.Fprintf(&b, "\t\t\tif _oldV, ok := op.Old.(%s); ok { _oldOK = t.%s == _oldV }\n", f.Type, f.Name) + fmt.Fprintf(&b, "\t\t\tif !_oldOK { if _oldF, ok := op.Old.(float64); ok { _oldOK = %s(t.%s) == _oldF } }\n", "float64", f.Name) + fmt.Fprintf(&b, "\t\t\tif !_oldOK {\n") + fmt.Fprintf(&b, "\t\t\t\treturn true, fmt.Errorf(\"strict check failed at %%s: expected %%v, got %%v\", op.Path, op.Old, t.%s)\n", f.Name) + b.WriteString("\t\t\t}\n") + } else { + fmt.Fprintf(&b, "\t\t\tif _oldV, ok := op.Old.(%s); !ok || t.%s != _oldV {\n", f.Type, f.Name) + fmt.Fprintf(&b, "\t\t\t\treturn true, fmt.Errorf(\"strict check failed at %%s: expected %%v, got %%v\", op.Path, op.Old, t.%s)\n", f.Name) + b.WriteString("\t\t\t}\n") + } + b.WriteString("\t\t}\n") + // Value assignment + if f.IsText { + // Text is a convergent CRDT type — delegate via Patch with a single-op sub-patch. + fmt.Fprintf(&b, "\t\top.Path = \"/\"\n") + fmt.Fprintf(&b, "\t\treturn true, t.%s.Patch(%sPatch[crdt.Text]{Operations: []%sOperation{op}}, logger)\n", f.Name, p, p) + return b.String() + } + fmt.Fprintf(&b, "\t\tif v, ok := op.New.(%s); ok {\n\t\t\tt.%s = v\n\t\t\treturn true, nil\n\t\t}\n", f.Type, f.Name) + // Numeric float64 fallback (JSON deserialises numbers as float64) + if f.Type == "int" || f.Type == "int64" || f.Type == "float64" { + fmt.Fprintf(&b, "\t\tif f, ok := op.New.(float64); ok {\n\t\t\tt.%s = %s(f)\n\t\t\treturn true, nil\n\t\t}\n", f.Name, f.Type) + } + return b.String() +} + +// delegateCase returns the sub-path delegation block for the default: branch. +func delegateCase(f FieldInfo, p string) string { + if f.Ignore || f.Atomic { + return "" + } + var b strings.Builder + if f.IsStruct { + fmt.Fprintf(&b, "\t\tif strings.HasPrefix(op.Path, \"/%s/\") {\n", f.JSONName) + if f.ReadOnly { + b.WriteString("\t\t\treturn true, fmt.Errorf(\"field %s is read-only\", op.Path)\n") + } else { + selfArg := "(&t." + f.Name + ")" + if isPtr(f.Type) { + selfArg = "t." + f.Name + fmt.Fprintf(&b, "\t\t\tif %s != nil {\n", selfArg) + fmt.Fprintf(&b, "\t\t\t\top.Path = op.Path[len(\"/%s/\")-1:]\n", f.JSONName) + fmt.Fprintf(&b, "\t\t\t\treturn %s.applyOperation(op, logger)\n\t\t\t}\n", selfArg) + } else { + fmt.Fprintf(&b, "\t\t\top.Path = op.Path[len(\"/%s/\")-1:]\n", f.JSONName) + fmt.Fprintf(&b, "\t\t\treturn %s.applyOperation(op, logger)\n", selfArg) + } + } + b.WriteString("\t\t}\n") + } + if f.IsCollection && isMapStringKey(f.Type) { + vt := mapVal(f.Type) + fmt.Fprintf(&b, "\t\tif strings.HasPrefix(op.Path, \"/%s/\") {\n", f.JSONName) + if f.ReadOnly { + b.WriteString("\t\t\treturn true, fmt.Errorf(\"field %s is read-only\", op.Path)\n") + } else if isPtr(vt) { + fmt.Fprintf(&b, "\t\t\tparts := strings.Split(op.Path[len(\"/%s/\"):], \"/\")\n", f.JSONName) + b.WriteString("\t\t\tkey := parts[0]\n") + fmt.Fprintf(&b, "\t\t\tif val, ok := t.%s[key]; ok && val != nil {\n", f.Name) + b.WriteString("\t\t\t\top.Path = \"/\"\n") + b.WriteString("\t\t\t\tif len(parts) > 1 { op.Path = \"/\" + strings.Join(parts[1:], \"/\") }\n") + b.WriteString("\t\t\t\treturn val.applyOperation(op, logger)\n\t\t\t}\n") + } else { + fmt.Fprintf(&b, "\t\t\tparts := strings.Split(op.Path[len(\"/%s/\"):], \"/\")\n", f.JSONName) + b.WriteString("\t\t\tkey := parts[0]\n") + fmt.Fprintf(&b, "\t\t\tif op.Kind == %sOpRemove {\n", p) + fmt.Fprintf(&b, "\t\t\t\tdelete(t.%s, key)\n\t\t\t\treturn true, nil\n\t\t\t}\n", f.Name) + fmt.Fprintf(&b, "\t\t\tif t.%s == nil { t.%s = make(%s) }\n", f.Name, f.Name, f.Type) + fmt.Fprintf(&b, "\t\t\tif v, ok := op.New.(%s); ok {\n\t\t\t\tt.%s[key] = v\n\t\t\t\treturn true, nil\n\t\t\t}\n", vt, f.Name) + } + b.WriteString("\t\t}\n") + } + return b.String() +} + +// diffFieldCode returns the diff fragment for one field. +func diffFieldCode(f FieldInfo, p string, typeKeys map[string]string) string { + var b strings.Builder + if f.Ignore { + return "" + } + if (f.IsStruct || f.IsText) && !f.Atomic { + self, other := "(&t."+f.Name+")", "&other."+f.Name + if isPtr(f.Type) { + self, other = "t."+f.Name, "other."+f.Name + } + if f.IsText { + other = "other." + f.Name + } + // Text is a slice value — &t.Field is never nil and Text.Diff handles empty slices. + // Only pointer fields need a nil guard. + needsGuard := isPtr(f.Type) + if needsGuard { + fmt.Fprintf(&b, "\tif %s != nil && %s != nil {\n", self, other) + } + fmt.Fprintf(&b, "\t\tsub%s := %s.Diff(%s)\n", f.Name, self, other) + fmt.Fprintf(&b, "\t\tfor _, op := range sub%s.Operations {\n", f.Name) + fmt.Fprintf(&b, "\t\t\tif op.Path == \"\" || op.Path == \"/\" { op.Path = \"/%s\" } else { op.Path = \"/%s\" + op.Path }\n", f.JSONName, f.JSONName) + b.WriteString("\t\t\tp.Operations = append(p.Operations, op)\n\t\t}\n") + if needsGuard { + b.WriteString("\t}\n") + } + } else if f.IsCollection && !f.Atomic { + if strings.HasPrefix(f.Type, "map[") { + vt := mapVal(f.Type) + ptrVal := isPtr(vt) + fmt.Fprintf(&b, "\tif other.%s != nil {\n", f.Name) + fmt.Fprintf(&b, "\t\tfor k, v := range other.%s {\n", f.Name) + fmt.Fprintf(&b, "\t\t\tif t.%s == nil {\n", f.Name) + fmt.Fprintf(&b, "\t\t\t\tp.Operations = append(p.Operations, %sOperation{Kind: %sOpReplace, Path: fmt.Sprintf(\"/%s/%%v\", k), New: v})\n", p, p, f.JSONName) + b.WriteString("\t\t\t\tcontinue\n\t\t\t}\n") + fmt.Fprintf(&b, "\t\t\tif oldV, ok := t.%s[k]; !ok || ", f.Name) + if ptrVal { + b.WriteString("!oldV.Equal(v) {\n") + } else { + b.WriteString("v != oldV {\n") + } + fmt.Fprintf(&b, "\t\t\t\tkind := %sOpReplace\n\t\t\t\tif !ok { kind = %sOpAdd }\n", p, p) + fmt.Fprintf(&b, "\t\t\t\tp.Operations = append(p.Operations, %sOperation{Kind: kind, Path: fmt.Sprintf(\"/%s/%%v\", k), Old: oldV, New: v})\n", p, f.JSONName) + b.WriteString("\t\t\t}\n\t\t}\n\t}\n") + fmt.Fprintf(&b, "\tif t.%s != nil {\n", f.Name) + fmt.Fprintf(&b, "\t\tfor k, v := range t.%s {\n", f.Name) + fmt.Fprintf(&b, "\t\t\tif other.%s == nil || !contains(other.%s, k) {\n", f.Name, f.Name) + fmt.Fprintf(&b, "\t\t\t\tp.Operations = append(p.Operations, %sOperation{Kind: %sOpRemove, Path: fmt.Sprintf(\"/%s/%%v\", k), Old: v})\n", p, p, f.JSONName) + b.WriteString("\t\t\t}\n\t\t}\n\t}\n") + } else { + // Slice + elemType := sliceElem(f.Type) + keyField := typeKeys[elemType] + if keyField != "" { + // Keyed slice diff + fmt.Fprintf(&b, "\totherByKey := make(map[any]int)\n") + fmt.Fprintf(&b, "\tfor i, v := range other.%s { otherByKey[v.%s] = i }\n", f.Name, keyField) + fmt.Fprintf(&b, "\tfor _, v := range t.%s {\n", f.Name) + fmt.Fprintf(&b, "\t\tif _, ok := otherByKey[v.%s]; !ok {\n", keyField) + fmt.Fprintf(&b, "\t\t\tp.Operations = append(p.Operations, %sOperation{Kind: %sOpRemove, Path: fmt.Sprintf(\"/%s/%%v\", v.%s), Old: v})\n", p, p, f.JSONName, keyField) + b.WriteString("\t\t}\n\t}\n") + fmt.Fprintf(&b, "\ttByKey := make(map[any]int)\n") + fmt.Fprintf(&b, "\tfor i, v := range t.%s { tByKey[v.%s] = i }\n", f.Name, keyField) + fmt.Fprintf(&b, "\tfor _, v := range other.%s {\n", f.Name) + fmt.Fprintf(&b, "\t\tif _, ok := tByKey[v.%s]; !ok {\n", keyField) + fmt.Fprintf(&b, "\t\t\tp.Operations = append(p.Operations, %sOperation{Kind: %sOpAdd, Path: fmt.Sprintf(\"/%s/%%v\", v.%s), New: v})\n", p, p, f.JSONName, keyField) + b.WriteString("\t\t}\n\t}\n") + } else { + fmt.Fprintf(&b, "\tif len(t.%s) != len(other.%s) {\n", f.Name, f.Name) + fmt.Fprintf(&b, "\t\tp.Operations = append(p.Operations, %sOperation{Kind: %sOpReplace, Path: \"/%s\", Old: t.%s, New: other.%s})\n", p, p, f.JSONName, f.Name, f.Name) + b.WriteString("\t} else {\n") + fmt.Fprintf(&b, "\t\tfor i := range t.%s {\n", f.Name) + fmt.Fprintf(&b, "\t\t\tif t.%s[i] != other.%s[i] {\n", f.Name, f.Name) + fmt.Fprintf(&b, "\t\t\t\tp.Operations = append(p.Operations, %sOperation{Kind: %sOpReplace, Path: fmt.Sprintf(\"/%s/%%d\", i), Old: t.%s[i], New: other.%s[i]})\n", p, p, f.JSONName, f.Name, f.Name) + b.WriteString("\t\t\t}\n\t\t}\n\t}\n") + } + } + } else { + fmt.Fprintf(&b, "\tif t.%s != other.%s {\n", f.Name, f.Name) + fmt.Fprintf(&b, "\t\tp.Operations = append(p.Operations, %sOperation{Kind: %sOpReplace, Path: \"/%s\", Old: t.%s, New: other.%s})\n", p, p, f.JSONName, f.Name, f.Name) + b.WriteString("\t}\n") + } + return b.String() +} + +// evalCondCase returns the case body for EvaluateCondition's path switch. +func evalCondCase(f FieldInfo, pkgPrefix string) string { + var b strings.Builder + n, typ := f.Name, f.Type + + b.WriteString("\t\tif c.Op == \"exists\" { return true, nil }\n") + fmt.Fprintf(&b, "\t\tif c.Op == \"type\" { return condition.CheckType(t.%s, c.Value.(string)), nil }\n", n) + fmt.Fprintf(&b, "\t\tif c.Op == \"matches\" { return regexp.MatchString(c.Value.(string), fmt.Sprintf(\"%%v\", t.%s)) }\n", n) + + switch { + case isNumericType(typ): + b.WriteString("\t\tvar _cv float64\n") + b.WriteString("\t\tswitch v := c.Value.(type) {\n") + fmt.Fprintf(&b, "\t\tcase %s: _cv = float64(v)\n", typ) + if typ != "float64" { + b.WriteString("\t\tcase float64: _cv = v\n") + } + if typ != "int" { + b.WriteString("\t\tcase int: _cv = float64(v)\n") + } + fmt.Fprintf(&b, "\t\tdefault: return false, fmt.Errorf(\"condition value type mismatch for field %s\")\n", n) + b.WriteString("\t\t}\n") + fmt.Fprintf(&b, "\t\t_fv := float64(t.%s)\n", n) + b.WriteString("\t\tswitch c.Op {\n") + b.WriteString("\t\tcase \"==\": return _fv == _cv, nil\n") + b.WriteString("\t\tcase \"!=\": return _fv != _cv, nil\n") + b.WriteString("\t\tcase \">\": return _fv > _cv, nil\n") + b.WriteString("\t\tcase \"<\": return _fv < _cv, nil\n") + b.WriteString("\t\tcase \">=\": return _fv >= _cv, nil\n") + b.WriteString("\t\tcase \"<=\": return _fv <= _cv, nil\n") + b.WriteString("\t\tcase \"in\":\n") + fmt.Fprintf(&b, "\t\t\tswitch vals := c.Value.(type) {\n\t\t\tcase []%s:\n\t\t\t\tfor _, v := range vals { if t.%s == v { return true, nil } }\n", typ, n) + b.WriteString("\t\t\tcase []any:\n\t\t\t\tfor _, v := range vals {\n\t\t\t\t\tswitch iv := v.(type) {\n") + fmt.Fprintf(&b, "\t\t\t\t\tcase %s: if t.%s == iv { return true, nil }\n", typ, n) + if typ != "float64" { + fmt.Fprintf(&b, "\t\t\t\t\tcase float64: if float64(t.%s) == iv { return true, nil }\n", n) + } + if typ != "int" { + fmt.Fprintf(&b, "\t\t\t\t\tcase int: if float64(t.%s) == float64(iv) { return true, nil }\n", n) + } + b.WriteString("\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false, nil\n\t\t}\n") + + case typ == "string": + fmt.Fprintf(&b, "\t\t_sv, _ok := c.Value.(string)\n") + fmt.Fprintf(&b, "\t\tif !_ok { return false, fmt.Errorf(\"condition value type mismatch for field %s\") }\n", n) + b.WriteString("\t\tswitch c.Op {\n") + fmt.Fprintf(&b, "\t\tcase \"==\": return t.%s == _sv, nil\n", n) + fmt.Fprintf(&b, "\t\tcase \"!=\": return t.%s != _sv, nil\n", n) + fmt.Fprintf(&b, "\t\tcase \">\": return t.%s > _sv, nil\n", n) + fmt.Fprintf(&b, "\t\tcase \"<\": return t.%s < _sv, nil\n", n) + fmt.Fprintf(&b, "\t\tcase \">=\": return t.%s >= _sv, nil\n", n) + fmt.Fprintf(&b, "\t\tcase \"<=\": return t.%s <= _sv, nil\n", n) + b.WriteString("\t\tcase \"in\":\n") + fmt.Fprintf(&b, "\t\t\tswitch vals := c.Value.(type) {\n\t\t\tcase []string:\n\t\t\t\tfor _, v := range vals { if t.%s == v { return true, nil } }\n", n) + fmt.Fprintf(&b, "\t\t\tcase []any:\n\t\t\t\tfor _, v := range vals { if sv, ok := v.(string); ok && t.%s == sv { return true, nil } }\n", n) + b.WriteString("\t\t\t}\n\t\t\treturn false, nil\n\t\t}\n") + + case typ == "bool": + fmt.Fprintf(&b, "\t\t_bv, _ok := c.Value.(bool)\n") + fmt.Fprintf(&b, "\t\tif !_ok { return false, fmt.Errorf(\"condition value type mismatch for field %s\") }\n", n) + b.WriteString("\t\tswitch c.Op {\n") + fmt.Fprintf(&b, "\t\tcase \"==\": return t.%s == _bv, nil\n", n) + fmt.Fprintf(&b, "\t\tcase \"!=\": return t.%s != _bv, nil\n", n) + b.WriteString("\t\t}\n") + + default: + b.WriteString("\t\tswitch c.Op {\n") + fmt.Fprintf(&b, "\t\tcase \"==\": return fmt.Sprintf(\"%%v\", t.%s) == fmt.Sprintf(\"%%v\", c.Value), nil\n", n) + fmt.Fprintf(&b, "\t\tcase \"!=\": return fmt.Sprintf(\"%%v\", t.%s) != fmt.Sprintf(\"%%v\", c.Value), nil\n", n) + b.WriteString("\t\t}\n") + } + return b.String() +} + +// equalFieldCode returns the equality check fragment for one field. +func equalFieldCode(f FieldInfo) string { + var b strings.Builder + self := "(&t." + f.Name + ")" + other := "(&other." + f.Name + ")" + if isPtr(f.Type) { + self = "t." + f.Name + other = "other." + f.Name + } + switch { + case f.IsStruct: + if isPtr(f.Type) { + fmt.Fprintf(&b, "\tif (%s == nil) != (%s == nil) { return false }\n", self, other) + fmt.Fprintf(&b, "\tif %s != nil && !%s.Equal(%s) { return false }\n", self, self, other) + } else { + fmt.Fprintf(&b, "\tif !%s.Equal(%s) { return false }\n", self, other) + } + case f.IsText: + fmt.Fprintf(&b, "\tif len(t.%s) != len(other.%s) { return false }\n", f.Name, f.Name) + fmt.Fprintf(&b, "\tfor i := range t.%s { if t.%s[i] != other.%s[i] { return false } }\n", f.Name, f.Name, f.Name) + case f.IsCollection: + fmt.Fprintf(&b, "\tif len(t.%s) != len(other.%s) { return false }\n", f.Name, f.Name) + if strings.HasPrefix(f.Type, "[]") { + et := sliceElem(f.Type) + ptrElem := isPtr(et) + fmt.Fprintf(&b, "\tfor i := range t.%s {\n", f.Name) + if ptrElem { + fmt.Fprintf(&b, "\t\tif (t.%s[i] == nil) != (other.%s[i] == nil) { return false }\n", f.Name, f.Name) + fmt.Fprintf(&b, "\t\tif t.%s[i] != nil && !t.%s[i].Equal(other.%s[i]) { return false }\n", f.Name, f.Name, f.Name) + } else if f.IsStruct { + fmt.Fprintf(&b, "\t\tif !t.%s[i].Equal(&other.%s[i]) { return false }\n", f.Name, f.Name) + } else { + fmt.Fprintf(&b, "\t\tif t.%s[i] != other.%s[i] { return false }\n", f.Name, f.Name) + } + b.WriteString("\t}\n") + } else if strings.HasPrefix(f.Type, "map[") { + vt := mapVal(f.Type) + ptrVal := isPtr(vt) + fmt.Fprintf(&b, "\tfor k, v := range t.%s {\n", f.Name) + fmt.Fprintf(&b, "\t\tvOther, ok := other.%s[k]\n", f.Name) + b.WriteString("\t\tif !ok { return false }\n") + if ptrVal { + b.WriteString("\t\tif (v == nil) != (vOther == nil) { return false }\n") + b.WriteString("\t\tif v != nil && !v.Equal(vOther) { return false }\n") + } else if f.IsStruct { + b.WriteString("\t\tif !v.Equal(&vOther) { return false }\n") + } else { + b.WriteString("\t\tif v != vOther { return false }\n") + } + b.WriteString("\t}\n") + } + default: + fmt.Fprintf(&b, "\tif t.%s != other.%s { return false }\n", f.Name, f.Name) + } + return b.String() +} + +// copyFieldInit returns the struct-literal initialiser fragment for one field (inside `res := &T{...}`). +func copyFieldInit(f FieldInfo) string { + switch { + case f.IsStruct: + return "" // handled in post-init phase + case f.IsText: + return fmt.Sprintf("\t\t%s: append(%s(nil), t.%s...),\n", f.Name, f.Type, f.Name) + case f.IsCollection && strings.HasPrefix(f.Type, "[]"): + et := sliceElem(f.Type) + if isPtr(et) || f.IsStruct { + return fmt.Sprintf("\t\t%s: make(%s, len(t.%s)),\n", f.Name, f.Type, f.Name) + } + return fmt.Sprintf("\t\t%s: append(%s(nil), t.%s...),\n", f.Name, f.Type, f.Name) + case f.IsCollection: + return "" // map — handled in post-init phase + default: + return fmt.Sprintf("\t\t%s: t.%s,\n", f.Name, f.Name) + } +} + +// copyFieldPost returns post-init deep-copy code for one field. +func copyFieldPost(f FieldInfo) string { + var b strings.Builder + if f.Ignore { + return "" + } + if f.IsStruct { + self := "(&t." + f.Name + ")" + if isPtr(f.Type) { + self = "t." + f.Name + fmt.Fprintf(&b, "\tif %s != nil { res.%s = %s.Clone() }\n", self, f.Name, self) + } else { + fmt.Fprintf(&b, "\tres.%s = *%s.Clone()\n", f.Name, self) + } + } + if f.IsCollection { + if strings.HasPrefix(f.Type, "[]") { + et := sliceElem(f.Type) + if isPtr(et) { + fmt.Fprintf(&b, "\tfor i, v := range t.%s { if v != nil { res.%s[i] = v.Clone() } }\n", f.Name, f.Name) + } else if f.IsStruct { + fmt.Fprintf(&b, "\tfor i := range t.%s { res.%s[i] = *t.%s[i].Clone() }\n", f.Name, f.Name, f.Name) + } + } else if strings.HasPrefix(f.Type, "map[") { + vt := mapVal(f.Type) + fmt.Fprintf(&b, "\tif t.%s != nil {\n\t\tres.%s = make(%s)\n", f.Name, f.Name, f.Type) + fmt.Fprintf(&b, "\t\tfor k, v := range t.%s {\n", f.Name) + if isPtr(vt) { + fmt.Fprintf(&b, "\t\t\tif v != nil { res.%s[k] = v.Clone() }\n", f.Name) + } else if f.IsStruct { + fmt.Fprintf(&b, "\t\t\tres.%s[k] = *v.Clone()\n", f.Name) + } else { + fmt.Fprintf(&b, "\t\t\tres.%s[k] = v\n", f.Name) + } + b.WriteString("\t\t}\n\t}\n") + } + } + return b.String() +} + +// ── templates ──────────────────────────────────────────────────────────────── + +var tmplFuncs = template.FuncMap{ + "fieldApplyCase": fieldApplyCase, + "delegateCase": delegateCase, + "diffFieldCode": diffFieldCode, + "evalCondCase": evalCondCase, + "equalFieldCode": equalFieldCode, + "copyFieldInit": copyFieldInit, + "copyFieldPost": copyFieldPost, + "not": func(b bool) bool { return !b }, +} + +var headerTmpl = template.Must(template.New("header").Funcs(tmplFuncs).Parse( + `// Code generated by deep-gen. DO NOT EDIT. +package {{.PkgName}} + +import ( + "fmt" + "log/slog" +{{- if .NeedsRegexp}} + "regexp" +{{- end}} +{{- if .NeedsStrings}} + "strings" +{{- end}} +{{- if .NeedsCondition}} + "github.com/brunoga/deep/v5/condition" +{{- end}} +{{- if .NeedsDeep}} + deep "github.com/brunoga/deep/v5" + _deepengine "github.com/brunoga/deep/v5/internal/engine" +{{- end}} +{{- if .NeedsCrdt}} + crdt "github.com/brunoga/deep/v5/crdt" +{{- end}} +) +`)) + +var patchTmpl = template.Must(template.New("patch").Funcs(tmplFuncs).Parse( + `// Patch applies p to t using the generated fast path. +func (t *{{.TypeName}}) Patch(p {{.P}}Patch[{{.TypeName}}], logger *slog.Logger) error { + if logger == nil { logger = slog.Default() } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &{{.P}}ApplyError{Errors: errs} + } + return nil +} + +`)) + +var applyOpTmpl = template.Must(template.New("applyOp").Funcs(tmplFuncs).Parse( + `func (t *{{.TypeName}}) applyOperation(op {{.P}}Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { return true, nil } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { return true, nil } + } + if op.Kind == {{.P}}OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == {{.P}}OpReplace || op.Kind == {{.P}}OpRemove) { + if !{{.P}}Equal(*t, op.Old.({{.TypeName}})) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == {{.P}}OpReplace { + if v, ok := op.New.({{.TypeName}}); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) +{{range .Fields}}{{if not .Ignore}}{{fieldApplyCase . $.P}}{{end}}{{end -}} + default: +{{range .Fields}}{{delegateCase . $.P}}{{end -}} + } + return false, nil +} + +`)) + +var diffTmpl = template.Must(template.New("diff").Funcs(tmplFuncs).Parse( + `// Diff compares t with other and returns a Patch. +func (t *{{.TypeName}}) Diff(other *{{.TypeName}}) {{.P}}Patch[{{.TypeName}}] { + p := {{.P}}Patch[{{.TypeName}}]{} +{{range .Fields}}{{diffFieldCode . $.P $.TypeKeys}}{{end}} + return p +} + +`)) + +var evalCondTmpl = template.Must(template.New("evalCond").Funcs(tmplFuncs).Parse( + `func (t *{{.TypeName}}) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { return false, err } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { return true, nil } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { return false, err } + return !ok, nil + } + return true, nil + } + + switch c.Path { +{{range .Fields}}{{if and (not .Ignore) (not .IsStruct) (not .IsCollection) (not .IsText) -}} + {{if ne .JSONName .Name}}case "/{{.JSONName}}", "/{{.Name}}":{{else}}case "/{{.Name}}":{{end}} +{{evalCondCase . $.P}}{{end}}{{end -}} + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +`)) + +var equalTmpl = template.Must(template.New("equal").Funcs(tmplFuncs).Parse( + `// Equal returns true if t and other are deeply equal. +func (t *{{.TypeName}}) Equal(other *{{.TypeName}}) bool { +{{range .Fields}}{{if not .Ignore}}{{equalFieldCode .}}{{end}}{{end -}} + return true +} + +`)) + +var copyTmpl = template.Must(template.New("copy").Funcs(tmplFuncs).Parse( + `// Clone returns a deep copy of t. +func (t *{{.TypeName}}) Clone() *{{.TypeName}} { + res := &{{.TypeName}}{ +{{range .Fields}}{{if not .Ignore}}{{copyFieldInit .}}{{end}}{{end -}} + } +{{range .Fields}}{{if not .Ignore}}{{copyFieldPost .}}{{end}}{{end -}} + return res +} +`)) + +var helpersTmpl = template.Must(template.New("helpers").Funcs(tmplFuncs).Parse( + ` +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} +`)) + +// ── generator ──────────────────────────────────────────────────────────────── + +func (g *Generator) writeHeader(allFields []FieldInfo) { + needsStrings, needsRegexp, needsCrdt := false, false, false + for _, f := range allFields { + if f.Ignore { + continue + } + if (f.IsStruct && !f.Atomic) || (f.IsCollection && isMapStringKey(f.Type)) { + needsStrings = true + } + if !f.IsStruct && !f.IsCollection && !f.IsText { + needsRegexp = true + } + if f.IsText { + needsCrdt = true + } + } + must(headerTmpl.Execute(&g.buf, headerData{ + PkgName: g.pkgName, + NeedsRegexp: needsRegexp, + NeedsStrings: needsStrings, + NeedsCondition: true, + NeedsDeep: g.pkgName != "deep", + NeedsCrdt: needsCrdt && g.pkgName != "deep", + })) +} + +func (g *Generator) writeType(typeName string, fields []FieldInfo) { + if g.pkgName != "deep" { + g.pkgPrefix = "deep." + } + d := typeData{TypeName: typeName, P: g.pkgPrefix, Fields: fields, TypeKeys: g.typeKeys} + must(patchTmpl.Execute(&g.buf, d)) + must(applyOpTmpl.Execute(&g.buf, d)) + must(diffTmpl.Execute(&g.buf, d)) + must(evalCondTmpl.Execute(&g.buf, d)) + must(equalTmpl.Execute(&g.buf, d)) + must(copyTmpl.Execute(&g.buf, d)) +} + +func (g *Generator) writeHelpers() { + if g.pkgName == "deep" { + return + } + must(helpersTmpl.Execute(&g.buf, nil)) +} + +func must(err error) { + if err != nil { + log.Fatalf("template error: %v", err) + } +} + +// ── AST parsing ────────────────────────────────────────────────────────────── + +func main() { + flag.Parse() + if len(*typeNames) == 0 { + log.Fatal("type flag required") + } + + dir := "." + if len(flag.Args()) > 0 { + dir = flag.Args()[0] + } + + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, dir, nil, 0) + if err != nil { + log.Fatal(err) + } + + var g *Generator + + for pkgName, pkg := range pkgs { + if strings.HasSuffix(pkgName, "_test") { + continue + } + if g == nil { + g = &Generator{pkgName: pkgName, typeKeys: make(map[string]string)} + } + + requested := make(map[string]bool) + for _, t := range strings.Split(*typeNames, ",") { + requested[strings.TrimSpace(t)] = true + } + + // Pass 1: collect deep:"key" field names. + for _, file := range pkg.Files { + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return true + } + for _, field := range st.Fields.List { + if field.Tag == nil || len(field.Names) == 0 { + continue + } + tag := strings.Trim(field.Tag.Value, "`") + if strings.Contains(tag, "deep:\"key\"") { + g.typeKeys[ts.Name.Name] = field.Names[0].Name + } + } + return true + }) + } + + // Pass 2: collect field info for requested types. + var allTypes []string + var allFields [][]FieldInfo + + for _, file := range pkg.Files { + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok || !requested[ts.Name.Name] { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return true + } + fields := parseFields(st) + allTypes = append(allTypes, ts.Name.Name) + allFields = append(allFields, fields) + return false + }) + } + + if len(allTypes) == 0 { + continue + } + + var combined []FieldInfo + for _, fs := range allFields { + combined = append(combined, fs...) + } + g.writeHeader(combined) + for i := range allTypes { + g.writeType(allTypes[i], allFields[i]) + } + g.writeHelpers() + } + + if g == nil { + return + } + + src, err := format.Source(g.buf.Bytes()) + if err != nil { + log.Printf("warning: gofmt failed: %v", err) + src = g.buf.Bytes() + } + + // Determine output file: explicit -output flag, or default to + // "{first_type_lowercase}_deep.go" in the target directory (like stringer). + outFile := *outputFile + if outFile == "" { + firstName := strings.ToLower(strings.SplitN(*typeNames, ",", 2)[0]) + outFile = filepath.Join(dir, firstName+"_deep.go") + } + if err := os.WriteFile(outFile, src, 0644); err != nil { + log.Fatalf("writing output: %v", err) + } + log.Printf("deep-gen: wrote %s", outFile) +} + +func parseFields(st *ast.StructType) []FieldInfo { + var fields []FieldInfo + for _, field := range st.Fields.List { + if len(field.Names) == 0 { + continue // embedded field + } + var ignore, readOnly, atomic bool + // Tags apply to all names in the declaration (e.g. `X, Y int \`json:"x"\`` + // is unusual but syntactically valid; we honour the tag for every name). + if field.Tag != nil { + tagVal := strings.Trim(field.Tag.Value, "`") + tag := reflect.StructTag(tagVal) + // json:"-" marks the whole field as ignored + if jt := tag.Get("json"); strings.Split(jt, ",")[0] == "-" { + ignore = true + } + for _, p := range strings.Split(tag.Get("deep"), ",") { + switch strings.TrimSpace(p) { + case "-": + ignore = true + case "readonly": + readOnly = true + case "atomic": + atomic = true + } + } + } + + typeName, isStruct, isCollection, isText := resolveType(field.Type) + for _, nameIdent := range field.Names { + name := nameIdent.Name + jsonName := name + if field.Tag != nil { + tagVal := strings.Trim(field.Tag.Value, "`") + tag := reflect.StructTag(tagVal) + if jt := tag.Get("json"); jt != "" { + if part := strings.Split(jt, ",")[0]; part != "" && part != "-" { + jsonName = part + } + } + } + fields = append(fields, FieldInfo{ + Name: name, + JSONName: jsonName, + Type: typeName, + IsStruct: isStruct, + IsCollection: isCollection, + IsText: isText, + Ignore: ignore, + ReadOnly: readOnly, + Atomic: atomic, + }) + } + } + return fields +} + +func resolveType(expr ast.Expr) (typeName string, isStruct, isCollection, isText bool) { + switch typ := expr.(type) { + case *ast.Ident: + typeName = typ.Name + if typeName == "Text" { + isText = true + typeName = "crdt.Text" + } else if len(typeName) > 0 && typeName[0] >= 'A' && typeName[0] <= 'Z' { + switch typeName { + case "String", "Int", "Bool", "Float64": + default: + isStruct = true + } + } + case *ast.StarExpr: + if ident, ok := typ.X.(*ast.Ident); ok { + typeName = "*" + ident.Name + isStruct = true + } + case *ast.SelectorExpr: + if ident, ok := typ.X.(*ast.Ident); ok { + if typ.Sel.Name == "Text" { + isText = true + typeName = "crdt.Text" + } else if ident.Name == "deep" { + typeName = "deep." + typ.Sel.Name + } else { + typeName = ident.Name + "." + typ.Sel.Name + } + } + case *ast.ArrayType: + isCollection = true + switch elt := typ.Elt.(type) { + case *ast.Ident: + typeName = "[]" + elt.Name + case *ast.StarExpr: + if ident, ok := elt.X.(*ast.Ident); ok { + typeName = "[]*" + ident.Name + } + default: + typeName = "[]any" + } + case *ast.MapType: + isCollection = true + keyName, valName := "any", "any" + if ident, ok := typ.Key.(*ast.Ident); ok { + keyName = ident.Name + } + switch vtyp := typ.Value.(type) { + case *ast.Ident: + valName = vtyp.Name + case *ast.StarExpr: + if ident, ok := vtyp.X.(*ast.Ident); ok { + valName = "*" + ident.Name + } + } + typeName = fmt.Sprintf("map[%s]%s", keyName, valName) + } + return +} diff --git a/cmd/deep-gen/main_test.go b/cmd/deep-gen/main_test.go new file mode 100644 index 0000000..e3b67c9 --- /dev/null +++ b/cmd/deep-gen/main_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestGeneratorOutput runs deep-gen on the internal/testmodels package and +// compares the output against the checked-in golden file. +func TestGeneratorOutput(t *testing.T) { + // Build the generator binary. + tmpDir := t.TempDir() + genBin := filepath.Join(tmpDir, "deep-gen") + buildCmd := exec.Command("go", "build", "-o", genBin, ".") + buildCmd.Dir = "." + if out, err := buildCmd.CombinedOutput(); err != nil { + t.Fatalf("build deep-gen: %v\n%s", err, out) + } + + // Run generator on testmodels. + outFile := filepath.Join(tmpDir, "user_deep.go") + runCmd := exec.Command(genBin, "-type=User,Detail", "-output", outFile, "../../internal/testmodels") + if out, err := runCmd.CombinedOutput(); err != nil { + t.Fatalf("run deep-gen: %v\n%s", err, out) + } + + got, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("read output: %v", err) + } + + golden, err := os.ReadFile("../../internal/testmodels/user_deep.go") + if err != nil { + t.Fatalf("read golden: %v", err) + } + + gotStr := strings.TrimSpace(string(got)) + goldenStr := strings.TrimSpace(string(golden)) + if gotStr != goldenStr { + t.Errorf("generator output does not match golden file\nwant:\n%s\n\ngot:\n%s", goldenStr, gotStr) + } +} diff --git a/cond/condition.go b/cond/condition.go deleted file mode 100644 index 6dfcc67..0000000 --- a/cond/condition.go +++ /dev/null @@ -1,341 +0,0 @@ -package cond - -import ( - "encoding/json" - - "github.com/brunoga/deep/v4/internal/core" -) - -// Condition represents a logical check against a value of type T. -type Condition[T any] interface { - // Evaluate evaluates the condition against the given value. - Evaluate(v *T) (bool, error) - - // MarshalJSON returns the JSON representation of the condition. - MarshalJSON() ([]byte, error) - - // MarshalSerializable returns a serializable representation of the condition. - MarshalSerializable() (any, error) - - InternalCondition -} - -// InternalCondition is an internal interface for efficient evaluation without reflection. -type InternalCondition interface { - EvaluateAny(v any) (bool, error) - Paths() []string - WithRelativePath(prefix string) InternalCondition -} - -// typedCondition wraps a InternalCondition to satisfy Condition[T]. -type typedCondition[T any] struct { - inner InternalCondition -} - -func (c *typedCondition[T]) Evaluate(v *T) (bool, error) { - return c.inner.EvaluateAny(v) -} - -func (c *typedCondition[T]) EvaluateAny(v any) (bool, error) { - return c.inner.EvaluateAny(v) -} - -func (c *typedCondition[T]) Paths() []string { - return c.inner.Paths() -} - -func (c *typedCondition[T]) WithRelativePath(prefix string) InternalCondition { - return c.inner.WithRelativePath(prefix) -} - -func (c *typedCondition[T]) MarshalJSON() ([]byte, error) { - s, err := MarshalConditionAny(c.inner) - if err != nil { - return nil, err - } - return json.Marshal(s) -} - -func (c *typedCondition[T]) MarshalSerializable() (any, error) { - return MarshalConditionAny(c.inner) -} - -func (c *typedCondition[T]) GobEncode() ([]byte, error) { - s, err := MarshalConditionAny(c.inner) - if err != nil { - return nil, err - } - return json.Marshal(s) -} - -func (c *typedCondition[T]) GobDecode(data []byte) error { - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return err - } - cond, err := UnmarshalConditionSurrogate[T](m) - if err != nil { - return err - } - if tc, ok := cond.(*typedCondition[T]); ok { - c.inner = tc.inner - } - return nil -} - -func (c *typedCondition[T]) unwrap() InternalCondition { - return c.inner -} - -// Eq returns a condition that checks if the value at the path is equal to the given value. -func Eq[T any](p string, val any) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareCondition{Path: core.DeepPath(p), Val: val, Op: "=="}, - } -} - -// EqFold returns a condition that checks if the value at the path is equal to the given value (case-insensitive). -func EqFold[T any](p string, val any) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareCondition{Path: core.DeepPath(p), Val: val, Op: "==", IgnoreCase: true}, - } -} - -// Ne returns a condition that checks if the value at the path is not equal to the given value. -func Ne[T any](p string, val any) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareCondition{Path: core.DeepPath(p), Val: val, Op: "!="}, - } -} - -// NeFold returns a condition that checks if the value at the path is not equal to the given value (case-insensitive). -func NeFold[T any](p string, val any) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareCondition{Path: core.DeepPath(p), Val: val, Op: "!=", IgnoreCase: true}, - } -} - -// Greater returns a condition that checks if the value at the path is greater than the given value. -func Greater[T any](p string, val any) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareCondition{Path: core.DeepPath(p), Val: val, Op: ">"}, - } -} - -// Less returns a condition that checks if the value at the path is less than the given value. -func Less[T any](p string, val any) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareCondition{Path: core.DeepPath(p), Val: val, Op: "<"}, - } -} - -// GreaterEqual returns a condition that checks if the value at the path is greater than or equal to the given value. -func GreaterEqual[T any](p string, val any) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareCondition{Path: core.DeepPath(p), Val: val, Op: ">="}, - } -} - -// LessEqual returns a condition that checks if the value at the path is less than or equal to the given value. -func LessEqual[T any](p string, val any) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareCondition{Path: core.DeepPath(p), Val: val, Op: "<="}, - } -} - -// Defined returns a condition that checks if the value at the path is defined. -func Defined[T any](p string) Condition[T] { - return &typedCondition[T]{ - inner: &rawDefinedCondition{Path: core.DeepPath(p)}, - } -} - -// Undefined returns a condition that checks if the value at the path is undefined. -func Undefined[T any](p string) Condition[T] { - return &typedCondition[T]{ - inner: &rawUndefinedCondition{Path: core.DeepPath(p)}, - } -} - -// Matches returns a condition that checks if the string value at the path matches the given regex pattern. -func Matches[T any](p, pattern string) Condition[T] { - return &typedCondition[T]{ - inner: &rawStringCondition{Path: core.DeepPath(p), Val: pattern, Op: "matches"}, - } -} - -// MatchesFold returns a condition that checks if the string value at the path matches the given regex pattern (case-insensitive). -func MatchesFold[T any](p, pattern string) Condition[T] { - return &typedCondition[T]{ - inner: &rawStringCondition{Path: core.DeepPath(p), Val: pattern, Op: "matches", IgnoreCase: true}, - } -} - -// StartsWith returns a condition that checks if the string value at the path starts with the given prefix. -func StartsWith[T any](p, val string) Condition[T] { - return &typedCondition[T]{ - inner: &rawStringCondition{Path: core.DeepPath(p), Val: val, Op: "starts"}, - } -} - -// StartsWithFold returns a condition that checks if the string value at the path starts with the given prefix (case-insensitive). -func StartsWithFold[T any](p, val string) Condition[T] { - return &typedCondition[T]{ - inner: &rawStringCondition{Path: core.DeepPath(p), Val: val, Op: "starts", IgnoreCase: true}, - } -} - -// EndsWith returns a condition that checks if the string value at the path ends with the given suffix. -func EndsWith[T any](p, val string) Condition[T] { - return &typedCondition[T]{ - inner: &rawStringCondition{Path: core.DeepPath(p), Val: val, Op: "ends"}, - } -} - -// EndsWithFold returns a condition that checks if the string value at the path ends with the given suffix (case-insensitive). -func EndsWithFold[T any](p, val string) Condition[T] { - return &typedCondition[T]{ - inner: &rawStringCondition{Path: core.DeepPath(p), Val: val, Op: "ends", IgnoreCase: true}, - } -} - -// Contains returns a condition that checks if the string value at the path contains the given substring. -func Contains[T any](p, val string) Condition[T] { - return &typedCondition[T]{ - inner: &rawStringCondition{Path: core.DeepPath(p), Val: val, Op: "contains"}, - } -} - -// ContainsFold returns a condition that checks if the string value at the path contains the given substring (case-insensitive). -func ContainsFold[T any](p, val string) Condition[T] { - return &typedCondition[T]{ - inner: &rawStringCondition{Path: core.DeepPath(p), Val: val, Op: "contains", IgnoreCase: true}, - } -} - -// In returns a condition that checks if the value at the path is one of the given values. -func In[T any](p string, values ...any) Condition[T] { - return &typedCondition[T]{ - inner: &rawInCondition{Path: core.DeepPath(p), Values: values}, - } -} - -// InFold returns a condition that checks if the value at the path is one of the given values (case-insensitive). -func InFold[T any](p string, values ...any) Condition[T] { - return &typedCondition[T]{ - inner: &rawInCondition{Path: core.DeepPath(p), Values: values, IgnoreCase: true}, - } -} - -// Type returns a condition that checks if the value at the path has the given type. -func Type[T any](p, typeName string) Condition[T] { - return &typedCondition[T]{ - inner: &rawTypeCondition{Path: core.DeepPath(p), TypeName: typeName}, - } -} - -// Log returns a condition that logs the given message during evaluation. -func Log[T any](message string) Condition[T] { - return &typedCondition[T]{ - inner: &rawLogCondition{Message: message}, - } -} - -// And returns a condition that represents a logical AND of multiple conditions. -func And[T any](conds ...Condition[T]) Condition[T] { - var innerConds []InternalCondition - for _, c := range conds { - if t, ok := c.(*typedCondition[T]); ok { - innerConds = append(innerConds, t.inner) - } else { - innerConds = append(innerConds, c) - } - } - return &typedCondition[T]{ - inner: &rawAndCondition{Conditions: innerConds}, - } -} - -// Or returns a condition that represents a logical OR of multiple conditions. -func Or[T any](conds ...Condition[T]) Condition[T] { - var innerConds []InternalCondition - for _, c := range conds { - if t, ok := c.(*typedCondition[T]); ok { - innerConds = append(innerConds, t.inner) - } else { - innerConds = append(innerConds, c) - } - } - return &typedCondition[T]{ - inner: &rawOrCondition{Conditions: innerConds}, - } -} - -// Not returns a condition that represents a logical NOT of a condition. -func Not[T any](c Condition[T]) Condition[T] { - var inner InternalCondition - if t, ok := c.(*typedCondition[T]); ok { - inner = t.inner - } else { - inner = c - } - return &typedCondition[T]{ - inner: &rawNotCondition{C: inner}, - } -} - -// EqualField returns a condition that checks if the value at path1 is equal to the value at path2. -func EqualField[T any](path1, path2 string) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareFieldCondition{Path1: core.DeepPath(path1), Path2: core.DeepPath(path2), Op: "=="}, - } -} - -// NotEqualField returns a condition that checks if the value at path1 is not equal to the value at path2. -func NotEqualField[T any](path1, path2 string) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareFieldCondition{Path1: core.DeepPath(path1), Path2: core.DeepPath(path2), Op: "!="}, - } -} - -// GreaterField returns a condition that checks if the value at path1 is greater than the value at path2. -func GreaterField[T any](path1, path2 string) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareFieldCondition{Path1: core.DeepPath(path1), Path2: core.DeepPath(path2), Op: ">"}, - } -} - -// LessField returns a condition that checks if the value at path1 is less than the value at path2. -func LessField[T any](path1, path2 string) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareFieldCondition{Path1: core.DeepPath(path1), Path2: core.DeepPath(path2), Op: "<"}, - } -} - -// GreaterEqualField returns a condition that checks if the value at path1 is greater than or equal to the value at path2. -func GreaterEqualField[T any](path1, path2 string) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareFieldCondition{Path1: core.DeepPath(path1), Path2: core.DeepPath(path2), Op: ">="}, - } -} - -// LessEqualField returns a condition that checks if the value at path1 is less than or equal to the value at path2. -func LessEqualField[T any](path1, path2 string) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareFieldCondition{Path1: core.DeepPath(path1), Path2: core.DeepPath(path2), Op: "<="}, - } -} - -// EqualFieldFold returns a condition that checks if the value at path1 is equal to the value at path2 (case-insensitive). -func EqualFieldFold[T any](path1, path2 string) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareFieldCondition{Path1: core.DeepPath(path1), Path2: core.DeepPath(path2), Op: "==", IgnoreCase: true}, - } -} - -// NotEqualFieldFold returns a condition that checks if the value at path1 is not equal to the value at path2 (case-insensitive). -func NotEqualFieldFold[T any](path1, path2 string) Condition[T] { - return &typedCondition[T]{ - inner: &rawCompareFieldCondition{Path1: core.DeepPath(path1), Path2: core.DeepPath(path2), Op: "!=", IgnoreCase: true}, - } -} diff --git a/cond/condition_impl.go b/cond/condition_impl.go deleted file mode 100644 index a8dc69f..0000000 --- a/cond/condition_impl.go +++ /dev/null @@ -1,354 +0,0 @@ -package cond - -import ( - "fmt" - "reflect" - "regexp" - "strings" - - "github.com/brunoga/deep/v4/internal/core" -) - -type rawDefinedCondition struct { - Path core.DeepPath -} - -func (c *rawDefinedCondition) EvaluateAny(v any) (bool, error) { - rv := core.ToReflectValue(v) - target, err := c.Path.Resolve(rv) - if err != nil { - return false, nil - } - return target.IsValid(), nil -} - -func (c *rawDefinedCondition) Paths() []string { return []string{string(c.Path)} } - -func (c *rawDefinedCondition) WithRelativePath(prefix string) InternalCondition { - // Re-parse internally - // pathParts := core.ParsePath(string(c.Path)) // Unused - prefixParts := core.ParsePath(prefix) - - newPath := c.Path.StripParts(prefixParts) - return &rawDefinedCondition{Path: newPath} -} - -type rawUndefinedCondition struct { - Path core.DeepPath -} - -func (c *rawUndefinedCondition) EvaluateAny(v any) (bool, error) { - rv := core.ToReflectValue(v) - target, err := c.Path.Resolve(rv) - if err != nil { - return true, nil - } - return !target.IsValid(), nil -} - -func (c *rawUndefinedCondition) Paths() []string { return []string{string(c.Path)} } - -func (c *rawUndefinedCondition) WithRelativePath(prefix string) InternalCondition { - // pathParts := core.ParsePath(string(c.Path)) // Unused - prefixParts := core.ParsePath(prefix) - newPath := c.Path.StripParts(prefixParts) - - return &rawUndefinedCondition{Path: newPath} -} - -type rawTypeCondition struct { - Path core.DeepPath - TypeName string -} - -func (c *rawTypeCondition) EvaluateAny(v any) (bool, error) { - rv := core.ToReflectValue(v) - target, err := c.Path.Resolve(rv) - if err != nil { - return c.TypeName == "undefined", nil - } - if !target.IsValid() { - return c.TypeName == "null" || c.TypeName == "undefined", nil - } - - switch c.TypeName { - case "string": - return target.Kind() == reflect.String, nil - case "number": - k := target.Kind() - return (k >= reflect.Int && k <= reflect.Int64) || - (k >= reflect.Uint && k <= reflect.Uintptr) || - (k == reflect.Float32 || k == reflect.Float64), nil - case "boolean": - return target.Kind() == reflect.Bool, nil - case "object": - return target.Kind() == reflect.Struct || target.Kind() == reflect.Map, nil - case "array": - return target.Kind() == reflect.Slice || target.Kind() == reflect.Array, nil - case "null": - k := target.Kind() - return (k == reflect.Pointer || k == reflect.Interface || k == reflect.Slice || k == reflect.Map) && target.IsNil(), nil - case "undefined": - return false, nil - default: - return false, fmt.Errorf("unknown type: %s", c.TypeName) - } -} - -func (c *rawTypeCondition) Paths() []string { return []string{string(c.Path)} } - -func (c *rawTypeCondition) WithRelativePath(prefix string) InternalCondition { - prefixParts := core.ParsePath(prefix) - return &rawTypeCondition{Path: c.Path.StripParts(prefixParts), TypeName: c.TypeName} -} - -type rawStringCondition struct { - Path core.DeepPath - Val string - Op string - IgnoreCase bool -} - -func (c *rawStringCondition) EvaluateAny(v any) (bool, error) { - rv := core.ToReflectValue(v) - target, err := c.Path.Resolve(rv) - if err != nil { - return false, nil - } - if !target.IsValid() || target.Kind() != reflect.String { - return false, nil - } - s := target.String() - val := c.Val - if c.IgnoreCase && c.Op != "matches" { - s = strings.ToLower(s) - val = strings.ToLower(val) - } - switch c.Op { - case "contains": - return strings.Contains(s, val), nil - case "starts": - return strings.HasPrefix(s, val), nil - case "ends": - return strings.HasSuffix(s, val), nil - case "matches": - pattern := val - if c.IgnoreCase { - pattern = "(?i)" + pattern - } - return regexp.MatchString(pattern, s) - } - return false, fmt.Errorf("unknown string operator: %s", c.Op) -} - -func (c *rawStringCondition) Paths() []string { return []string{string(c.Path)} } - -func (c *rawStringCondition) WithRelativePath(prefix string) InternalCondition { - prefixParts := core.ParsePath(prefix) - return &rawStringCondition{ - Path: c.Path.StripParts(prefixParts), - Val: c.Val, - Op: c.Op, - IgnoreCase: c.IgnoreCase, - } -} - -type rawInCondition struct { - Path core.DeepPath - Values []any - IgnoreCase bool -} - -func (c *rawInCondition) EvaluateAny(v any) (bool, error) { - rv := core.ToReflectValue(v) - target, err := c.Path.Resolve(rv) - if err != nil { - return false, nil - } - for _, val := range c.Values { - match, err := core.CompareValues(target, reflect.ValueOf(val), "==", c.IgnoreCase) - if err != nil { - return false, err - } - if match { - return true, nil - } - } - return false, nil -} - -func (c *rawInCondition) Paths() []string { return []string{string(c.Path)} } - -func (c *rawInCondition) WithRelativePath(prefix string) InternalCondition { - prefixParts := core.ParsePath(prefix) - return &rawInCondition{ - Path: c.Path.StripParts(prefixParts), - Values: c.Values, - IgnoreCase: c.IgnoreCase, - } -} - -type rawLogCondition struct { - Message string -} - -func (c *rawLogCondition) EvaluateAny(v any) (bool, error) { - fmt.Printf("DEEP LOG CONDITION: %s (value: %v)\n", c.Message, v) - return true, nil -} - -func (c *rawLogCondition) Paths() []string { return nil } - -func (c *rawLogCondition) WithRelativePath(prefix string) InternalCondition { - return c -} - -type rawCompareCondition struct { - Path core.DeepPath - Val any - Op string - IgnoreCase bool -} - -func (c *rawCompareCondition) EvaluateAny(v any) (bool, error) { - rv := core.ToReflectValue(v) - target, err := c.Path.Resolve(rv) - if err != nil { - return false, err - } - return core.CompareValues(target, reflect.ValueOf(c.Val), c.Op, c.IgnoreCase) -} - -func (c *rawCompareCondition) Paths() []string { - return []string{string(c.Path)} -} - -func (c *rawCompareCondition) WithRelativePath(prefix string) InternalCondition { - prefixParts := core.ParsePath(prefix) - return &rawCompareCondition{ - Path: c.Path.StripParts(prefixParts), - Val: c.Val, - Op: c.Op, - IgnoreCase: c.IgnoreCase, - } -} - -type rawCompareFieldCondition struct { - Path1 core.DeepPath - Path2 core.DeepPath - Op string - IgnoreCase bool -} - -func (c *rawCompareFieldCondition) EvaluateAny(v any) (bool, error) { - rv := core.ToReflectValue(v) - target1, err := c.Path1.Resolve(rv) - if err != nil { - return false, err - } - target2, err := c.Path2.Resolve(rv) - if err != nil { - return false, err - } - return core.CompareValues(target1, target2, c.Op, c.IgnoreCase) -} - -func (c *rawCompareFieldCondition) Paths() []string { - return []string{string(c.Path1), string(c.Path2)} -} - -func (c *rawCompareFieldCondition) WithRelativePath(prefix string) InternalCondition { - prefixParts := core.ParsePath(prefix) - return &rawCompareFieldCondition{ - Path1: c.Path1.StripParts(prefixParts), - Path2: c.Path2.StripParts(prefixParts), - Op: c.Op, - IgnoreCase: c.IgnoreCase, - } -} - -type rawAndCondition struct { - Conditions []InternalCondition -} - -func (c *rawAndCondition) EvaluateAny(v any) (bool, error) { - for _, sub := range c.Conditions { - ok, err := sub.EvaluateAny(v) - if err != nil { - return false, err - } - if !ok { - return false, nil - } - } - return true, nil -} - -func (c *rawAndCondition) Paths() []string { - var res []string - for _, sub := range c.Conditions { - res = append(res, sub.Paths()...) - } - return res -} - -func (c *rawAndCondition) WithRelativePath(prefix string) InternalCondition { - res := &rawAndCondition{Conditions: make([]InternalCondition, len(c.Conditions))} - for i, sub := range c.Conditions { - res.Conditions[i] = sub.WithRelativePath(prefix) - } - return res -} - -type rawOrCondition struct { - Conditions []InternalCondition -} - -func (c *rawOrCondition) EvaluateAny(v any) (bool, error) { - for _, sub := range c.Conditions { - ok, err := sub.EvaluateAny(v) - if err != nil { - return false, err - } - if ok { - return true, nil - } - } - return false, nil -} - -func (c *rawOrCondition) Paths() []string { - var res []string - for _, sub := range c.Conditions { - res = append(res, sub.Paths()...) - } - return res -} - -func (c *rawOrCondition) WithRelativePath(prefix string) InternalCondition { - res := &rawOrCondition{Conditions: make([]InternalCondition, len(c.Conditions))} - for i, sub := range c.Conditions { - res.Conditions[i] = sub.WithRelativePath(prefix) - } - return res -} - -type rawNotCondition struct { - C InternalCondition -} - -func (c *rawNotCondition) EvaluateAny(v any) (bool, error) { - ok, err := c.C.EvaluateAny(v) - if err != nil { - return false, err - } - return !ok, nil -} - -func (c *rawNotCondition) Paths() []string { - return c.C.Paths() -} - -func (c *rawNotCondition) WithRelativePath(prefix string) InternalCondition { - return &rawNotCondition{C: c.C.WithRelativePath(prefix)} -} diff --git a/cond/condition_impl_test.go b/cond/condition_impl_test.go deleted file mode 100644 index 3423dd1..0000000 --- a/cond/condition_impl_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package cond - -import ( - "testing" -) - -func TestApplyChecked_ExhaustiveConditions(t *testing.T) { - type Data struct { - V int - S string - B bool - } - tests := []struct { - name string - expr string - v Data - want bool - }{ - {"Equal", "V == 10", Data{V: 10}, true}, - {"NotEqual", "V != 10", Data{V: 5}, true}, - {"Greater", "V > 5", Data{V: 10}, true}, - {"GreaterFalse", "V > 10", Data{V: 10}, false}, - {"Less", "V < 10", Data{V: 5}, true}, - {"GreaterEqual", "V >= 10", Data{V: 10}, true}, - {"LessEqual", "V <= 10", Data{V: 10}, true}, - {"StringGreater", "S > 'a'", Data{S: "b"}, true}, - {"AndTrue", "V > 0 AND B == true", Data{V: 1, B: true}, true}, - {"AndFalse", "V > 0 AND B == false", Data{V: 1, B: true}, false}, - {"OrTrue", "V > 10 OR B == true", Data{V: 1, B: true}, true}, - {"NotTrue", "NOT (V == 0)", Data{V: 1}, true}, - {"Complex", "(V > 0 AND V < 10) OR V == 100", Data{V: 5}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cond, err := ParseCondition[Data](tt.expr) - if err != nil { - t.Fatalf("ParseCondition failed: %v", err) - } - got, err := cond.Evaluate(&tt.v) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if got != tt.want { - t.Errorf("Evaluate() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestFieldConditions(t *testing.T) { - type Data struct { - A int - B int - C int - S string - T string - } - tests := []struct { - name string - cond Condition[Data] - v Data - want bool - }{ - {"EqualField_True", EqualField[Data]("A", "B"), Data{A: 1, B: 1}, true}, - {"EqualField_False", EqualField[Data]("A", "B"), Data{A: 1, B: 2}, false}, - {"NotEqualField_True", NotEqualField[Data]("A", "C"), Data{A: 1, C: 2}, true}, - {"NotEqualField_False", NotEqualField[Data]("A", "B"), Data{A: 1, B: 1}, false}, - {"GreaterField_True", GreaterField[Data]("B", "A"), Data{A: 1, B: 2}, true}, - {"GreaterField_False", GreaterField[Data]("A", "B"), Data{A: 1, B: 2}, false}, - {"LessField_True", LessField[Data]("A", "C"), Data{A: 1, C: 2}, true}, - {"LessEqualField_True", LessEqualField[Data]("A", "B"), Data{A: 1, B: 1}, true}, - {"GreaterEqualField_True", GreaterEqualField[Data]("A", "B"), Data{A: 1, B: 1}, true}, - {"String_LessField", LessField[Data]("S", "T"), Data{S: "apple", T: "banana"}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.cond.Evaluate(&tt.v) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if got != tt.want { - t.Errorf("Evaluate() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestNewPredicates(t *testing.T) { - type Config struct { - Env string - } - type User struct { - Name string - Age int - Tags []string - Config *Config - Bio string - } - - u := &User{ - Name: "Alice", - Age: 30, - Tags: []string{"admin", "user"}, - Config: &Config{ - Env: "prod", - }, - Bio: "Software Engineer from Zurich", - } - - tests := []struct { - name string - cond Condition[User] - want bool - }{ - {"Defined_Name", Defined[User]("Name"), true}, - {"Defined_Age", Defined[User]("Age"), true}, - {"Defined_Missing", Defined[User]("Missing"), false}, - {"Undefined_Missing", Undefined[User]("Missing"), true}, - {"Undefined_Name", Undefined[User]("Name"), false}, - {"Type_Name_String", Type[User]("Name", "string"), true}, - {"Type_Age_Number", Type[User]("Age", "number"), true}, - {"Type_Tags_Array", Type[User]("Tags", "array"), true}, - {"Type_Config_Object", Type[User]("Config", "object"), true}, - {"Type_Bio_String", Type[User]("Bio", "string"), true}, - {"Contains_Bio", Contains[User]("Bio", "Zurich"), true}, - {"Contains_Bio_Fold", ContainsFold[User]("Bio", "zurich"), true}, - {"Contains_Bio_False", Contains[User]("Bio", "Berlin"), false}, - {"StartsWith_Bio", StartsWith[User]("Bio", "Software"), true}, - {"StartsWith_Bio_Fold", StartsWithFold[User]("Bio", "software"), true}, - {"EndsWith_Bio", EndsWith[User]("Bio", "Zurich"), true}, - {"EndsWith_Bio_Fold", EndsWithFold[User]("Bio", "zurich"), true}, - {"Matches_Bio", Matches[User]("Bio", ".*Engineer.*"), true}, - {"Matches_Bio_Fold", MatchesFold[User]("Bio", ".*engineer.*"), true}, - {"In_Age", In[User]("Age", 20, 30, 40), true}, - {"In_Age_False", In[User]("Age", 20, 25, 40), false}, - {"In_Bio_Fold", InFold[User]("Name", "ALICE", "BOB"), true}, - {"EqFold", EqFold[User]("Name", "alice"), true}, - {"NeFold", NeFold[User]("Name", "bob"), true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.cond.Evaluate(u) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if got != tt.want { - t.Errorf("Evaluate() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestCompareValues_Exhaustive(t *testing.T) { - type Data struct { - U uint - F float64 - S string - } - tests := []struct { - name string - expr string - v Data - want bool - }{ - {"U_>_5", "U > 5", Data{U: 10}, true}, - {"U_<_20", "U < 20", Data{U: 10}, true}, - {"U_>=_10", "U >= 10", Data{U: 10}, true}, - {"U_<=_10", "U <= 10", Data{U: 10}, true}, - {"F_>_3.0", "F > 3.0", Data{F: 3.14}, true}, - {"F_<_4.0", "F < 4.0", Data{F: 3.14}, true}, - {"S_>_'apple'", "S > 'apple'", Data{S: "banana"}, true}, - {"S_<_'cherry'", "S < 'cherry'", Data{S: "banana"}, true}, - {"S_==_'banana'", "S == 'banana'", Data{S: "banana"}, true}, - {"S_!=_'apple'", "S != 'apple'", Data{S: "banana"}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cond, err := ParseCondition[Data](tt.expr) - if err != nil { - t.Fatalf("ParseCondition failed: %v", err) - } - got, err := cond.Evaluate(&tt.v) - if err != nil { - t.Fatalf("Evaluate failed: %v", err) - } - if got != tt.want { - t.Errorf("Evaluate() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestCondition_Aliases(t *testing.T) { - type Data struct { I int; S string } - d := Data{I: 10, S: "FOO"} - - tests := []struct { - name string - cond Condition[Data] - want bool - }{ - {"Ne", Ne[Data]("I", 11), true}, - {"NeFold", NeFold[Data]("S", "bar"), true}, - {"GreaterEqual", GreaterEqual[Data]("I", 10), true}, - {"LessEqual", LessEqual[Data]("I", 10), true}, - {"EqualFieldFold", EqualFieldFold[Data]("S", "S"), true}, - } - - for _, tt := range tests { - got, _ := tt.cond.Evaluate(&d) - if got != tt.want { - t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) - } - } -} diff --git a/cond/condition_parser.go b/cond/condition_parser.go deleted file mode 100644 index 65e58ca..0000000 --- a/cond/condition_parser.go +++ /dev/null @@ -1,290 +0,0 @@ -package cond - -import ( - "fmt" - "strconv" - "strings" - - "github.com/brunoga/deep/v4/internal/core" -) - -// ParseCondition parses a string expression into a Condition[T] tree. -func ParseCondition[T any](expr string) (Condition[T], error) { - raw, err := parseRawCondition(expr) - if err != nil { - return nil, err - } - return &typedCondition[T]{inner: raw}, nil -} - -func parseRawCondition(expr string) (InternalCondition, error) { - p := &parser{lexer: newLexer(expr)} - p.next() - cond, err := p.parseExpr() - if err != nil { - return nil, err - } - if p.curr.kind != tokEOF { - return nil, fmt.Errorf("unexpected token: %v", p.curr.val) - } - return cond, nil -} - -type tokenKind int - -const ( - tokError tokenKind = iota - tokEOF - tokIdent - tokString - tokNumber - tokBool - tokEq - tokNeq - tokGt - tokLt - tokGte - tokLte - tokAnd - tokOr - tokNot - tokLParen - tokRParen -) - -type token struct { - kind tokenKind - val string -} - -type lexer struct { - input string - pos int -} - -func newLexer(input string) *lexer { - return &lexer{input: input} -} - -func (l *lexer) next() token { - l.skipWhitespace() - if l.pos >= len(l.input) { - return token{kind: tokEOF} - } - c := l.input[l.pos] - switch { - case c == '(': - l.pos++ - return token{kind: tokLParen, val: "("} - case c == ')': - l.pos++ - return token{kind: tokRParen, val: ")"} - case c == '=' && l.peek() == '=': - l.pos += 2 - return token{kind: tokEq, val: "=="} - case c == '!' && l.peek() == '=': - l.pos += 2 - return token{kind: tokNeq, val: "!="} - case c == '>' && l.peek() == '=': - l.pos += 2 - return token{kind: tokGte, val: ">="} - case c == '>': - l.pos++ - return token{kind: tokGt, val: ">"} - case c == '<' && l.peek() == '=': - l.pos += 2 - return token{kind: tokLte, val: "<="} - case c == '<': - l.pos++ - return token{kind: tokLt, val: "<"} - case c == '\'' || c == '"': - return l.lexString(c) - case isDigit(c): - return l.lexNumber() - case isAlpha(c) || c == '/': - return l.lexIdent() - } - return token{kind: tokError, val: string(c)} -} - -func (l *lexer) peek() byte { - if l.pos+1 < len(l.input) { - return l.input[l.pos+1] - } - return 0 -} - -func (l *lexer) skipWhitespace() { - for l.pos < len(l.input) && isWhitespace(l.input[l.pos]) { - l.pos++ - } -} - -func (l *lexer) lexString(quote byte) token { - l.pos++ - start := l.pos - for l.pos < len(l.input) && l.input[l.pos] != quote { - l.pos++ - } - val := l.input[start:l.pos] - if l.pos < len(l.input) { - l.pos++ - } - return token{kind: tokString, val: val} -} - -func (l *lexer) lexNumber() token { - start := l.pos - for l.pos < len(l.input) && (isDigit(l.input[l.pos]) || l.input[l.pos] == '.') { - l.pos++ - } - return token{kind: tokNumber, val: l.input[start:l.pos]} -} - -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] == ']' || l.input[l.pos] == '/' || l.input[l.pos] == '~') { - l.pos++ - } - val := l.input[start:l.pos] - upper := strings.ToUpper(val) - switch upper { - case "AND": - return token{kind: tokAnd, val: val} - case "OR": - return token{kind: tokOr, val: val} - case "NOT": - return token{kind: tokNot, val: val} - case "TRUE": - return token{kind: tokBool, val: "true"} - case "FALSE": - return token{kind: tokBool, val: "false"} - } - return token{kind: tokIdent, val: val} -} - -func isWhitespace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' } -func isDigit(c byte) bool { return c >= '0' && c <= '9' } -func isAlpha(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' } - -type parser struct { - lexer *lexer - curr token -} - -func (p *parser) next() { - p.curr = p.lexer.next() -} - -func (p *parser) parseExpr() (InternalCondition, error) { - return p.parseOr() -} - -func (p *parser) parseOr() (InternalCondition, error) { - left, err := p.parseAnd() - if err != nil { - return nil, err - } - for p.curr.kind == tokOr { - p.next() - right, err := p.parseAnd() - if err != nil { - return nil, err - } - left = &rawOrCondition{Conditions: []InternalCondition{left, right}} - } - return left, nil -} - -func (p *parser) parseAnd() (InternalCondition, error) { - left, err := p.parseFactor() - if err != nil { - return nil, err - } - for p.curr.kind == tokAnd { - p.next() - right, err := p.parseFactor() - if err != nil { - return nil, err - } - left = &rawAndCondition{Conditions: []InternalCondition{left, right}} - } - return left, nil -} - -func (p *parser) parseFactor() (InternalCondition, error) { - switch p.curr.kind { - case tokNot: - p.next() - cond, err := p.parseFactor() - if err != nil { - return nil, err - } - return &rawNotCondition{C: cond}, nil - case tokLParen: - p.next() - cond, err := p.parseExpr() - if err != nil { - return nil, err - } - if p.curr.kind != tokRParen { - return nil, fmt.Errorf("expected ')', got %v", p.curr.val) - } - p.next() - return cond, nil - case tokIdent: - return p.parseComparison() - } - return nil, fmt.Errorf("unexpected token: %v", p.curr.val) -} - -func (p *parser) parseComparison() (InternalCondition, error) { - condPath := p.curr.val - p.next() - opTok := p.curr - if opTok.kind < tokEq || opTok.kind > tokLte { - return nil, fmt.Errorf("expected comparison operator, got %v", opTok.val) - } - p.next() - valTok := p.curr - var val any - var isField bool - var fieldPath string - - switch valTok.kind { - case tokString: - val = valTok.val - case tokNumber: - if strings.Contains(valTok.val, ".") { - f, err := strconv.ParseFloat(valTok.val, 64) - if err != nil { - return nil, fmt.Errorf("invalid float literal: %s", valTok.val) - } - val = f - } else { - i, err := strconv.ParseInt(valTok.val, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid integer literal: %s", valTok.val) - } - val = int(i) - } - case tokBool: - val = (valTok.val == "true") - case tokIdent: - isField = true - fieldPath = valTok.val - default: - return nil, fmt.Errorf("expected value or field, got %v", valTok.val) - } - p.next() - - ops := map[tokenKind]string{ - tokEq: "==", tokNeq: "!=", tokGt: ">", tokLt: "<", tokGte: ">=", tokLte: "<=", - } - opStr := ops[opTok.kind] - - if isField { - return &rawCompareFieldCondition{Path1: core.DeepPath(condPath), Path2: core.DeepPath(fieldPath), Op: opStr}, nil - } - return &rawCompareCondition{Path: core.DeepPath(condPath), Val: val, Op: opStr}, nil -} diff --git a/cond/condition_parser_test.go b/cond/condition_parser_test.go deleted file mode 100644 index 10554e1..0000000 --- a/cond/condition_parser_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package cond - -import ( - "testing" -) - -func TestParseCondition(t *testing.T) { - type Data struct { - Name string - Level int - Active bool - Score float64 - Tags []string - } - tests := []string{ - "Name == 'Alice'", - "Level > 5", - "Active == true", - "Score >= 95.0", - "Tags[0] == 'admin'", - "(Level > 5 AND Active == true) OR Name == 'Bob'", - "NOT (Level < 5)", - "Level > 5 AND Level < 15", - "Level == 10 OR Level == 20", - "Name != 'Bob' AND (Score < 100.0 OR Level == 0)", - } - for _, tt := range tests { - t.Run(tt, func(t *testing.T) { - cond, err := ParseCondition[Data](tt) - if err != nil { - t.Fatalf("ParseCondition failed: %v", err) - } - if cond == nil { - t.Fatal("Expected non-nil condition") - } - }) - } -} - -func TestParseFieldCondition(t *testing.T) { - type Data struct { - A, B, C int - } - tests := []string{ - "A == B", - "A != C", - "C > A", - "A < C", - "A >= B", - "A <= B", - "A == C", - } - for _, tt := range tests { - t.Run(tt, func(t *testing.T) { - cond, err := ParseCondition[Data](tt) - if err != nil { - t.Fatalf("ParseCondition failed: %v", err) - } - if cond == nil { - t.Fatal("Expected non-nil condition") - } - }) - } -} diff --git a/cond/condition_serialization.go b/cond/condition_serialization.go deleted file mode 100644 index 768a387..0000000 --- a/cond/condition_serialization.go +++ /dev/null @@ -1,323 +0,0 @@ -package cond - -import ( - "encoding/gob" - "encoding/json" - "fmt" - "reflect" - "strings" - - "github.com/brunoga/deep/v4/internal/core" -) - -func init() { - gob.Register(&condSurrogate{}) -} - -type condSurrogate struct { - Kind string `json:"k" gob:"k"` - Data any `json:"d,omitempty" gob:"d,omitempty"` -} - -// ConditionToSerializable returns a serializable representation of the condition. -func ConditionToSerializable(c any) (any, error) { - if c == nil { - return nil, nil - } - - if t, ok := c.(interface{ unwrap() InternalCondition }); ok { - return ConditionToSerializable(t.unwrap()) - } - - return MarshalConditionAny(c) -} - -func MarshalCondition[T any](c Condition[T]) (any, error) { - if t, ok := c.(*typedCondition[T]); ok { - return MarshalConditionAny(t.inner) - } - return MarshalConditionAny(c) -} - -func MarshalConditionAny(c any) (any, error) { - if c == nil { - return nil, nil - } - - // Use reflection to extract the underlying fields regardless of T. - v := reflect.ValueOf(c) - if v.Kind() == reflect.Pointer { - v = v.Elem() - } - - typeName := v.Type().Name() - if strings.HasPrefix(typeName, "rawCompareCondition") { - op := v.FieldByName("Op").String() - kind := "compare" - if op == "==" { - kind = "equal" - } else if op == "!=" { - kind = "not_equal" - } - return &condSurrogate{ - Kind: kind, - Data: map[string]any{ - "p": string(v.FieldByName("Path").Interface().(core.DeepPath)), - "v": v.FieldByName("Val").Interface(), - "o": op, - "ic": v.FieldByName("IgnoreCase").Bool(), - }, - }, nil - } - if strings.HasPrefix(typeName, "rawCompareFieldCondition") { - op := v.FieldByName("Op").String() - kind := "compare_field" - if op == "==" { - kind = "equal_field" - } else if op == "!=" { - kind = "not_equal_field" - } - return &condSurrogate{ - Kind: kind, - Data: map[string]any{ - "p1": string(v.FieldByName("Path1").Interface().(core.DeepPath)), - "p2": string(v.FieldByName("Path2").Interface().(core.DeepPath)), - "o": op, - "ic": v.FieldByName("IgnoreCase").Bool(), - }, - }, nil - } - if strings.HasPrefix(typeName, "rawDefinedCondition") { - return &condSurrogate{ - Kind: "defined", - Data: map[string]any{ - "p": string(v.FieldByName("Path").Interface().(core.DeepPath)), - }, - }, nil - } - if strings.HasPrefix(typeName, "rawUndefinedCondition") { - return &condSurrogate{ - Kind: "undefined", - Data: map[string]any{ - "p": string(v.FieldByName("Path").Interface().(core.DeepPath)), - }, - }, nil - } - if strings.HasPrefix(typeName, "rawTypeCondition") { - return &condSurrogate{ - Kind: "type", - Data: map[string]any{ - "p": string(v.FieldByName("Path").Interface().(core.DeepPath)), - "t": v.FieldByName("TypeName").String(), - }, - }, nil - } - if strings.HasPrefix(typeName, "rawStringCondition") { - return &condSurrogate{ - Kind: "string", - Data: map[string]any{ - "p": string(v.FieldByName("Path").Interface().(core.DeepPath)), - "v": v.FieldByName("Val").String(), - "o": v.FieldByName("Op").String(), - "ic": v.FieldByName("IgnoreCase").Bool(), - }, - }, nil - } - if strings.HasPrefix(typeName, "rawInCondition") { - return &condSurrogate{ - Kind: "in", - Data: map[string]any{ - "p": string(v.FieldByName("Path").Interface().(core.DeepPath)), - "v": v.FieldByName("Values").Interface(), - "ic": v.FieldByName("IgnoreCase").Bool(), - }, - }, nil - } - if strings.HasPrefix(typeName, "rawLogCondition") { - return &condSurrogate{ - Kind: "log", - Data: map[string]any{ - "m": v.FieldByName("Message").String(), - }, - }, nil - } - if strings.HasPrefix(typeName, "rawAndCondition") { - condsVal := v.FieldByName("Conditions") - conds := make([]any, 0, condsVal.Len()) - for i := 0; i < condsVal.Len(); i++ { - s, err := MarshalConditionAny(condsVal.Index(i).Interface()) - if err != nil { - return nil, err - } - conds = append(conds, s) - } - return &condSurrogate{ - Kind: "and", - Data: conds, - }, nil - } - if strings.HasPrefix(typeName, "rawOrCondition") { - condsVal := v.FieldByName("Conditions") - conds := make([]any, 0, condsVal.Len()) - for i := 0; i < condsVal.Len(); i++ { - s, err := MarshalConditionAny(condsVal.Index(i).Interface()) - if err != nil { - return nil, err - } - conds = append(conds, s) - } - return &condSurrogate{ - Kind: "or", - Data: conds, - }, nil - } - if strings.HasPrefix(typeName, "rawNotCondition") { - sub, err := MarshalConditionAny(v.FieldByName("C").Interface()) - if err != nil { - return nil, err - } - return &condSurrogate{ - Kind: "not", - Data: sub, - }, nil - } - - return nil, fmt.Errorf("unknown condition type: %T", c) -} - -func UnmarshalCondition[T any](data []byte) (Condition[T], error) { - var s condSurrogate - if err := json.Unmarshal(data, &s); err != nil { - return nil, err - } - return UnmarshalConditionSurrogate[T](&s) -} - -// ConditionFromSerializable reconstructs a condition from its serializable representation. -func ConditionFromSerializable[T any](s any) (Condition[T], error) { - return UnmarshalConditionSurrogate[T](s) -} - -func UnmarshalConditionSurrogate[T any](s any) (Condition[T], error) { - if s == nil { - return nil, nil - } - - var kind string - var data any - - switch v := s.(type) { - case *condSurrogate: - kind = v.Kind - data = v.Data - case map[string]any: - kind = v["k"].(string) - data = v["d"] - default: - return nil, fmt.Errorf("invalid condition surrogate type: %T", s) - } - - var inner InternalCondition - - switch kind { - case "equal": - d := data.(map[string]any) - ic := getBool(d, "ic") - inner = &rawCompareCondition{Path: core.DeepPath(d["p"].(string)), Val: d["v"], Op: "==", IgnoreCase: ic} - case "not_equal": - d := data.(map[string]any) - ic := getBool(d, "ic") - inner = &rawCompareCondition{Path: core.DeepPath(d["p"].(string)), Val: d["v"], Op: "!=", IgnoreCase: ic} - case "compare": - d := data.(map[string]any) - ic := getBool(d, "ic") - inner = &rawCompareCondition{Path: core.DeepPath(d["p"].(string)), Val: d["v"], Op: d["o"].(string), IgnoreCase: ic} - case "equal_field": - d := data.(map[string]any) - ic := getBool(d, "ic") - inner = &rawCompareFieldCondition{Path1: core.DeepPath(d["p1"].(string)), Path2: core.DeepPath(d["p2"].(string)), Op: "==", IgnoreCase: ic} - case "not_equal_field": - d := data.(map[string]any) - ic := getBool(d, "ic") - inner = &rawCompareFieldCondition{Path1: core.DeepPath(d["p1"].(string)), Path2: core.DeepPath(d["p2"].(string)), Op: "!=", IgnoreCase: ic} - case "compare_field": - d := data.(map[string]any) - ic := getBool(d, "ic") - inner = &rawCompareFieldCondition{Path1: core.DeepPath(d["p1"].(string)), Path2: core.DeepPath(d["p2"].(string)), Op: d["o"].(string), IgnoreCase: ic} - case "defined": - d := data.(map[string]any) - inner = &rawDefinedCondition{Path: core.DeepPath(d["p"].(string))} - case "undefined": - d := data.(map[string]any) - inner = &rawUndefinedCondition{Path: core.DeepPath(d["p"].(string))} - case "type": - d := data.(map[string]any) - inner = &rawTypeCondition{Path: core.DeepPath(d["p"].(string)), TypeName: d["t"].(string)} - case "string": - d := data.(map[string]any) - ic := getBool(d, "ic") - inner = &rawStringCondition{Path: core.DeepPath(d["p"].(string)), Val: d["v"].(string), Op: d["o"].(string), IgnoreCase: ic} - case "in": - d := data.(map[string]any) - ic := getBool(d, "ic") - vals := d["v"].([]any) - inner = &rawInCondition{Path: core.DeepPath(d["p"].(string)), Values: vals, IgnoreCase: ic} - case "log": - d := data.(map[string]any) - inner = &rawLogCondition{Message: d["m"].(string)} - case "and": - d := data.([]any) - conds := make([]InternalCondition, 0, len(d)) - for _, subData := range d { - sub, err := UnmarshalConditionSurrogate[T](subData) - if err != nil { - return nil, err - } - // sub is Condition[T]. We need InternalCondition. - if t, ok := sub.(*typedCondition[T]); ok { - conds = append(conds, t.inner) - } else { - return nil, fmt.Errorf("unexpected condition type in and: %T", sub) - } - } - inner = &rawAndCondition{Conditions: conds} - case "or": - d := data.([]any) - conds := make([]InternalCondition, 0, len(d)) - for _, subData := range d { - sub, err := UnmarshalConditionSurrogate[T](subData) - if err != nil { - return nil, err - } - if t, ok := sub.(*typedCondition[T]); ok { - conds = append(conds, t.inner) - } else { - return nil, fmt.Errorf("unexpected condition type in or: %T", sub) - } - } - inner = &rawOrCondition{Conditions: conds} - case "not": - sub, err := UnmarshalConditionSurrogate[T](data) - if err != nil { - return nil, err - } - if t, ok := sub.(*typedCondition[T]); ok { - inner = &rawNotCondition{C: t.inner} - } else { - return nil, fmt.Errorf("unexpected condition type in not: %T", sub) - } - default: - return nil, fmt.Errorf("unknown condition kind: %s", kind) - } - - return &typedCondition[T]{inner: inner}, nil -} - -func getBool(d map[string]any, key string) bool { - if v, ok := d[key]; ok { - if b, ok := v.(bool); ok { - return b - } - } - return false -} diff --git a/cond/condition_serialization_test.go b/cond/condition_serialization_test.go deleted file mode 100644 index 71b1293..0000000 --- a/cond/condition_serialization_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package cond - -import ( - "encoding/json" - "testing" -) - -func TestFieldConditionSerialization(t *testing.T) { - type Data struct { - A, B int - } - tests := []struct { - name string - cond Condition[Data] - }{ - {"EqualField", EqualField[Data]("A", "B")}, - {"CompareField", GreaterField[Data]("A", "B")}, - {"andCondition", And(EqualField[Data]("A", "B"), GreaterField[Data]("A", "B"))}, - {"orCondition", Or(EqualField[Data]("A", "B"), GreaterField[Data]("A", "B"))}, - {"notCondition", Not(EqualField[Data]("A", "B"))}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data, err := json.Marshal(tt.cond) - if err != nil { - t.Fatalf("Marshal failed: %v", err) - } - cond2, err := UnmarshalCondition[Data](data) - if err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - if cond2 == nil { - t.Fatal("Expected non-nil condition") - } - }) - } -} - -func TestPredicatesSerialization(t *testing.T) { - type Config struct { - Env string - } - type User struct { - Name string - Age int - Tags []string - Config *Config - Bio string - } - - tests := []struct { - name string - cond Condition[User] - }{ - {"Defined", Defined[User]("Name")}, - {"Undefined", Undefined[User]("Missing")}, - {"Type", Type[User]("Name", "string")}, - {"Contains", Contains[User]("Bio", "Zurich")}, - {"ContainsFold", ContainsFold[User]("Bio", "zurich")}, - {"In", In[User]("Age", 20, 30)}, - {"InFold", InFold[User]("Name", "ALICE")}, - {"Log", Log[User]("test message")}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data, err := json.Marshal(tt.cond) - if err != nil { - t.Fatalf("Marshal failed: %v", err) - } - cond2, err := UnmarshalCondition[User](data) - if err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - if cond2 == nil { - t.Fatal("Expected non-nil condition") - } - // Test Log evaluation during serialization test to cover logCondition.Evaluate - if tt.name == "Log" { - cond2.Evaluate(&User{Name: "foo"}) - } - }) - } -} diff --git a/cond/condition_test.go b/cond/condition_test.go deleted file mode 100644 index 9769cfa..0000000 --- a/cond/condition_test.go +++ /dev/null @@ -1,395 +0,0 @@ -package cond - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/brunoga/deep/v4/internal/core" -) - -func TestJSONPointer_Resolve(t *testing.T) { - type Config struct { - Port int - Host string - } - type Data struct { - Network Config - Meta map[string]any - } - d := Data{ - Network: Config{Port: 8080, Host: "localhost"}, - Meta: map[string]any{"env": "prod"}, - } - rv := reflect.ValueOf(d) - - tests := []struct { - pointer string - want any - }{ - {"/Network/Port", 8080}, - {"/Network/Host", "localhost"}, - {"/Meta/env", "prod"}, - } - for _, tt := range tests { - val, err := core.DeepPath(tt.pointer).Resolve(rv) - if err != nil { - t.Errorf("Resolve(%s) failed: %v", tt.pointer, err) - continue - } - if val.Interface() != tt.want { - t.Errorf("Resolve(%s) = %v, want %v", tt.pointer, val.Interface(), tt.want) - } - } -} - -func TestJSONPointer_SpecialChars(t *testing.T) { - m := map[string]int{ - "foo/bar": 1, - "foo~bar": 2, - } - rv := reflect.ValueOf(m) - - tests := []struct { - pointer string - want int - }{ - {"/foo~1bar", 1}, - {"/foo~0bar", 2}, - } - for _, tt := range tests { - val, err := core.DeepPath(tt.pointer).Resolve(rv) - if err != nil { - t.Fatalf("Resolve(%s) failed: %v", tt.pointer, err) - } - if val.Int() != int64(tt.want) { - t.Errorf("Resolve(%s) = %v, want %v", tt.pointer, val.Int(), tt.want) - } - } -} - -func TestJSONPointer_inConditions(t *testing.T) { - type Data struct { - A int - } - d := Data{A: 10} - cond, err := ParseCondition[Data]("/A == 10") - if err != nil { - t.Fatalf("ParseCondition failed: %v", err) - } - ok, _ := cond.Evaluate(&d) - if !ok { - t.Error("Condition with JSON Pointer failed") - } -} - -func TestPath_ResolveParentPath(t *testing.T) { - tests := []struct { - path string - wantParent string - wantKey string - wantIndex int - isIndex bool - }{ - {"/A/B", "/A", "B", 0, false}, - {"/A/0", "/A", "0", 0, true}, - {"/Map/Key~1WithSlash", "/Map", "Key/WithSlash", 0, false}, - {"/Top", "", "Top", 0, false}, - } - - for _, tt := range tests { - parent, part, err := core.DeepPath(tt.path).ResolveParentPath() - if err != nil { - t.Errorf("ResolveParentPath(%s) error: %v", tt.path, err) - continue - } - if string(parent) != tt.wantParent { - t.Errorf("ResolveParentPath(%s) parent = %s, want %s", tt.path, parent, tt.wantParent) - } - if part.IsIndex != tt.isIndex { - t.Errorf("ResolveParentPath(%s) isIndex = %v, want %v", tt.path, part.IsIndex, tt.isIndex) - } - if part.IsIndex { - if part.Index != tt.wantIndex { - t.Errorf("ResolveParentPath(%s) index = %d, want %d", tt.path, part.Index, tt.wantIndex) - } - } else { - if part.Key != tt.wantKey { - t.Errorf("ResolveParentPath(%s) key = %s, want %s", tt.path, part.Key, tt.wantKey) - } - } - } -} - -func TestPath_SetDelete(t *testing.T) { - type Data struct { - A int - M map[string]int - S []int - } - d := Data{A: 1, M: map[string]int{"a": 1}, S: []int{1}} - rv := reflect.ValueOf(&d).Elem() - - // Set - core.DeepPath("/A").Set(rv, reflect.ValueOf(2)) - if d.A != 2 { - t.Errorf("Set A failed: %d", d.A) - } - core.DeepPath("/M/b").Set(rv, reflect.ValueOf(2)) - if d.M["b"] != 2 { - t.Errorf("Set M.b failed: %v", d.M) - } - core.DeepPath("/S/1").Set(rv, reflect.ValueOf(2)) // Append - if len(d.S) != 2 || d.S[1] != 2 { - t.Errorf("Set S[1] failed: %v", d.S) - } - - // Delete - core.DeepPath("/M/a").Delete(rv) - if _, ok := d.M["a"]; ok { - t.Error("Delete M.a failed") - } - core.DeepPath("/S/0").Delete(rv) - if len(d.S) != 1 || d.S[0] != 2 { - t.Errorf("Delete S[0] failed: %v", d.S) - } -} - -func TestPath_Errors_Exhaustive(t *testing.T) { - type S struct{ A int } - var s *S - rv := reflect.ValueOf(s) - - // Resolve nil pointer - _, err := core.DeepPath("/A").Resolve(rv) - if err == nil { - t.Error("Expected error resolving through nil pointer") - } - - // Resolve empty path parent - _, _, err = core.DeepPath("").ResolveParent(reflect.ValueOf(1)) - if err == nil { - t.Error("Expected error resolveParent empty") - } - - // Navigate invalid index - _, _, err = core.DeepPath("").Navigate(reflect.ValueOf([]int{1}), []core.PathPart{{Index: 5, IsIndex: true}}) - if err == nil { - t.Error("Expected error index out of bounds") - } - - // Navigate invalid map key type - m := map[float64]int{1.0: 1} - _, _, err = core.DeepPath("").Navigate(reflect.ValueOf(m), []core.PathPart{{Key: "1"}}) - if err == nil { - t.Error("Expected error unsupported map key") - } - - // Navigate non-struct field - _, _, err = core.DeepPath("").Navigate(reflect.ValueOf(1), []core.PathPart{{Key: "A"}}) - if err == nil { - t.Error("Expected error non-struct field") - } - - // Set/Delete errors - core.DeepPath("/A").Delete(reflect.ValueOf(1)) -} - -func TestCondition_Errors(t *testing.T) { - type Data struct{ A int } - - t.Run("PathResolveErrors", func(t *testing.T) { - cond := Defined[Data]("/Missing/Field") - ok, _ := cond.Evaluate(&Data{}) - if ok { - t.Error("Defined should be false for missing path") - } - - condU := Undefined[Data]("/Missing/Field") - ok, _ = condU.Evaluate(&Data{}) - if !ok { - t.Error("Undefined should be true for missing path") - } - - condT := Type[Data]("/Missing/Field", "undefined") - ok, _ = condT.Evaluate(&Data{}) - if !ok { - t.Error("Type should be true for missing path if looking for undefined") - } - }) - - t.Run("CompareValuesErrors", func(t *testing.T) { - _, err := core.CompareValues(reflect.ValueOf(1), reflect.ValueOf("a"), ">", false) - if err == nil { - t.Error("Expected error comparing int and string with >") - } - - _, err = core.CompareValues(reflect.ValueOf(struct{}{}), reflect.ValueOf(struct{}{}), ">", false) - if err == nil { - t.Error("Expected error comparing structs with >") - } - }) - - t.Run("ParserErrors", func(t *testing.T) { - _, err := ParseCondition[Data]("A == ") - if err == nil { - t.Error("Expected error parsing incomplete expression") - } - _, err = ParseCondition[Data]("A ==") - if err == nil { - t.Error("Expected error parsing expression without value") - } - _, err = ParseCondition[Data]("(A == 1") - if err == nil { - t.Error("Expected error parsing unclosed parenthesis") - } - _, err = ParseCondition[Data]("A == 1 )") - if err == nil { - t.Error("Expected error parsing unexpected parenthesis") - } - }) - - t.Run("SerializationErrors", func(t *testing.T) { - _, err := MarshalConditionAny(123) - if err == nil { - t.Error("Expected error marshalling unknown type") - } - _, err = UnmarshalConditionSurrogate[any](123) - if err == nil { - t.Error("Expected error converting from invalid surrogate type") - } - _, err = UnmarshalConditionSurrogate[any](map[string]any{"k": "unknown", "d": nil}) - if err == nil { - t.Error("Expected error converting from unknown kind") - } - }) -} - -func TestCondition_Structure(t *testing.T) { - // Test Paths() and WithRelativePath() to improve coverage - type Data struct { - A struct { - B int - } - } - - c := Eq[Data]("/A/B", 10) - paths := c.Paths() - if len(paths) != 1 || paths[0] != "/A/B" { - t.Errorf("Paths() incorrect: %v", paths) - } - - // Test relative path - // Condition on /A/B, prefix /A. - // Result path should be /B. - rel := c.WithRelativePath("/A") - relPaths := rel.Paths() - if len(relPaths) != 1 || relPaths[0] != "/B" { - t.Errorf("WithRelativePath() incorrect: %v", relPaths) - } - - // Test complex condition structure - c2 := And(Eq[Data]("/A/B", 10), Greater[Data]("/A/B", 5)) - paths2 := c2.Paths() - if len(paths2) != 2 { - t.Errorf("Expected 2 paths, got %d", len(paths2)) - } - - rel2 := c2.WithRelativePath("/A") - relPaths2 := rel2.Paths() - if relPaths2[0] != "/B" || relPaths2[1] != "/B" { - t.Errorf("Relative paths incorrect: %v", relPaths2) - } -} - -func TestCondition_Structure_Exhaustive(t *testing.T) { - type Data struct{} - - // Defined - cDef := Defined[Data]("/P") - if len(cDef.Paths()) != 1 { - t.Error("Defined Paths failed") - } - if cDef.WithRelativePath("/A").Paths()[0] != "/P" { // Path logic might differ depending on prefix - // If prefix doesn't match, it returns original. - } - - // Undefined - cUndef := Undefined[Data]("/P") - if len(cUndef.Paths()) != 1 { - t.Error("Undefined Paths failed") - } - - // Not - cNot := Not(cDef) - if len(cNot.Paths()) != 1 { - t.Error("Not Paths failed") - } - - // Or - cOr := Or(cDef, cUndef) - if len(cOr.Paths()) != 2 { - t.Error("Or Paths failed") - } - - // CompareField - cComp := EqualField[Data]("/A", "/B") - if len(cComp.Paths()) != 2 { - t.Error("EqualField Paths failed") - } - - // In - cIn := In[Data]("/A", 1, 2) - if len(cIn.Paths()) != 1 { - t.Error("In Paths failed") - } - - // String ops - cStr := Contains[Data]("/A", "foo") - if len(cStr.Paths()) != 1 { - t.Error("Contains Paths failed") - } - - // Log - cLog := Log[Data]("msg") - if len(cLog.Paths()) != 0 { - t.Error("Log Paths should be empty") - } -} - -func TestMarshalCondition(t *testing.T) { - type Data struct { - A int - } - c := Eq[Data]("/A", 10) - // MarshalCondition now returns (any, error) which is the surrogate. - // To test JSON output, we should marshal the surrogate. - surrogate, err := MarshalCondition(c) - if err != nil { - t.Fatalf("MarshalCondition failed: %v", err) - } - - bytes, err := json.Marshal(surrogate) - if err != nil { - t.Fatalf("json.Marshal failed: %v", err) - } - - var m map[string]any - if err := json.Unmarshal(bytes, &m); err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - - // Use "equal" as expected by implementation - if m["k"] != "equal" { - t.Errorf("Expected k=equal, got %v", m["k"]) - } - - // Test MarshalJSON method on the Condition itself (should return bytes directly) - bytes2, err := c.MarshalJSON() - if err != nil { - t.Errorf("MarshalJSON failed: %v", err) - } - if string(bytes) != string(bytes2) { - t.Error("Wrapper and method differ") - } -} diff --git a/condition/condition.go b/condition/condition.go new file mode 100644 index 0000000..78f98a8 --- /dev/null +++ b/condition/condition.go @@ -0,0 +1,259 @@ +package condition + +import ( + "fmt" + "reflect" + "regexp" + + icore "github.com/brunoga/deep/v5/internal/core" +) + +// Condition operator constants. +const ( + Eq = "==" + Ne = "!=" + Gt = ">" + Lt = "<" + Ge = ">=" + Le = "<=" + Exists = "exists" + In = "in" + Matches = "matches" + Type = "type" + And = "and" + Or = "or" + Not = "not" +) + +// Condition represents a serializable predicate for conditional application. +type Condition struct { + Path string `json:"p,omitempty"` + Op string `json:"o"` // see operator constants above + Value any `json:"v,omitempty"` + Sub []*Condition `json:"apply,omitempty"` // Sub-conditions for logical operators (and, or, not) +} + +// Evaluate evaluates a condition against a root value. +func Evaluate(root reflect.Value, c *Condition) (bool, error) { + if c == nil { + return true, nil + } + + if c.Op == And { + for _, sub := range c.Sub { + ok, err := Evaluate(root, sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + } + if c.Op == Or { + for _, sub := range c.Sub { + ok, err := Evaluate(root, sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + } + if c.Op == Not { + if len(c.Sub) > 0 { + ok, err := Evaluate(root, c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + } + + val, err := icore.DeepPath(c.Path).Resolve(root) + if err != nil { + if c.Op == Exists { + return false, nil + } + return false, err + } + + if c.Op == Exists { + return val.IsValid(), nil + } + + if c.Op == Matches { + pattern, ok := c.Value.(string) + if !ok { + return false, fmt.Errorf("matches requires string pattern") + } + matched, err := regexp.MatchString(pattern, fmt.Sprintf("%v", val.Interface())) + if err != nil { + return false, fmt.Errorf("invalid regex pattern: %w", err) + } + return matched, nil + } + + if c.Op == In { + v := reflect.ValueOf(c.Value) + if v.Kind() != reflect.Slice && v.Kind() != reflect.Array { + return false, fmt.Errorf("in requires slice or array") + } + for i := 0; i < v.Len(); i++ { + if icore.Equal(val.Interface(), v.Index(i).Interface()) { + return true, nil + } + } + return false, nil + } + + if c.Op == Type { + typeName, ok := c.Value.(string) + if !ok { + return false, fmt.Errorf("type requires string value") + } + return CheckType(val.Interface(), typeName), nil + } + + return icore.CompareValues(val, reflect.ValueOf(c.Value), c.Op, false) +} + +// ToPredicate returns a JSON-serializable map representing the condition in +// the JSON Patch predicate wire format. This is the inverse of [FromPredicate]. +func (c *Condition) ToPredicate() map[string]any { + if c == nil { + return nil + } + + op := c.Op + switch op { + case Eq: + op = "test" + case Ne: + // Not equal is a 'not' predicate in some extensions + return map[string]any{ + "op": "not", + "apply": []map[string]any{ + {"op": "test", "path": c.Path, "value": c.Value}, + }, + } + case Gt: + op = "more" + case Ge: + op = "more-or-equal" + case Lt: + op = "less" + case Le: + op = "less-or-equal" + case Exists: + op = "defined" + case In: + op = "contains" + case "log": + op = "log" + case Matches: + op = "matches" + case Type: + op = "type" + case And, Or, Not: + res := map[string]any{ + "op": op, + } + var apply []map[string]any + for _, sub := range c.Sub { + apply = append(apply, sub.ToPredicate()) + } + res["apply"] = apply + return res + } + + return map[string]any{ + "op": op, + "path": c.Path, + "value": c.Value, + } +} + +// FromPredicate parses a JSON Patch predicate wire-format map into a +// [Condition]. This is the inverse of [Condition.ToPredicate]. +func FromPredicate(m map[string]any) *Condition { + if m == nil { + return nil + } + op, _ := m["op"].(string) + path, _ := m["path"].(string) + value := m["value"] + + switch op { + case "test": + return &Condition{Path: path, Op: Eq, Value: value} + case "not": + // Could be encoded != or a logical not. + // If it wraps a single test on the same path, treat as !=. + if apply, ok := m["apply"].([]any); ok && len(apply) == 1 { + if inner, ok := apply[0].(map[string]any); ok { + if inner["op"] == "test" { + innerPath, _ := inner["path"].(string) + return &Condition{Path: innerPath, Op: Ne, Value: inner["value"]} + } + } + } + return &Condition{Op: Not, Sub: parseApply(m["apply"])} + case "more": + return &Condition{Path: path, Op: Gt, Value: value} + case "more-or-equal": + return &Condition{Path: path, Op: Ge, Value: value} + case "less": + return &Condition{Path: path, Op: Lt, Value: value} + case "less-or-equal": + return &Condition{Path: path, Op: Le, Value: value} + case "defined": + return &Condition{Path: path, Op: Exists} + case "contains": + return &Condition{Path: path, Op: In, Value: value} + case And, Or: + return &Condition{Op: op, Sub: parseApply(m["apply"])} + default: + // log, matches, type — same op name, pass through + return &Condition{Path: path, Op: op, Value: value} + } +} + +func parseApply(raw any) []*Condition { + items, ok := raw.([]any) + if !ok { + return nil + } + out := make([]*Condition, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]any); ok { + if c := FromPredicate(m); c != nil { + out = append(out, c) + } + } + } + return out +} + +// CheckType reports whether v matches the given type name. +func CheckType(v any, typeName string) bool { + rv := reflect.ValueOf(v) + switch typeName { + case "string": + return rv.Kind() == reflect.String + case "number": + k := rv.Kind() + return (k >= reflect.Int && k <= reflect.Int64) || + (k >= reflect.Uint && k <= reflect.Uintptr) || + (k == reflect.Float32 || k == reflect.Float64) + case "boolean": + return rv.Kind() == reflect.Bool + case "object": + return rv.Kind() == reflect.Struct || rv.Kind() == reflect.Map + case "array": + return rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array + case "null": + if !rv.IsValid() { + return true + } + return (rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface || rv.Kind() == reflect.Slice || rv.Kind() == reflect.Map) && rv.IsNil() + } + return false +} diff --git a/condition/condition_test.go b/condition/condition_test.go new file mode 100644 index 0000000..e17280b --- /dev/null +++ b/condition/condition_test.go @@ -0,0 +1,78 @@ +package condition + +import ( + "reflect" + "testing" +) + +func TestCheckType(t *testing.T) { + type testUser struct { + ID int + } + u := testUser{ID: 1} + + if !CheckType("foo", "string") { + t.Error("CheckType string failed") + } + if !CheckType(1, "number") { + t.Error("CheckType number failed") + } + if !CheckType(true, "boolean") { + t.Error("CheckType boolean failed") + } + if !CheckType(u, "object") { + t.Error("CheckType object failed") + } + if !CheckType([]int{}, "array") { + t.Error("CheckType array failed") + } + if !CheckType((*testUser)(nil), "null") { + t.Error("CheckType null failed") + } + if !CheckType(nil, "null") { + t.Error("CheckType nil null failed") + } + if CheckType("foo", "number") { + t.Error("CheckType invalid failed") + } +} + +func TestEvaluate(t *testing.T) { + type testUser struct { + ID int `json:"id"` + Name string `json:"full_name"` + } + u := testUser{ID: 1, Name: "Alice"} + root := reflect.ValueOf(u) + + tests := []struct { + c *Condition + want bool + }{ + {c: &Condition{Op: "exists", Path: "/id"}, want: true}, + {c: &Condition{Op: "exists", Path: "/none"}, want: false}, + {c: &Condition{Op: "matches", Path: "/full_name", Value: "^Al.*$"}, want: true}, + {c: &Condition{Op: "type", Path: "/id", Value: "number"}, want: true}, + {c: &Condition{Op: "and", Sub: []*Condition{ + {Op: "==", Path: "/id", Value: 1}, + {Op: "==", Path: "/full_name", Value: "Alice"}, + }}, want: true}, + {c: &Condition{Op: "or", Sub: []*Condition{ + {Op: "==", Path: "/id", Value: 2}, + {Op: "==", Path: "/full_name", Value: "Alice"}, + }}, want: true}, + {c: &Condition{Op: "not", Sub: []*Condition{ + {Op: "==", Path: "/id", Value: 2}, + }}, want: true}, + } + + for _, tt := range tests { + got, err := Evaluate(root, tt.c) + if err != nil { + t.Errorf("Evaluate(%s) error: %v", tt.c.Op, err) + } + if got != tt.want { + t.Errorf("Evaluate(%s) = %v, want %v", tt.c.Op, got, tt.want) + } + } +} diff --git a/crdt/crdt.go b/crdt/crdt.go index 2062661..36041a9 100644 --- a/crdt/crdt.go +++ b/crdt/crdt.go @@ -1,165 +1,54 @@ +// Package crdt provides Conflict-free Replicated Data Types (CRDTs) built on +// top of the deep patch engine. +// +// The central type is [CRDT], a concurrency-safe wrapper around any value of +// type T. It tracks causal history using a per-field Hybrid Logical Clock (HLC) +// and resolves concurrent edits with Last-Write-Wins (LWW) semantics. +// +// # Basic workflow +// +// 1. Create nodes: nodeA := crdt.NewCRDT(initial, "node-a") +// 2. Edit locally: delta := nodeA.Edit(func(v *T) { v.Field = newVal }) +// 3. Distribute: send delta (JSON-serializable) to peers +// 4. Apply remotely: nodeB.ApplyDelta(delta) +// +// For full-state synchronization between two nodes use [CRDT.Merge]. +// +// # Text CRDT +// +// [Text] is a convergent, ordered sequence of [TextRun] segments. It supports +// concurrent insertions and deletions across nodes and is integrated with +// [CRDT] directly — no separate registration required. package crdt import ( "encoding/json" - "fmt" - "sort" + "log/slog" "sync" - "github.com/brunoga/deep/v4" - "github.com/brunoga/deep/v4/cond" - "github.com/brunoga/deep/v4/crdt/hlc" - crdtresolver "github.com/brunoga/deep/v4/resolvers/crdt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/crdt/hlc" ) -func init() { - deep.RegisterCustomPatch(&textPatch{}) - deep.RegisterCustomDiff[Text](func(a, b Text) (deep.Patch[Text], error) { - // Optimization: if both are same, return nil - if len(a) == len(b) { - same := true - for i := range a { - if a[i] != b[i] { - same = false - break - } - } - if same { - return nil, nil - } - } - return &textPatch{Runs: b}, nil - }) -} - -// textPatch is a specialized patch for Text CRDT. -type textPatch struct { - Runs Text -} - -func (p *textPatch) PatchKind() string { return "text" } - -func (p *textPatch) Apply(v *Text) { - *v = p.Runs.normalize() -} - -func (p *textPatch) ApplyChecked(v *Text) error { - p.Apply(v) - return nil -} - -func (p *textPatch) ApplyResolved(v *Text, r deep.ConflictResolver) error { - *v = mergeTextRuns(*v, p.Runs) - return nil +// LWW represents a Last-Write-Wins register for type T. +// Embed LWW fields in a struct to track per-field causality. +// Use Set to update the value; it accepts the write only if ts is strictly newer. +type LWW[T any] struct { + Value T `json:"v"` + Timestamp hlc.HLC `json:"t"` } -func mergeTextRuns(a, b Text) Text { - allRuns := append(a[:0:0], a...) - allRuns = append(allRuns, b...) - - // 1. Find all split points for each base ID (NodeID + WallTime) - type baseID struct { - WallTime int64 - NodeID string - } - splits := make(map[baseID]map[int32]bool) - - for _, run := range allRuns { - base := baseID{run.ID.WallTime, run.ID.NodeID} - if splits[base] == nil { - splits[base] = make(map[int32]bool) - } - splits[base][run.ID.Logical] = true - splits[base][run.ID.Logical+int32(len(run.Value))] = true - } - - // 2. Re-split all runs according to split points and merge into a map - combinedMap := make(map[hlc.HLC]TextRun) - for _, run := range allRuns { - base := baseID{run.ID.WallTime, run.ID.NodeID} - - relevantSplits := []int32{} - for s := range splits[base] { - if s > run.ID.Logical && s < run.ID.Logical+int32(len(run.Value)) { - relevantSplits = append(relevantSplits, s) - } - } - sort.Slice(relevantSplits, func(i, j int) bool { return relevantSplits[i] < relevantSplits[j] }) - - currentLogical := run.ID.Logical - currentValue := run.Value - currentPrev := run.Prev - - for _, s := range relevantSplits { - offset := int(s - currentLogical) - - id := run.ID - id.Logical = currentLogical - - newRun := TextRun{ - ID: id, - Value: currentValue[:offset], - Prev: currentPrev, - Deleted: run.Deleted, - } - if existing, ok := combinedMap[id]; ok { - if newRun.Deleted { - existing.Deleted = true - } - combinedMap[id] = existing - } else { - combinedMap[id] = newRun - } - - currentPrev = id - currentPrev.Logical += int32(offset - 1) - currentValue = currentValue[offset:] - currentLogical = s - } - - id := run.ID - id.Logical = currentLogical - newRun := TextRun{ - ID: id, - Value: currentValue, - Prev: currentPrev, - Deleted: run.Deleted, - } - if existing, ok := combinedMap[id]; ok { - if newRun.Deleted { - existing.Deleted = true - } - combinedMap[id] = existing - } else { - combinedMap[id] = newRun - } - } - - // 3. Reconstruct the slice - result := make(Text, 0, len(combinedMap)) - for _, run := range combinedMap { - result = append(result, run) +// Set updates the register's value and timestamp if ts is after the current +// timestamp. Returns true if the update was accepted. +func (l *LWW[T]) Set(v T, ts hlc.HLC) bool { + if ts.After(l.Timestamp) { + l.Value = v + l.Timestamp = ts + return true } - - return result.normalize() -} - -func (p *textPatch) Walk(fn func(path string, op deep.OpKind, old, new any) error) error { - return fn("", deep.OpReplace, nil, p.Runs) + return false } -func (p *textPatch) WithCondition(c cond.Condition[Text]) deep.Patch[Text] { return p } -func (p *textPatch) WithStrict(strict bool) deep.Patch[Text] { return p } -func (p *textPatch) Reverse() deep.Patch[Text] { return p } -func (p *textPatch) ToJSONPatch() ([]byte, error) { return nil, nil } -func (p *textPatch) Summary() string { return "Text update" } -func (p *textPatch) String() string { return "TextPatch" } - -func (p *textPatch) MarshalSerializable() (any, error) { - return deep.PatchToSerializable(p) -} - - // CRDT represents a Conflict-free Replicated Data Type wrapper around type T. type CRDT[T any] struct { mu sync.RWMutex @@ -171,27 +60,29 @@ type CRDT[T any] struct { } // Delta represents a set of changes with a causal timestamp. +// Obtain a Delta via CRDT.Edit; apply it on remote nodes via CRDT.ApplyDelta. type Delta[T any] struct { - Patch deep.Patch[T] `json:"p"` - Timestamp hlc.HLC `json:"t"` + patch deep.Patch[T] + Timestamp hlc.HLC `json:"t"` +} + +func (d Delta[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Patch deep.Patch[T] `json:"p"` + Timestamp hlc.HLC `json:"t"` + }{d.patch, d.Timestamp}) } func (d *Delta[T]) UnmarshalJSON(data []byte) error { var m struct { - Patch json.RawMessage `json:"p"` - Timestamp hlc.HLC `json:"t"` + Patch deep.Patch[T] `json:"p"` + Timestamp hlc.HLC `json:"t"` } if err := json.Unmarshal(data, &m); err != nil { return err } + d.patch = m.Patch d.Timestamp = m.Timestamp - if len(m.Patch) > 0 && string(m.Patch) != "null" { - p := deep.NewPatch[T]() - if err := json.Unmarshal(m.Patch, p); err != nil { - return err - } - d.Patch = p - } return nil } @@ -207,93 +98,58 @@ func NewCRDT[T any](initial T, nodeID string) *CRDT[T] { } // NodeID returns the unique identifier for this CRDT instance. -func (c *CRDT[T]) NodeID() string { - return c.nodeID -} +func (c *CRDT[T]) NodeID() string { return c.nodeID } // Clock returns the internal hybrid logical clock. -func (c *CRDT[T]) Clock() *hlc.Clock { - return c.clock -} +func (c *CRDT[T]) Clock() *hlc.Clock { return c.clock } // View returns a deep copy of the current value. func (c *CRDT[T]) View() T { c.mu.RLock() defer c.mu.RUnlock() - copied, err := deep.Copy(c.value) - if err != nil { - var zero T - return zero - } - return copied + return deep.Clone(c.value) } -// Edit applies changes and returns a Delta. +// Edit applies fn to a copy of the current value, computes a delta, advances +// the local clock, and returns the delta for distribution to peers. Returns an +// empty Delta if the edit produces no changes. func (c *CRDT[T]) Edit(fn func(*T)) Delta[T] { c.mu.Lock() defer c.mu.Unlock() - workingCopy, err := deep.Copy(c.value) - if err != nil { - return Delta[T]{} - } + workingCopy := deep.Clone(c.value) fn(&workingCopy) patch, err := deep.Diff(c.value, workingCopy) - if err != nil || patch == nil { + if err != nil { + slog.Default().Error("crdt: Edit diff failed", "err", err) return Delta[T]{} } - - now := c.clock.Now() - c.updateMetadataLocked(patch, now) - - c.value = workingCopy - - return Delta[T]{ - Patch: patch, - Timestamp: now, - } -} - -// CreateDelta takes an existing patch, applies it to the local value, -// updates local metadata, and returns a Delta. Use this if you have -// already generated a patch manually. -func (c *CRDT[T]) CreateDelta(patch deep.Patch[T]) Delta[T] { - if patch == nil { + if patch.IsEmpty() { return Delta[T]{} } - c.mu.Lock() - defer c.mu.Unlock() - now := c.clock.Now() c.updateMetadataLocked(patch, now) + c.value = workingCopy - patch.Apply(&c.value) - - return Delta[T]{ - Patch: patch, - Timestamp: now, - } + return Delta[T]{patch: patch, Timestamp: now} } func (c *CRDT[T]) updateMetadataLocked(patch deep.Patch[T], ts hlc.HLC) { - err := patch.Walk(func(path string, op deep.OpKind, old, new any) error { - if op == deep.OpRemove { - c.tombstones[path] = ts + for _, op := range patch.Operations { + if op.Kind == deep.OpRemove { + c.tombstones[op.Path] = ts } else { - c.clocks[path] = ts + c.clocks[op.Path] = ts } - return nil - }) - if err != nil { - panic(fmt.Errorf("crdt metadata update failed: %w", err)) } } -// ApplyDelta applies a delta using LWW resolution. +// ApplyDelta applies a delta from a remote peer using Last-Write-Wins resolution. +// Returns true if any operations were accepted. func (c *CRDT[T]) ApplyDelta(delta Delta[T]) bool { - if delta.Patch == nil { + if delta.patch.IsEmpty() { return false } @@ -302,25 +158,47 @@ func (c *CRDT[T]) ApplyDelta(delta Delta[T]) bool { c.clock.Update(delta.Timestamp) - resolver := &crdtresolver.LWWResolver{ - Clocks: c.clocks, - Tombstones: c.tombstones, - OpTime: delta.Timestamp, + // Text is a convergent CRDT with its own merge semantics — always apply, + // skipping the LWW clock filter that would discard concurrent inserts/deletes. + if _, ok := any(c.value).(Text); ok { + return deep.Apply(&c.value, delta.patch) == nil } - if err := delta.Patch.ApplyResolved(&c.value, resolver); err != nil { - return false + var filtered []deep.Operation + for _, op := range delta.patch.Operations { + opTime := delta.Timestamp + + // LWW: effective local time is the max of the write clock and tombstone. + lTime := c.clocks[op.Path] + if lTomb, ok := c.tombstones[op.Path]; ok && lTomb.After(lTime) { + lTime = lTomb + } + + if !opTime.After(lTime) { + continue // local is newer or equal — skip + } + + filtered = append(filtered, op) + if op.Kind == deep.OpRemove { + c.tombstones[op.Path] = opTime + } else { + c.clocks[op.Path] = opTime + } } - return true + if len(filtered) == 0 { + return false + } + return deep.Apply(&c.value, deep.Patch[T]{Operations: filtered}) == nil } -// Merge merges another state. +// Merge performs a full state-based merge with another CRDT node. +// For each changed field the node with the strictly newer effective timestamp +// (max of write clock and tombstone) wins. func (c *CRDT[T]) Merge(other *CRDT[T]) bool { c.mu.Lock() defer c.mu.Unlock() - // Update local clock for _, h := range other.clocks { c.clock.Update(h) } @@ -328,38 +206,67 @@ func (c *CRDT[T]) Merge(other *CRDT[T]) bool { c.clock.Update(h) } - patch, err := deep.Diff(c.value, other.value) - if err != nil || patch == nil { - c.mergeMeta(other) - return false - } - - // State-based Resolver - if _, ok := any(c.value).(*Text); ok { - // Special case for Text - v := any(c.value).(Text) + // Text has its own convergent merge that doesn't rely on per-field clocks. + if v, ok := any(c.value).(Text); ok { otherV := any(other.value).(Text) - c.value = any(mergeTextRuns(v, otherV)).(T) + c.value = any(MergeTextRuns(v, otherV)).(T) c.mergeMeta(other) return true } - resolver := &crdtresolver.StateResolver{ - LocalClocks: c.clocks, - LocalTombstones: c.tombstones, - RemoteClocks: other.clocks, - RemoteTombstones: other.tombstones, + patch, err := deep.Diff(c.value, other.value) + if err != nil || patch.IsEmpty() { + c.mergeMeta(other) + return false } - if err := patch.ApplyResolved(&c.value, resolver); err != nil { - return false + // State-based LWW: apply each op only if the remote effective time is + // strictly newer than the local effective time for that path. + var filtered []deep.Operation + for _, op := range patch.Operations { + rClock, hasRC := other.clocks[op.Path] + rTomb, hasRT := other.tombstones[op.Path] + + // If remote has no timing info for this path, local wins by default. + if !hasRC && !hasRT { + continue + } + + lTime := c.clocks[op.Path] + if lTomb, ok := c.tombstones[op.Path]; ok && lTomb.After(lTime) { + lTime = lTomb + } + + rTime := rClock + if hasRT && rTomb.After(rTime) { + rTime = rTomb + } + + if !rTime.After(lTime) { + continue // local is newer or equal + } + + filtered = append(filtered, op) + if op.Kind == deep.OpRemove { + if hasRT { + c.tombstones[op.Path] = rTomb + } + } else { + if hasRC { + c.clocks[op.Path] = rClock + } + } } c.mergeMeta(other) + + if len(filtered) == 0 { + return false + } + _ = deep.Apply(&c.value, deep.Patch[T]{Operations: filtered}) return true } - func (c *CRDT[T]) mergeMeta(other *CRDT[T]) { for k, v := range other.clocks { if existing, ok := c.clocks[k]; !ok || v.After(existing) { diff --git a/crdt/crdt_test.go b/crdt/crdt_test.go index 7c666aa..53dde3b 100644 --- a/crdt/crdt_test.go +++ b/crdt/crdt_test.go @@ -4,8 +4,6 @@ import ( "reflect" "testing" "time" - - "github.com/brunoga/deep/v4" ) type TestUser struct { @@ -37,29 +35,19 @@ func TestCRDT_EditDelta(t *testing.T) { } } -func TestCRDT_CreateDelta(t *testing.T) { +func TestCRDT_EditApplied(t *testing.T) { node := NewCRDT(TestUser{ID: 1, Name: "Old"}, "node1") - // Manually create a patch using deep.Diff - patch := deep.MustDiff(node.View(), TestUser{ID: 1, Name: "New"}) - - // Use the new helper to wrap it into a Delta and update local state - delta := node.CreateDelta(patch) + delta := node.Edit(func(u *TestUser) { u.Name = "New" }) if node.View().Name != "New" { t.Errorf("expected New, got %s", node.View().Name) } - if delta.Timestamp.Logical != 0 { - t.Errorf("expected logical clock 0, got %d", delta.Timestamp.Logical) - } - - // We cannot access internal clocks/tombstones directly anymore. - // But we can check if the Delta has the correct metadata updates implicitly via ApplyDelta to another node. node2 := NewCRDT(TestUser{ID: 1, Name: "Old"}, "node2") node2.ApplyDelta(delta) if node2.View().Name != "New" { - t.Error("CreateDelta produced invalid delta") + t.Error("delta from Edit did not propagate correctly") } } @@ -133,7 +121,7 @@ func TestCRDT_JSON(t *testing.T) { if newNode.View().Name != "Modified" { t.Errorf("expected Name Modified, got %s", newNode.View().Name) } - + // Ensure clocks are restored by doing a merge // If clocks are missing, merge might fail to converge correctly or assume old data node2 := NewCRDT(TestUser{ID: 1, Name: "Initial"}, "node2") diff --git a/crdt/hlc/hlc.go b/crdt/hlc/hlc.go index 7d96d5a..446c2c0 100644 --- a/crdt/hlc/hlc.go +++ b/crdt/hlc/hlc.go @@ -1,3 +1,16 @@ +// Package hlc implements a Hybrid Logical Clock (HLC) for distributed +// causality tracking. +// +// An [HLC] timestamp combines a physical wall-clock component with a logical +// counter and a node identifier, providing a total ordering of events across +// nodes that is consistent with real time. When two events share the same wall +// time, the logical counter breaks ties; when both are equal, the node ID +// provides a deterministic tiebreaker. +// +// Use [NewClock] to create a per-node clock, [Clock.Now] to generate a new +// timestamp, and [Clock.Update] to advance the clock when receiving a remote +// timestamp (ensuring the local clock is always at least as recent as any +// observed remote event). package hlc import ( diff --git a/crdt/text.go b/crdt/text.go index 07fb73b..0da6d0f 100644 --- a/crdt/text.go +++ b/crdt/text.go @@ -1,19 +1,21 @@ package crdt import ( + "encoding/json" + "log/slog" "sort" "strings" - "github.com/brunoga/deep/v4/crdt/hlc" + "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/crdt/hlc" ) // TextRun represents a contiguous run of characters with a unique starting ID. -// Individual characters within the run have implicit IDs: ID + index. type TextRun struct { ID hlc.HLC `deep:"key" json:"id"` Value string `json:"v"` - Prev hlc.HLC `json:"p,omitempty"` // ID of the character this run follows - Deleted bool `json:"d,omitempty"` // Tombstone for CRDT convergence + Prev hlc.HLC `json:"p,omitempty"` + Deleted bool `json:"d,omitempty"` } // Text represents a CRDT-friendly text structure using runs. @@ -35,39 +37,23 @@ func (t Text) Insert(pos int, value string, clock *hlc.Clock) Text { if value == "" { return t } - - // 1. Find the anchor (predecessor ID) prevID := t.findIDAt(pos - 1) - - // 2. Create the new run + result := t.splitAt(pos) newRun := TextRun{ ID: clock.Reserve(len(value)), Value: value, Prev: prevID, } - - // 3. If we are inserting in the middle of a run, we MUST split it - // to maintain causal integrity. - result := t.splitAt(pos) - - // 4. Add the new run. For now, we just append; the custom merge/render - // will handle the ordering. result = append(result, newRun) - return result.normalize() } -// Delete removes length characters starting at pos by marking them as deleted. +// Delete removes length characters starting at pos. func (t Text) Delete(pos, length int) Text { if length <= 0 { return t } - - // Split at boundaries to isolate the range - result := t.splitAt(pos) - result = result.splitAt(pos + length) - - // Mark runs in range as deleted + result := t.splitAt(pos).splitAt(pos + length) currentPos := 0 ordered := result.getOrdered() for i := range ordered { @@ -79,7 +65,6 @@ func (t Text) Delete(pos, length int) Text { currentPos += runLen } } - return ordered.normalize() } @@ -87,7 +72,6 @@ func (t Text) findIDAt(pos int) hlc.HLC { if pos < 0 { return hlc.HLC{} } - currentPos := 0 for _, run := range t.getOrdered() { if run.Deleted { @@ -101,7 +85,6 @@ func (t Text) findIDAt(pos int) hlc.HLC { } currentPos += runLen } - return hlc.HLC{} } @@ -109,7 +92,6 @@ func (t Text) splitAt(pos int) Text { if pos <= 0 { return t } - ordered := t.getOrdered() currentPos := 0 for i, run := range ordered { @@ -119,44 +101,22 @@ func (t Text) splitAt(pos int) Text { runLen := len(run.Value) if pos > currentPos && pos < currentPos+runLen { offset := pos - currentPos - - // Original run is replaced by two parts left := TextRun{ ID: run.ID, Value: run.Value[:offset], Prev: run.Prev, Deleted: run.Deleted, } - rightID := run.ID rightID.Logical += int32(offset) - rightPrev := run.ID rightPrev.Logical += int32(offset - 1) - right := TextRun{ ID: rightID, Value: run.Value[offset:], Prev: rightPrev, Deleted: run.Deleted, } - - // Mark original as "tombstoned" but we don't need the actual - // tombstone if we are in a slice-based approach, UNLESS we - // want to avoid the "Replace" conflict. - - // Actually, let's keep it simple: the split SHOULD be deterministic. - // The problem is that the "Value" of run.ID is being changed - // in two different ways by Node A and Node B. - - // Node A splits at 6: ID 1.0 becomes "word1 " - // Node B splits at 18: ID 1.0 becomes "word1 word2 word3 " - - // If we merge these, one wins. - // If Node B wins, ID 1.0 is "word1 word2 word3 ". - // Then we have ID 1.6 (from A) which is "word2 word3 word4". - // Result: "word1 word2 word3 " + "word2 word3 word4" -> DUPLICATION. - newText := make(Text, 0, len(ordered)+1) newText = append(newText, ordered[:i]...) newText = append(newText, left, right) @@ -168,44 +128,27 @@ func (t Text) splitAt(pos int) Text { return t } -// getOrdered returns the runs in their causal order. func (t Text) getOrdered() Text { if len(t) <= 1 { return t } - - // Build adjacency list: PrevID -> [Runs] children := make(map[hlc.HLC][]TextRun) for _, run := range t { children[run.Prev] = append(children[run.Prev], run) } - - // Sort siblings by ID descending for deterministic convergence for _, runs := range children { sort.Slice(runs, func(i, j int) bool { return runs[i].ID.After(runs[j].ID) }) } - var result Text seen := make(map[hlc.HLC]bool) - var walk func(hlc.HLC) walk = func(id hlc.HLC) { - // A parent can be the start of a run OR any character within a run. - // If 'id' is a character within a run, we should have already rendered - // up to that character. - - // In this optimized implementation, we assume children only attach to - // explicitly split boundaries or the very end of a run. - // (Text.Insert and Text.splitAt ensure this). - for _, run := range children[id] { if !seen[run.ID] { seen[run.ID] = true result = append(result, run) - - // After rendering this run, check for children attached to any of its characters. for i := 0; i < len(run.Value); i++ { charID := run.ID charID.Logical += int32(i) @@ -214,42 +157,158 @@ func (t Text) getOrdered() Text { } } } - - walk(hlc.HLC{}) // Start from root + walk(hlc.HLC{}) return result } -// normalize merges adjacent runs if they are contiguous in ID and causality. func (t Text) normalize() Text { ordered := t.getOrdered() if len(ordered) <= 1 { return ordered } - result := make(Text, 0, len(ordered)) result = append(result, ordered[0]) - for i := 1; i < len(ordered); i++ { lastIdx := len(result) - 1 last := &result[lastIdx] curr := ordered[i] - - // Check if they can be merged: - // 1. Same deletion status - // 2. Contiguous IDs - // 3. Current follows exactly the last char of previous expectedID := last.ID expectedID.Logical += int32(len(last.Value)) - prevID := last.ID prevID.Logical += int32(len(last.Value) - 1) - if curr.Deleted == last.Deleted && curr.ID == expectedID && curr.Prev == prevID { last.Value += curr.Value } else { result = append(result, curr) } } - return result } + +// Diff compares t with other and returns a Patch. +func (t Text) Diff(other Text) deep.Patch[Text] { + if len(t) == len(other) { + same := true + for i := range t { + if t[i] != other[i] { + same = false + break + } + } + if same { + return deep.Patch[Text]{} + } + } + return deep.Patch[Text]{ + Operations: []deep.Operation{ + {Kind: deep.OpReplace, Path: "/", New: other}, + }, + } +} + +// Patch applies p to t. +func (t *Text) Patch(p deep.Patch[Text], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + var errs []error + for _, op := range p.Operations { + if _, err := t.applyOperation(op, logger); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *Text) applyOperation(op deep.Operation, _ *slog.Logger) (bool, error) { + if op.Path == "" || op.Path == "/" { + if other, ok := op.New.(Text); ok { + *t = MergeTextRuns(*t, other) + return true, nil + } + // Handle JSON roundtrip: op.New arrives as []interface{} after JSON decode. + if raw, ok := op.New.([]interface{}); ok { + data, err := json.Marshal(raw) + if err != nil { + return false, err + } + var other Text + if err := json.Unmarshal(data, &other); err != nil { + return false, err + } + *t = MergeTextRuns(*t, other) + return true, nil + } + } + return false, nil +} + +// MergeTextRuns merges two Text states into a single convergent state. +func MergeTextRuns(a, b Text) Text { + allRuns := append(a[:0:0], a...) + allRuns = append(allRuns, b...) + type baseID struct { + WallTime int64 + NodeID string + } + splits := make(map[baseID]map[int32]bool) + for _, run := range allRuns { + base := baseID{run.ID.WallTime, run.ID.NodeID} + if splits[base] == nil { + splits[base] = make(map[int32]bool) + } + splits[base][run.ID.Logical] = true + splits[base][run.ID.Logical+int32(len(run.Value))] = true + } + combinedMap := make(map[hlc.HLC]TextRun) + for _, run := range allRuns { + base := baseID{run.ID.WallTime, run.ID.NodeID} + relevantSplits := []int32{} + for s := range splits[base] { + if s > run.ID.Logical && s < run.ID.Logical+int32(len(run.Value)) { + relevantSplits = append(relevantSplits, s) + } + } + sort.Slice(relevantSplits, func(i, j int) bool { return relevantSplits[i] < relevantSplits[j] }) + currentLogical := run.ID.Logical + currentValue := run.Value + currentPrev := run.Prev + for _, s := range relevantSplits { + offset := int(s - currentLogical) + id := run.ID + id.Logical = currentLogical + newRun := TextRun{ID: id, Value: currentValue[:offset], Prev: currentPrev, Deleted: run.Deleted} + if existing, ok := combinedMap[id]; ok { + if newRun.Deleted { + existing.Deleted = true + } + combinedMap[id] = existing + } else { + combinedMap[id] = newRun + } + currentPrev = id + currentPrev.Logical += int32(offset - 1) + currentValue = currentValue[offset:] + currentLogical = s + } + id := run.ID + id.Logical = currentLogical + newRun := TextRun{ID: id, Value: currentValue, Prev: currentPrev, Deleted: run.Deleted} + if existing, ok := combinedMap[id]; ok { + if newRun.Deleted { + existing.Deleted = true + } + combinedMap[id] = existing + } else { + combinedMap[id] = newRun + } + } + result := make(Text, 0, len(combinedMap)) + for _, run := range combinedMap { + result = append(result, run) + } + return result.normalize() +} diff --git a/crdt/text_test.go b/crdt/text_test.go index 2a0cb71..549b9a6 100644 --- a/crdt/text_test.go +++ b/crdt/text_test.go @@ -4,7 +4,7 @@ import ( "sync" "testing" - "github.com/brunoga/deep/v4/crdt/hlc" + "github.com/brunoga/deep/v5/crdt/hlc" ) func TestText_Insert(t *testing.T) { @@ -26,7 +26,7 @@ func TestText_Insert(t *testing.T) { t.Errorf("expected He!llo, got %s", text.String()) } // Runs should be: "He", "!", "llo" - // After normalize: "He", "!", "llo" (they are not contiguous in ID sequence + // After normalize: "He", "!", "llo" (they are not contiguous in ID sequence // because "!" has a new WallTime) if len(text) != 3 { t.Errorf("expected 3 runs, got %d", len(text)) @@ -72,7 +72,7 @@ func TestText_Delete(t *testing.T) { if text.String() != "Heo" { t.Errorf("expected Heo, got %s", text.String()) } - + // "He" (active), "ll" (tombstone), "o" (active), " World" (tombstone) // normalize() might merge adjacent tombstones. if text[1].Value != "ll" || !text[1].Deleted { diff --git a/diff.go b/diff.go index c76290c..4f2e029 100644 --- a/diff.go +++ b/diff.go @@ -2,1141 +2,47 @@ package deep import ( "fmt" - "reflect" - "strconv" - "strings" - "sync" - - "github.com/brunoga/deep/v4/internal/core" - "github.com/brunoga/deep/v4/internal/unsafe" + "github.com/brunoga/deep/v5/internal/engine" ) -// DiffOption allows configuring the behavior of the Diff function. -// Note: DiffOption is defined in options.go - -type diffConfig struct { - ignoredPaths map[string]bool - detectMoves bool -} - -type diffOptionFunc func(*diffConfig) - -func (f diffOptionFunc) applyDiffOption() {} - -// DiffDetectMoves returns an option that enables move and copy detection. -func DiffDetectMoves(enable bool) DiffOption { - return diffOptionFunc(func(c *diffConfig) { - c.detectMoves = enable - }) -} - -// Keyer is an interface that types can implement to provide a canonical -// representation for map keys. This allows semantic equality checks for -// complex map keys. -type Keyer interface { - CanonicalKey() any -} - -// Differ is a stateless engine for calculating patches between two values. -type Differ struct { - config *diffConfig - customDiffs map[reflect.Type]func(a, b reflect.Value, ctx *diffContext) (diffPatch, error) -} - -// NewDiffer creates a new Differ with the given options. -func NewDiffer(opts ...DiffOption) *Differ { - config := &diffConfig{ - ignoredPaths: make(map[string]bool), - } - for _, opt := range opts { - if f, ok := opt.(diffOptionFunc); ok { - f(config) - } else if u, ok := opt.(unifiedOption); ok { - config.ignoredPaths[core.NormalizePath(string(u))] = true - } - } - return &Differ{ - config: config, - customDiffs: make(map[reflect.Type]func(a, b reflect.Value, ctx *diffContext) (diffPatch, error)), - } -} - -// diffContext holds transient state for a single Diff execution. -type diffContext struct { - valueIndex map[any]string - movedPaths map[string]bool - visited map[core.VisitKey]bool - pathStack []string - rootB reflect.Value -} - -var diffContextPool = sync.Pool{ - New: func() any { - return &diffContext{ - valueIndex: make(map[any]string), - movedPaths: make(map[string]bool), - visited: make(map[core.VisitKey]bool), - pathStack: make([]string, 0, 32), - } - }, -} - -func getDiffContext() *diffContext { - return diffContextPool.Get().(*diffContext) -} - -func releaseDiffContext(ctx *diffContext) { - for k := range ctx.valueIndex { - delete(ctx.valueIndex, k) - } - for k := range ctx.movedPaths { - delete(ctx.movedPaths, k) +// Diff compares two values and returns a Patch describing the changes from a to b. +// Generated types (produced by deep-gen) dispatch to a reflection-free implementation. +// For other types, Diff falls back to the reflection engine which may return an error +// for unsupported kinds (chan, func, etc.). +func Diff[T any](a, b T) (Patch[T], error) { + // 1. Try generated optimized path (pointer receiver, pointer arg) + if differ, ok := any(&a).(interface { + Diff(*T) Patch[T] + }); ok { + return differ.Diff(&b), nil } - for k := range ctx.visited { - delete(ctx.visited, k) - } - ctx.pathStack = ctx.pathStack[:0] - ctx.rootB = reflect.Value{} - diffContextPool.Put(ctx) -} -func (ctx *diffContext) buildPath() string { - var b strings.Builder - b.WriteByte('/') - for i, s := range ctx.pathStack { - if i > 0 { - b.WriteByte('/') - } - b.WriteString(core.EscapeKey(s)) + // 2. Try hand-written Diff with value arg (e.g. crdt.Text) + if differ, ok := any(a).(interface { + Diff(T) Patch[T] + }); ok { + return differ.Diff(b), nil } - return b.String() -} - -var ( - defaultDiffer = NewDiffer() - mu sync.RWMutex -) -// RegisterCustomDiff registers a custom diff function for a specific type globally. -func RegisterCustomDiff[T any](fn func(a, b T) (Patch[T], error)) { - mu.Lock() - d := defaultDiffer - mu.Unlock() - - var t T - typ := reflect.TypeOf(t) - d.customDiffs[typ] = func(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { - p, err := fn(a.Interface().(T), b.Interface().(T)) - if err != nil { - return nil, err - } - if p == nil { - return nil, nil - } - if unwrapper, ok := p.(patchUnwrapper); ok { - return unwrapper.unwrap(), nil - } - return &customDiffPatch{patch: p}, nil - } -} - -// Diff compares two values a and b and returns a Patch that can be applied. -func (d *Differ) Diff(a, b any) (Patch[any], error) { - va := reflect.ValueOf(&a).Elem() - vb := reflect.ValueOf(&b).Elem() - - ctx := getDiffContext() - defer releaseDiffContext(ctx) - ctx.rootB = vb - - if d.config.detectMoves { - d.indexValues(va, ctx) - d.detectMovesRecursive(vb, ctx) - } - - patch, err := d.diffRecursive(va, vb, false, ctx) + // 3. Fallback to reflection engine + p, err := engine.Diff(a, b) if err != nil { - return nil, err + return Patch[T]{}, fmt.Errorf("deep.Diff: %w", err) } - if patch == nil { - return nil, nil + if p == nil { + return Patch[T]{}, nil } - return &typedPatch[any]{inner: patch, strict: true}, nil -} - -func (d *Differ) detectMovesRecursive(v reflect.Value, ctx *diffContext) { - if !v.IsValid() { - return - } - - key := getHashKey(v) - if key != nil && isHashable(v) { - if fromPath, ok := ctx.valueIndex[key]; ok { - currentPath := ctx.buildPath() - if fromPath != currentPath { - if isMove, checked := ctx.movedPaths[fromPath]; checked { - // Already checked this source path. - _ = isMove - } else { - if !d.isValueAtTarget(fromPath, v.Interface(), ctx) { - ctx.movedPaths[fromPath] = true - } else { - ctx.movedPaths[fromPath] = false - } - } - } - } - } - - switch v.Kind() { - case reflect.Struct: - info := core.GetTypeInfo(v.Type()) - for _, fInfo := range info.Fields { - if fInfo.Tag.Ignore { - continue - } - ctx.pathStack = append(ctx.pathStack, fInfo.Name) - d.detectMovesRecursive(v.Field(fInfo.Index), ctx) - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - } - case reflect.Slice, reflect.Array: - for i := 0; i < v.Len(); i++ { - ctx.pathStack = append(ctx.pathStack, strconv.Itoa(i)) - d.detectMovesRecursive(v.Index(i), ctx) - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - } - case reflect.Map: - iter := v.MapRange() - for iter.Next() { - k := iter.Key() - ck := k.Interface() - if keyer, ok := ck.(Keyer); ok { - ck = keyer.CanonicalKey() - } - ctx.pathStack = append(ctx.pathStack, fmt.Sprintf("%v", ck)) - d.detectMovesRecursive(iter.Value(), ctx) - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - } - case reflect.Pointer, reflect.Interface: - if !v.IsNil() { - d.detectMovesRecursive(v.Elem(), ctx) - } - } -} - -// Diff compares two values a and b and returns a Patch that can be applied. -// It returns an error if the comparison fails (e.g., due to custom diff failure). -func Diff[T any](a, b T, opts ...DiffOption) (Patch[T], error) { - var d *Differ - if len(opts) == 0 { - mu.RLock() - d = defaultDiffer - mu.RUnlock() - } else { - d = NewDiffer(opts...) - } - return DiffUsing(d, a, b) -} - -// DiffUsing compares two values a and b using the specified Differ and returns a Patch. -func DiffUsing[T any](d *Differ, a, b T) (Patch[T], error) { - va := reflect.ValueOf(&a).Elem() - vb := reflect.ValueOf(&b).Elem() - - ctx := getDiffContext() - defer releaseDiffContext(ctx) - ctx.rootB = vb - - if d.config.detectMoves { - d.indexValues(va, ctx) - d.detectMovesRecursive(vb, ctx) - } - - patch, err := d.diffRecursive(va, vb, false, ctx) - if err != nil { - return nil, err - } - - if patch == nil { - return nil, nil - } - - return &typedPatch[T]{ - inner: patch, - strict: true, - }, nil -} -// MustDiff compares two values a and b and returns a Patch that can be applied. -// It panics if the comparison fails. -func MustDiff[T any](a, b T, opts ...DiffOption) Patch[T] { - p, err := Diff(a, b, opts...) - if err != nil { - panic(err) - } - return p -} - -// MustDiffUsing compares two values a and b using the specified Differ and returns a Patch. -// It panics if the comparison fails. -func MustDiffUsing[T any](d *Differ, a, b T) Patch[T] { - p, err := DiffUsing(d, a, b) - if err != nil { - panic(err) - } - return p -} - -func isHashable(v reflect.Value) bool { - kind := v.Kind() - switch kind { - case reflect.Slice, reflect.Map, reflect.Func: - return false - case reflect.Pointer, reflect.Interface: - if v.IsNil() { - return true - } - return isHashable(v.Elem()) - case reflect.Struct: - for i := 0; i < v.NumField(); i++ { - if !isHashable(v.Field(i)) { - return false - } - } - case reflect.Array: - for i := 0; i < v.Len(); i++ { - if !isHashable(v.Index(i)) { - return false - } - } - } - return true -} - -func isNilValue(v reflect.Value) bool { - if !v.IsValid() { - return true - } - switch v.Kind() { - case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: - return v.IsNil() - } - return false -} - -func (d *Differ) tryDetectMove(v reflect.Value, path string, ctx *diffContext) (fromPath string, isMove bool, found bool) { - if !d.config.detectMoves { - return "", false, false - } - key := getHashKey(v) - if key == nil || !isHashable(v) { - return "", false, false - } - fromPath, found = ctx.valueIndex[key] - if !found || fromPath == path { - return "", false, false - } - isMove = ctx.movedPaths[fromPath] - return fromPath, isMove, true -} - -func getHashKey(v reflect.Value) any { - if !v.IsValid() { + res := Patch[T]{} + p.Walk(func(path string, op engine.OpKind, old, new any) error { + res.Operations = append(res.Operations, Operation{ + Kind: op, + Path: path, + Old: old, + New: new, + }) return nil - } - if v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { - if v.IsNil() { - return nil - } - // For pointers/interfaces, use the pointer value as key to ensure - // consistent identity regardless of the interface type it's wrapped in. - if v.Kind() == reflect.Pointer { - return v.Pointer() - } - // For interfaces, recurse to get the underlying pointer or value. - return getHashKey(v.Elem()) - } - if v.CanInterface() { - return v.Interface() - } - return nil -} - -func (d *Differ) indexValues(v reflect.Value, ctx *diffContext) { - if !v.IsValid() { - return - } - - key := getHashKey(v) - if key != nil && isHashable(v) { - if _, ok := ctx.valueIndex[key]; !ok { - ctx.valueIndex[key] = ctx.buildPath() - } - } - - kind := v.Kind() - if kind == reflect.Pointer || kind == reflect.Interface { - if v.IsNil() { - return - } - if kind == reflect.Pointer { - ptr := v.Pointer() - if ctx.visited[core.VisitKey{A: ptr}] { - return - } - ctx.visited[core.VisitKey{A: ptr}] = true - } - d.indexValues(v.Elem(), ctx) - return - } - - switch kind { - case reflect.Struct: - info := core.GetTypeInfo(v.Type()) - for _, fInfo := range info.Fields { - if fInfo.Tag.Ignore { - continue - } - ctx.pathStack = append(ctx.pathStack, fInfo.Name) - d.indexValues(v.Field(fInfo.Index), ctx) - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - } - case reflect.Slice, reflect.Array: - for i := 0; i < v.Len(); i++ { - ctx.pathStack = append(ctx.pathStack, strconv.Itoa(i)) - d.indexValues(v.Index(i), ctx) - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - } - case reflect.Map: - iter := v.MapRange() - for iter.Next() { - k := iter.Key() - ck := k.Interface() - if keyer, ok := ck.(Keyer); ok { - ck = keyer.CanonicalKey() - } - ctx.pathStack = append(ctx.pathStack, fmt.Sprintf("%v", ck)) - d.indexValues(iter.Value(), ctx) - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - } - } -} - -func (d *Differ) isValueAtTarget(path string, val any, ctx *diffContext) bool { - if !ctx.rootB.IsValid() { - return false - } - targetVal, err := core.DeepPath(path).Resolve(ctx.rootB) - if err != nil { - return false - } - if !targetVal.IsValid() { - return false - } - return core.Equal(targetVal.Interface(), val) -} - -func (d *Differ) diffRecursive(a, b reflect.Value, atomic bool, ctx *diffContext) (diffPatch, error) { - if len(d.config.ignoredPaths) > 0 { - if d.config.ignoredPaths[ctx.buildPath()] { - return nil, nil - } - } - - if !a.IsValid() && !b.IsValid() { - return nil, nil - } - - if atomic { - if core.ValueEqual(a, b, nil) { - return nil, nil - } - return newValuePatch(core.DeepCopyValue(a), core.DeepCopyValue(b)), nil - } - - if !a.IsValid() || !b.IsValid() { - if !b.IsValid() { - return newValuePatch(a, reflect.Value{}), nil - } - return newValuePatch(a, b), nil - } - - if a.Type() != b.Type() { - return newValuePatch(a, b), nil - } - - if a.Kind() == reflect.Struct || a.Kind() == reflect.Map || a.Kind() == reflect.Slice { - if !atomic { - // Skip valueEqual and recurse - } else { - if core.ValueEqual(a, b, nil) { - return nil, nil - } - } - } else { - // Basic types equality handled by Kind switch below. - } - - if fn, ok := d.customDiffs[a.Type()]; ok { - return fn(a, b, ctx) - } - - // Move/Copy Detection - if fromPath, isMove, ok := d.tryDetectMove(b, ctx.buildPath(), ctx); ok { - if isMove { - return &movePatch{from: fromPath, path: ctx.buildPath()}, nil - } - return ©Patch{from: fromPath}, nil - } - - if a.CanInterface() { - if a.Kind() == reflect.Struct || a.Kind() == reflect.Pointer { - method := a.MethodByName("Diff") - if method.IsValid() && method.Type().NumIn() == 1 && method.Type().NumOut() == 2 { - if method.Type().In(0) == a.Type() && - method.Type().Out(1).Implements(reflect.TypeOf((*error)(nil)).Elem()) { - res := method.Call([]reflect.Value{b}) - if !res[1].IsNil() { - return nil, res[1].Interface().(error) - } - if res[0].IsNil() { - return nil, nil - } - if unwrapper, ok := res[0].Interface().(patchUnwrapper); ok { - return unwrapper.unwrap(), nil - } - return &customDiffPatch{patch: res[0].Interface()}, nil - } - } - } - } - - switch a.Kind() { - case reflect.Bool: - if a.Bool() == b.Bool() { - return nil, nil - } - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - if a.Int() == b.Int() { - return nil, nil - } - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - if a.Uint() == b.Uint() { - return nil, nil - } - case reflect.Float32, reflect.Float64: - if a.Float() == b.Float() { - return nil, nil - } - case reflect.Complex64, reflect.Complex128: - if a.Complex() == b.Complex() { - return nil, nil - } - case reflect.String: - if a.String() == b.String() { - return nil, nil - } - case reflect.Pointer: - return d.diffPtr(a, b, ctx) - case reflect.Interface: - return d.diffInterface(a, b, ctx) - case reflect.Struct: - return d.diffStruct(a, b, ctx) - case reflect.Slice: - return d.diffSlice(a, b, ctx) - case reflect.Map: - return d.diffMap(a, b, ctx) - case reflect.Array: - return d.diffArray(a, b, ctx) - default: - if a.Kind() == reflect.Func || a.Kind() == reflect.Chan || a.Kind() == reflect.UnsafePointer { - if a.IsNil() && b.IsNil() { - return nil, nil - } - } - } - - // Default: if types are basic and immutable, return valuePatch without deep copy. - k := a.Kind() - if (k >= reflect.Bool && k <= reflect.String) || k == reflect.Float32 || k == reflect.Float64 || k == reflect.Complex64 || k == reflect.Complex128 { - return newValuePatch(a, b), nil - } - return newValuePatch(core.DeepCopyValue(a), core.DeepCopyValue(b)), nil -} - -func (d *Differ) diffPtr(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { - if a.IsNil() && b.IsNil() { - return nil, nil - } - if a.IsNil() { - return newValuePatch(a, b), nil - } - if b.IsNil() { - return newValuePatch(core.DeepCopyValue(a), reflect.Zero(a.Type())), nil - } - - if a.Pointer() == b.Pointer() { - return nil, nil - } - - k := core.VisitKey{A: a.Pointer(), B: b.Pointer(), Typ: a.Type()} - if ctx.visited[k] { - return nil, nil - } - ctx.visited[k] = true - - elemPatch, err := d.diffRecursive(a.Elem(), b.Elem(), false, ctx) - if err != nil { - return nil, err - } - if elemPatch == nil { - return nil, nil - } - - return newPtrPatch(elemPatch), nil -} - -func (d *Differ) diffInterface(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { - if a.IsNil() && b.IsNil() { - return nil, nil - } - if a.IsNil() || b.IsNil() { - if !b.IsValid() { - return newValuePatch(a, reflect.Value{}), nil - } - return newValuePatch(a, b), nil - } - - if a.Elem().Type() != b.Elem().Type() { - return newValuePatch(a, b), nil - } - - elemPatch, err := d.diffRecursive(a.Elem(), b.Elem(), false, ctx) - if err != nil { - return nil, err - } - if elemPatch == nil { - return nil, nil - } - - return &interfacePatch{elemPatch: elemPatch}, nil -} - -func (d *Differ) diffStruct(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { - var fields map[string]diffPatch - info := core.GetTypeInfo(b.Type()) - - for _, fInfo := range info.Fields { - if fInfo.Tag.Ignore { - continue - } - - var fA reflect.Value - if a.IsValid() { - fA = a.Field(fInfo.Index) - if !fA.CanInterface() { - unsafe.DisableRO(&fA) - } - } - fB := b.Field(fInfo.Index) - if !fB.CanInterface() { - unsafe.DisableRO(&fB) - } - - ctx.pathStack = append(ctx.pathStack, fInfo.Name) - patch, err := d.diffRecursive(fA, fB, fInfo.Tag.Atomic, ctx) - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - - if err != nil { - return nil, err - } - if patch != nil { - if fInfo.Tag.ReadOnly { - patch = &readOnlyPatch{inner: patch} - } - if fields == nil { - fields = make(map[string]diffPatch) - } - fields[fInfo.Name] = patch - } - } - - if fields == nil { - return nil, nil - } - - sp := newStructPatch() - for k, v := range fields { - sp.fields[k] = v - } - return sp, nil -} - -func (d *Differ) diffArray(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { - indices := make(map[int]diffPatch) - - for i := 0; i < b.Len(); i++ { - ctx.pathStack = append(ctx.pathStack, strconv.Itoa(i)) - var vA reflect.Value - if a.IsValid() && i < a.Len() { - vA = a.Index(i) - } - patch, err := d.diffRecursive(vA, b.Index(i), false, ctx) - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - - if err != nil { - return nil, err - } - if patch != nil { - indices[i] = patch - } - } - - if len(indices) == 0 { - return nil, nil - } - - return &arrayPatch{indices: indices}, nil -} - -func (d *Differ) diffMap(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { - if isNilValue(a) && isNilValue(b) { - return nil, nil - } - if isNilValue(a) || isNilValue(b) { - if isNilValue(b) { - return newValuePatch(a, reflect.Value{}), nil - } - if !d.config.detectMoves { - return newValuePatch(a, b), nil - } - } - - if a.IsValid() && b.IsValid() && a.Pointer() == b.Pointer() { - return nil, nil - } - - var added map[any]reflect.Value - var removed map[any]reflect.Value - var modified map[any]diffPatch - var originalKeys map[any]any - - getCanonical := func(v reflect.Value) any { - if v.CanInterface() { - val := v.Interface() - if k, ok := val.(Keyer); ok { - ck := k.CanonicalKey() - if originalKeys == nil { - originalKeys = make(map[any]any) - } - originalKeys[ck] = val - return ck - } - return val - } - return v.Interface() - } - - bByCanonical := make(map[any]reflect.Value) - iterB := b.MapRange() - for iterB.Next() { - bByCanonical[getCanonical(iterB.Key())] = iterB.Value() - } - - if a.IsValid() { - iterA := a.MapRange() - for iterA.Next() { - k := iterA.Key() - vA := iterA.Value() - ck := getCanonical(k) - - ctx.pathStack = append(ctx.pathStack, fmt.Sprintf("%v", ck)) - vB, found := bByCanonical[ck] - if !found { - currentPath := ctx.buildPath() - if (len(d.config.ignoredPaths) == 0 || !d.config.ignoredPaths[currentPath]) && !ctx.movedPaths[currentPath] { - if removed == nil { - removed = make(map[any]reflect.Value) - } - removed[ck] = core.DeepCopyValue(vA) - } - } else { - patch, err := d.diffRecursive(vA, vB, false, ctx) - if err != nil { - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - return nil, err - } - if patch != nil { - if modified == nil { - modified = make(map[any]diffPatch) - } - modified[ck] = patch - } - delete(bByCanonical, ck) - } - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - } - } - - for ck, vB := range bByCanonical { - // Escape the key before joining - currentPath := core.JoinPath(ctx.buildPath(), core.EscapeKey(fmt.Sprintf("%v", ck))) - if len(d.config.ignoredPaths) == 0 || !d.config.ignoredPaths[currentPath] { - if fromPath, isMove, ok := d.tryDetectMove(vB, currentPath, ctx); ok { - if modified == nil { - modified = make(map[any]diffPatch) - } - if isMove { - modified[ck] = &movePatch{from: fromPath, path: currentPath} - } else { - modified[ck] = ©Patch{from: fromPath} - } - delete(bByCanonical, ck) - continue - } - if added == nil { - added = make(map[any]reflect.Value) - } - added[ck] = core.DeepCopyValue(vB) - } - } - - if added == nil && removed == nil && modified == nil { - return nil, nil - } - - mp := newMapPatch(b.Type().Key()) - for k, v := range added { - mp.added[k] = v - } - for k, v := range removed { - mp.removed[k] = v - } - for k, v := range modified { - mp.modified[k] = v - } - for k, v := range originalKeys { - mp.originalKeys[k] = v - } - return mp, nil -} - -func (d *Differ) diffSlice(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { - if isNilValue(a) && isNilValue(b) { - return nil, nil - } - if isNilValue(a) || isNilValue(b) { - if isNilValue(b) { - return newValuePatch(a, reflect.Value{}), nil - } - // If move detection is enabled, we don't want to just return a valuePatch - // because some elements in b might have been moved from elsewhere. - if !d.config.detectMoves { - return newValuePatch(a, b), nil - } - } - - if a.IsValid() && b.IsValid() && a.Pointer() == b.Pointer() { - return nil, nil - } - - lenA := 0 - if a.IsValid() { - lenA = a.Len() - } - lenB := b.Len() - - prefix := 0 - if a.IsValid() { - for prefix < lenA && prefix < lenB { - vA := a.Index(prefix) - vB := b.Index(prefix) - if core.ValueEqual(vA, vB, nil) { - prefix++ - } else { - break - } - } - } - - suffix := 0 - if a.IsValid() { - for suffix < (lenA-prefix) && suffix < (lenB-prefix) { - vA := a.Index(lenA - 1 - suffix) - vB := b.Index(lenB - 1 - suffix) - if core.ValueEqual(vA, vB, nil) { - suffix++ - } else { - break - } - } - } - - midAStart := prefix - midAEnd := lenA - suffix - midBStart := prefix - midBEnd := lenB - suffix - - keyField, hasKey := core.GetKeyField(b.Type().Elem()) - - if midAStart == midAEnd && midBStart < midBEnd { - var ops []sliceOp - for i := midBStart; i < midBEnd; i++ { - var prevKey any - if hasKey { - if i > 0 { - prevKey = core.ExtractKey(b.Index(i-1), keyField) - } - } - - // Move/Copy Detection - val := b.Index(i) - currentPath := core.JoinPath(ctx.buildPath(), strconv.Itoa(i)) - if fromPath, isMove, ok := d.tryDetectMove(val, currentPath, ctx); ok { - var p diffPatch - if isMove { - p = &movePatch{from: fromPath, path: currentPath} - } else { - p = ©Patch{from: fromPath} - } - - op := sliceOp{ - Kind: OpCopy, - Index: i, - Patch: p, - PrevKey: prevKey, - } - if hasKey { - op.Key = core.ExtractKey(val, keyField) - } - ops = append(ops, op) - continue - } - - op := sliceOp{ - Kind: OpAdd, - Index: i, - Val: core.DeepCopyValue(b.Index(i)), - PrevKey: prevKey, - } - if hasKey { - op.Key = core.ExtractKey(b.Index(i), keyField) - } - ops = append(ops, op) - } - return &slicePatch{ops: ops}, nil - } - - if midBStart == midBEnd && midAStart < midAEnd { - var ops []sliceOp - for i := midAEnd - 1; i >= midAStart; i-- { - currentPath := core.JoinPath(ctx.buildPath(), strconv.Itoa(i)) - if ctx.movedPaths[currentPath] { - continue - } - op := sliceOp{ - Kind: OpRemove, - Index: i, - Val: core.DeepCopyValue(a.Index(i)), - } - if hasKey { - op.Key = core.ExtractKey(a.Index(i), keyField) - } - ops = append(ops, op) - } - return &slicePatch{ops: ops}, nil - } - - if midAStart >= midAEnd && midBStart >= midBEnd { - return nil, nil - } - - ops, err := d.computeSliceEdits(a, b, midAStart, midAEnd, midBStart, midBEnd, keyField, hasKey, ctx) - if err != nil { - return nil, err - } - - return &slicePatch{ops: ops}, nil -} - -func (d *Differ) computeSliceEdits(a, b reflect.Value, aStart, aEnd, bStart, bEnd, keyField int, hasKey bool, ctx *diffContext) ([]sliceOp, error) { - n := aEnd - aStart - m := bEnd - bStart - - same := func(v1, v2 reflect.Value) bool { - if hasKey { - k1 := v1 - k2 := v2 - if k1.Kind() == reflect.Pointer { - if k1.IsNil() || k2.IsNil() { - return k1.IsNil() && k2.IsNil() - } - k1 = k1.Elem() - k2 = k2.Elem() - } - return core.ValueEqual(k1.Field(keyField), k2.Field(keyField), nil) - } - return core.ValueEqual(v1, v2, nil) - } - - max := n + m - v := make([]int, 2*max+1) - offset := max - trace := [][]int{} - - for dStep := 0; dStep <= max; dStep++ { - vc := make([]int, 2*max+1) - copy(vc, v) - trace = append(trace, vc) - - for k := -dStep; k <= dStep; k += 2 { - var x int - if k == -dStep || (k != dStep && v[k-1+offset] < v[k+1+offset]) { - x = v[k+1+offset] - } else { - x = v[k-1+offset] + 1 - } - y := x - k - for x < n && y < m && same(a.Index(aStart+x), b.Index(bStart+y)) { - x++ - y++ - } - v[k+offset] = x - if x >= n && y >= m { - return d.backtrackMyers(a, b, aStart, aEnd, bStart, bEnd, keyField, hasKey, trace, ctx) - } - } - } - - return nil, nil -} - -func (d *Differ) backtrackMyers(a, b reflect.Value, aStart, aEnd, bStart, bEnd, keyField int, hasKey bool, trace [][]int, ctx *diffContext) ([]sliceOp, error) { - var ops []sliceOp - x, y := aEnd-aStart, bEnd-bStart - offset := (aEnd - aStart) + (bEnd - bStart) - - for dStep := len(trace) - 1; dStep > 0; dStep-- { - v := trace[dStep] - k := x - y - - var prevK int - if k == -dStep || (k != dStep && v[k-1+offset] < v[k+1+offset]) { - prevK = k + 1 - } else { - prevK = k - 1 - } - - prevX := v[prevK+offset] - prevY := prevX - prevK - - for x > prevX && y > prevY { - vA := a.Index(aStart + x - 1) - vB := b.Index(bStart + y - 1) - if !core.ValueEqual(vA, vB, nil) { - ctx.pathStack = append(ctx.pathStack, fmt.Sprintf("%v", core.ExtractKey(vA, keyField))) - p, err := d.diffRecursive(vA, vB, false, ctx) - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - if err != nil { - return nil, err - } - op := sliceOp{ - Kind: OpReplace, - Index: aStart + x - 1, - Patch: p, - } - if hasKey { - op.Key = core.ExtractKey(vA, keyField) - } - ops = append(ops, op) - } - x-- - y-- - } - - if x > prevX { - currentPath := core.JoinPath(ctx.buildPath(), strconv.Itoa(aStart+x-1)) - if !ctx.movedPaths[currentPath] { - op := sliceOp{ - Kind: OpRemove, - Index: aStart + x - 1, - Val: core.DeepCopyValue(a.Index(aStart + x - 1)), - } - if hasKey { - op.Key = core.ExtractKey(a.Index(aStart+x-1), keyField) - } - ops = append(ops, op) - } - } else if y > prevY { - var prevKey any - if hasKey && (bStart+y-2 >= 0) { - prevKey = core.ExtractKey(b.Index(bStart+y-2), keyField) - } - val := b.Index(bStart + y - 1) - - currentPath := core.JoinPath(ctx.buildPath(), strconv.Itoa(aStart+x)) - if fromPath, isMove, ok := d.tryDetectMove(val, currentPath, ctx); ok { - var p diffPatch - if isMove { - p = &movePatch{from: fromPath, path: currentPath} - } else { - p = ©Patch{from: fromPath} - } - op := sliceOp{ - Kind: OpCopy, - Index: aStart + x, - Patch: p, - PrevKey: prevKey, - } - if hasKey { - op.Key = core.ExtractKey(val, keyField) - } - ops = append(ops, op) - x, y = prevX, prevY - continue - } - - op := sliceOp{ - Kind: OpAdd, - Index: aStart + x, - Val: core.DeepCopyValue(val), - PrevKey: prevKey, - } - if hasKey { - op.Key = core.ExtractKey(b.Index(bStart+y-1), keyField) - } - ops = append(ops, op) - } - x, y = prevX, prevY - } - - for x > 0 && y > 0 { - vA := a.Index(aStart + x - 1) - vB := b.Index(bStart + y - 1) - if !core.ValueEqual(vA, vB, nil) { - ctx.pathStack = append(ctx.pathStack, fmt.Sprintf("%v", core.ExtractKey(vA, keyField))) - p, err := d.diffRecursive(vA, vB, false, ctx) - ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] - if err != nil { - return nil, err - } - op := sliceOp{ - Kind: OpReplace, - Index: aStart + x - 1, - Patch: p, - } - if hasKey { - op.Key = core.ExtractKey(vA, keyField) - } - ops = append(ops, op) - } - x-- - y-- - } - - for i := 0; i < len(ops)/2; i++ { - ops[i], ops[len(ops)-1-i] = ops[len(ops)-1-i], ops[i] - } + }) - return ops, nil + return res, nil } diff --git a/diff_test.go b/diff_test.go index 62ace08..18d6dc6 100644 --- a/diff_test.go +++ b/diff_test.go @@ -1,735 +1,108 @@ -package deep +package deep_test import ( - "fmt" - "reflect" + "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/internal/testmodels" + "testing" ) -func TestDiff_Basic(t *testing.T) { - tests := []struct { - name string - a, b int - }{ - {"Same", 1, 1}, - {"Different", 1, 2}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - patch := MustDiff(tt.a, tt.b) - if tt.a == tt.b { - if patch != nil { - t.Errorf("Expected nil patch, got %v", patch) - } - } else { - if patch == nil { - t.Errorf("Expected non-nil patch") - } - val := tt.a - patch.Apply(&val) - if val != tt.b { - t.Errorf("Apply failed: expected %v, got %v", tt.b, val) - } - } - }) - } -} - -func TestDiff_Struct(t *testing.T) { - type S struct { - A int - B string - c int // unexported - } - a := S{A: 1, B: "one", c: 10} - b := S{A: 2, B: "one", c: 20} - patch := MustDiff(a, b) - if patch == nil { - t.Fatal("Expected patch") - } - patch.Apply(&a) - if a != b { - t.Errorf("Apply failed: expected %+v, got %+v", b, a) - } -} - -func TestDiff_Ptr(t *testing.T) { - v1 := 10 - v2 := 20 - tests := []struct { - name string - a, b *int - }{ - {"BothNil", nil, nil}, - {"NilToVal", nil, &v1}, - {"ValToNil", &v1, nil}, - {"ValToValSame", &v1, &v1}, - {"ValToValDiffAddr", &v1, &v2}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var input *int - if tt.a != nil { - val := *tt.a - input = &val - } - target := tt.b - patch := MustDiff(input, target) - isEqual := false - if input == nil && target == nil { - isEqual = true - } else if input != nil && target != nil && *input == *target { - isEqual = true - } - if isEqual { - if patch != nil { - t.Errorf("Expected nil patch") - } - } else { - if patch == nil { - t.Fatal("Expected patch") - } - patch.Apply(&input) - if target == nil { - if input != nil { - t.Errorf("Expected nil, got %v", input) - } - } else { - if input == nil { - t.Errorf("Expected %v, got nil", *target) - } else if *input != *target { - t.Errorf("Expected %v, got %v", *target, *input) - } - } - } - }) - } -} - -func TestDiff_Map(t *testing.T) { - tests := []struct { - name string - a, b map[string]int - }{ - { - "Add", - map[string]int{"a": 1}, - map[string]int{"a": 1, "b": 2}, - }, - { - "Remove", - map[string]int{"a": 1, "b": 2}, - map[string]int{"a": 1}, - }, - { - "Modify", - map[string]int{"a": 1}, - map[string]int{"a": 2}, - }, - { - "Mixed", - map[string]int{"a": 1, "b": 2}, - map[string]int{"a": 2, "c": 3}, - }, - { - "NilToMap", - nil, - map[string]int{"a": 1}, - }, - { - "MapToNil", - map[string]int{"a": 1}, - nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var a map[string]int - if tt.a != nil { - a = make(map[string]int) - for k, v := range tt.a { - a[k] = v - } - } - patch := MustDiff(a, tt.b) - if patch == nil { - t.Fatal("Expected patch") - } - patch.Apply(&a) - if !reflect.DeepEqual(a, tt.b) { - t.Errorf("Apply failed: expected %v, got %v", tt.b, a) - } - }) - } -} - -func TestDiff_Slice(t *testing.T) { - tests := []struct { - name string - a, b []string - }{ - { - "Append", - []string{"a"}, - []string{"a", "b"}, - }, - { - "DeleteEnd", - []string{"a", "b"}, - []string{"a"}, - }, - { - "DeleteStart", - []string{"a", "b"}, - []string{"b"}, - }, - { - "InsertStart", - []string{"b"}, - []string{"a", "b"}, - }, - { - "InsertMiddle", - []string{"a", "c"}, - []string{"a", "b", "c"}, - }, - { - "Modify", - []string{"a", "b", "c"}, - []string{"a", "X", "c"}, - }, - { - "Complex", - []string{"a", "b", "c", "d"}, - []string{"a", "c", "E", "d", "f"}, - }, - { - "NilToSlice", - nil, - []string{"a"}, - }, - { - "SliceToNil", - []string{"a"}, - nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var a []string - if tt.a != nil { - a = make([]string, len(tt.a)) - copy(a, tt.a) - } - patch := MustDiff(a, tt.b) - if patch == nil { - t.Fatal("Expected patch") - } - patch.Apply(&a) - if !reflect.DeepEqual(a, tt.b) { - t.Errorf("Apply failed: expected %v, got %v", tt.b, a) - } - }) - } -} - -func TestDiff_Array(t *testing.T) { - a := [3]int{1, 2, 3} - b := [3]int{1, 4, 3} - patch := MustDiff(a, b) - patch.Apply(&a) - if a != b { - t.Errorf("Apply failed: expected %v, got %v", b, a) - } -} - -func TestDiff_Interface(t *testing.T) { - var a any = 1 - var b any = 2 - patch := MustDiff(a, b) - patch.Apply(&a) - if a != b { - t.Errorf("Apply failed: expected %v, got %v", b, a) - } - b = "string" - patch = MustDiff(a, b) - patch.Apply(&a) - if a != b { - t.Errorf("Apply failed: expected %v, got %v", b, a) - } -} - -func TestDiff_Nested(t *testing.T) { - type Child struct { - Name string - } - type Parent struct { - C *Child - L []int - } - a := Parent{ - C: &Child{Name: "old"}, - L: []int{1, 2}, - } - b := Parent{ - C: &Child{Name: "new"}, - L: []int{1, 2, 3}, - } - patch := MustDiff(a, b) - patch.Apply(&a) - if !reflect.DeepEqual(a, b) { - t.Errorf("Apply failed") - } -} - -func TestDiff_SliceStruct(t *testing.T) { - type S struct { - ID int - V string - } - a := []S{{1, "v1"}, {2, "v2"}} - b := []S{{1, "v1"}, {2, "v2-mod"}} - patch := MustDiff(a, b) - patch.Apply(&a) - if !reflect.DeepEqual(a, b) { - t.Errorf("Apply failed") - } -} - -func TestDiff_InterfaceExhaustive(t *testing.T) { - tests := []struct { - name string - a, b any - }{ - {"NilToNil", nil, nil}, - {"NilToVal", nil, 1}, - {"ValToNil", 1, nil}, - {"SameTypeDiffVal", 1, 2}, - {"DiffType", 1, "string"}, - {"SameTypeNestedDiff", map[string]int{"a": 1}, map[string]int{"a": 2}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - a := tt.a - patch := MustDiff(a, tt.b) - if tt.a == nil && tt.b == nil { - if patch != nil { - t.Errorf("Expected nil patch") - } - return - } - if patch == nil { - t.Fatal("Expected patch") - } - patch.Apply(&a) - if !reflect.DeepEqual(a, tt.b) { - t.Errorf("Apply failed: expected %v, got %v", tt.b, a) - } - }) - } -} - -func TestDiff_MapExhaustive(t *testing.T) { - type S struct{ A int } - tests := []struct { - name string - a, b map[string]S - }{ - { - "ModifiedValue", - map[string]S{"a": {1}}, - map[string]S{"a": {2}}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - a := tt.a - patch := MustDiff(a, tt.b) - patch.Apply(&a) - if !reflect.DeepEqual(a, tt.b) { - t.Errorf("Apply failed: expected %v, got %v", tt.b, a) - } - }) - } -} - -type CustomTypeForDiffer struct { - Value int -} - -func (c CustomTypeForDiffer) Diff(other CustomTypeForDiffer) (Patch[CustomTypeForDiffer], error) { - if c.Value == other.Value { - return nil, nil - } - type internal CustomTypeForDiffer - p := MustDiff(internal(c), internal{Value: other.Value + 1000}) - return &typedPatch[CustomTypeForDiffer]{ - inner: p.(*typedPatch[internal]).inner, - strict: true, - }, nil -} - -func TestDiff_CustomDiffer_ValueReceiver(t *testing.T) { - a := CustomTypeForDiffer{Value: 10} - b := CustomTypeForDiffer{Value: 20} - - patch := MustDiff(a, b) - if patch == nil { - t.Fatal("Expected patch") - } - - patch.Apply(&a) - - expected := 20 + 1000 - if a.Value != expected { - t.Errorf("Custom Diff method was not called correctly: expected %d, got %d", expected, a.Value) - } -} - -type CustomPtrTypeForDiffer struct { - Value int -} - -func (c *CustomPtrTypeForDiffer) Diff(other *CustomPtrTypeForDiffer) (Patch[*CustomPtrTypeForDiffer], error) { - if (c == nil && other == nil) || (c != nil && other != nil && c.Value == other.Value) { - return nil, nil - } - - type internal CustomPtrTypeForDiffer - p := MustDiff((*internal)(c), &internal{Value: other.Value + 5000}) - return &typedPatch[*CustomPtrTypeForDiffer]{ - inner: p.(*typedPatch[*internal]).inner, - strict: true, - }, nil -} - -func TestDiff_CustomDiffer_PointerReceiver(t *testing.T) { - a := &CustomPtrTypeForDiffer{Value: 10} - b := &CustomPtrTypeForDiffer{Value: 20} - - patch := MustDiff(a, b) - if patch == nil { - t.Fatal("Expected patch") +func TestBuilder(t *testing.T) { + type Config struct { + Theme string `json:"theme"` } - patch.Apply(&a) - - expected := 20 + 5000 - if a.Value != expected { - t.Errorf("Custom Diff method (ptr receiver) was not called correctly: expected %d, got %d", expected, a.Value) - } -} - -type CustomErrorDiffer struct { - Value int -} - -func (c CustomErrorDiffer) Diff(other CustomErrorDiffer) (Patch[CustomErrorDiffer], error) { - return nil, fmt.Errorf("custom error") -} + c1 := Config{Theme: "dark"} -func TestDiff_CustomDiffer_ErrorCase(t *testing.T) { - a := CustomErrorDiffer{Value: 1} - b := CustomErrorDiffer{Value: 2} + patch := deep.Edit(&c1). + With(deep.Set(deep.Field(func(c *Config) *string { return &c.Theme }), "light")). + Build() - defer func() { - if r := recover(); r == nil { - t.Errorf("Expected panic due to custom error in Diff") - } else { - if fmt.Sprintf("%v", r) != "custom error" { - t.Errorf("Expected panic 'custom error', got '%v'", r) - } - } - }() - - MustDiff(a, b) -} - -func TestDiff_CustomDiffer_ToJSONPatch(t *testing.T) { - a := CustomTypeForDiffer{Value: 10} - b := CustomTypeForDiffer{Value: 20} - - patch := MustDiff(a, b) - if patch == nil { - t.Fatal("Expected patch") + if err := deep.Apply(&c1, patch); err != nil { + t.Fatalf("deep.Apply failed: %v", err) } - jsonPatch, err := patch.ToJSONPatch() - if err != nil { - t.Fatalf("ToJSONPatch failed: %v", err) + if c1.Theme != "light" { + t.Errorf("got %s, want light", c1.Theme) } - - expected := `[{"op":"replace","path":"/Value","value":1020}]` - if string(jsonPatch) != expected { - t.Errorf("Expected JSON patch %s, got %s", expected, string(jsonPatch)) - } -} - -type CustomNestedForJSON struct { - Inner CustomTypeForDiffer } -func TestDiff_CustomDiffer_ToJSONPatch_Nested(t *testing.T) { - a := CustomNestedForJSON{Inner: CustomTypeForDiffer{Value: 10}} - b := CustomNestedForJSON{Inner: CustomTypeForDiffer{Value: 20}} - - patch := MustDiff(a, b) - if patch == nil { - t.Fatal("Expected patch") - } - - jsonPatch, err := patch.ToJSONPatch() - if err != nil { - t.Fatalf("ToJSONPatch failed: %v", err) - } - - expected := `[{"op":"replace","path":"/Inner/Value","value":1020}]` - if string(jsonPatch) != expected { - t.Errorf("Expected JSON patch %s, got %s", expected, string(jsonPatch)) - } -} - -func TestRegisterCustomDiff(t *testing.T) { - type Custom struct { - Val string +func TestComplexBuilder(t *testing.T) { + u1 := testmodels.User{ + ID: 1, + Name: "Alice", + Roles: []string{"user"}, + Score: map[string]int{"a": 10}, } - RegisterCustomDiff(func(a, b Custom) (Patch[Custom], error) { - if a.Val == b.Val { - return nil, nil - } - builder := NewPatchBuilder[Custom]() - builder.Field("Val").Put("CUSTOM:" + b.Val) - return builder.Build() - }) + namePath := deep.Field(func(u *testmodels.User) *string { return &u.Name }) + agePath := deep.Field(func(u *testmodels.User) *int { return &u.Info.Age }) + rolesPath := deep.Field(func(u *testmodels.User) *[]string { return &u.Roles }) + scorePath := deep.Field(func(u *testmodels.User) *map[string]int { return &u.Score }) - c1 := Custom{Val: "old"} - c2 := Custom{Val: "new"} + patch := deep.Edit(&u1). + With( + deep.Set(namePath, "Alice Smith"), + deep.Set(agePath, 35), + deep.Add(deep.At(rolesPath, 1), "admin"), + deep.Set(deep.MapKey(scorePath, "b"), 20), + deep.Remove(deep.MapKey(scorePath, "a")), + ). + Build() - patch := MustDiff(c1, c2) - if patch == nil { - t.Fatal("Expected patch") + u2 := u1 + if err := deep.Apply(&u2, patch); err != nil { + t.Fatalf("deep.Apply failed: %v", err) } - target := Custom{Val: "old"} - patch.Apply(&target) - - if target.Val != "CUSTOM:new" { - t.Errorf("Expected CUSTOM:new, got %s", target.Val) + if u2.Name != "Alice Smith" { + t.Errorf("Name failed: %s", u2.Name) } -} - -type KeyedTask struct { - ID string `deep:"key"` - Status string - Value int -} - -func TestKeyedSlice_Basic(t *testing.T) { - a := []KeyedTask{ - {ID: "t1", Status: "todo", Value: 1}, - {ID: "t2", Status: "todo", Value: 2}, + if u2.Info.Age != 35 { + t.Errorf("Age failed: %d", u2.Info.Age) } - b := []KeyedTask{ - {ID: "t2", Status: "done", Value: 2}, - {ID: "t1", Status: "todo", Value: 1}, + if len(u2.Roles) != 2 || u2.Roles[1] != "admin" { + t.Errorf("Roles failed: %v", u2.Roles) } - - patch := MustDiff(a, b) - if patch == nil { - t.Fatal("Expected patch") + if u2.Score["b"] != 20 { + t.Errorf("Score failed: %v", u2.Score) } - - patch.Apply(&a) - - if len(a) != 2 { - t.Fatalf("Expected 2 tasks, got %d", len(a)) - } - - if a[0].ID != "t2" || a[0].Status != "done" { - t.Errorf("Expected t2 done at index 0, got %+v", a[0]) - } - if a[1].ID != "t1" || a[1].Status != "todo" { - t.Errorf("Expected t1 todo at index 1, got %+v", a[1]) - } -} - -func TestKeyedSlice_Ptr(t *testing.T) { - a := []*KeyedTask{ - {ID: "t1", Status: "todo"}, - {ID: "t2", Status: "todo"}, - } - b := []*KeyedTask{ - {ID: "t2", Status: "done"}, - {ID: "t1", Status: "todo"}, - } - - patch := MustDiff(a, b) - if patch == nil { - t.Fatal("Expected patch") - } - - patch.Apply(&a) - - if a[0].ID != "t2" || a[0].Status != "done" { - t.Errorf("Expected t2 done at index 0, got %+v", a[0]) - } - if a[1].ID != "t1" || a[1].Status != "todo" { - t.Errorf("Expected t1 todo at index 1, got %+v", a[1]) + if _, ok := u2.Score["a"]; ok { + t.Errorf("Score 'a' should have been removed") } } -func TestKeyedSlice_Complex(t *testing.T) { - a := []KeyedTask{ - {ID: "t1", Status: "todo"}, - {ID: "t2", Status: "todo"}, - {ID: "t3", Status: "todo"}, - } - b := []KeyedTask{ - {ID: "t3", Status: "todo"}, - {ID: "t1", Status: "done"}, - {ID: "t4", Status: "new"}, - } - - patch := MustDiff(a, b) - if patch == nil { - t.Fatal("Expected patch") - } +func TestLog(t *testing.T) { + u := testmodels.User{ID: 1, Name: "Alice"} - patch.Apply(&a) + namePath := deep.Field(func(u *testmodels.User) *string { return &u.Name }) - if len(a) != 3 { - t.Fatalf("Expected 3 tasks, got %d", len(a)) - } + p := deep.Edit(&u). + Log("Starting update"). + With(deep.Set(namePath, "Bob")). + Log("Finished update"). + Build() - if a[0].ID != "t3" || a[1].ID != "t1" || a[1].Status != "done" || a[2].ID != "t4" { - t.Errorf("Unexpected results after apply: %+v", a) - } + deep.Apply(&u, p) } -func TestDiff_MoveDetection(t *testing.T) { - type Document struct { - Title string - Content string - } - - type Workspace struct { - Drafts []Document - Archive map[string]Document - } +func TestBuilderAdvanced(t *testing.T) { + u := &testmodels.User{} + idPath := deep.Field(func(u *testmodels.User) *int { return &u.ID }) + namePath := deep.Field(func(u *testmodels.User) *string { return &u.Name }) - doc := Document{ - Title: "Move Test", - Content: "Some content", - } + p := deep.Edit(u). + Guard(deep.Eq(idPath, 1)). + With( + deep.Set(idPath, 2).Unless(deep.Eq(idPath, 1)), + ). + Build() - ws := Workspace{ - Drafts: []Document{doc}, - Archive: make(map[string]Document), - } + _ = deep.Gt(idPath, 0) + _ = deep.Lt(idPath, 10) + _ = deep.Exists(namePath) - target := Workspace{ - Drafts: []Document{}, - Archive: map[string]Document{ - "moved": doc, - }, + if p.Guard == nil || p.Guard.Op != "==" { + t.Error("Guard failed") } - - t.Run("Disabled", func(t *testing.T) { - patch := MustDiff(ws, target, DiffDetectMoves(false)) - moveCount := 0 - patch.Walk(func(path string, op OpKind, old, new any) error { - if op == OpMove { - moveCount++ - } - return nil - }) - if moveCount != 0 { - t.Errorf("Expected 0 moves when disabled, got %d", moveCount) - } - - // Verify apply still works (via copy/delete) - final, _ := Copy(ws) - err := patch.ApplyChecked(&final) - if err != nil { - t.Fatalf("Apply failed: %v", err) - } - if len(final.Drafts) != 0 || len(final.Archive) != 1 || final.Archive["moved"].Title != doc.Title { - t.Errorf("Final state incorrect: %+v", final) - } - }) - - t.Run("Enabled", func(t *testing.T) { - patch := MustDiff(ws, target, DiffDetectMoves(true)) - moveCount := 0 - var movePath string - var moveFrom string - patch.Walk(func(path string, op OpKind, old, new any) error { - if op == OpMove { - moveCount++ - movePath = path - moveFrom = old.(string) - } - return nil - }) - if moveCount != 1 { - t.Errorf("Expected 1 move when enabled, got %d", moveCount) - } - if movePath != "/Archive/moved" || moveFrom != "/Drafts/0" { - t.Errorf("Unexpected move: %s from %s", movePath, moveFrom) - } - - // Verify apply works - final, _ := Copy(ws) - err := patch.ApplyChecked(&final) - if err != nil { - t.Fatalf("Apply failed: %v", err) - } - if len(final.Drafts) != 0 || len(final.Archive) != 1 || final.Archive["moved"].Title != doc.Title { - t.Errorf("Final state incorrect: %+v", final) - } - }) - - t.Run("MapToSlice", func(t *testing.T) { - doc := &Document{ - Title: "Move Test Ptr", - Content: "Some content", - } - type WorkspacePtr struct { - Drafts []*Document - Archive map[string]*Document - } - ws := WorkspacePtr{ - Drafts: []*Document{}, - Archive: map[string]*Document{ - "d1": doc, - }, - } - target := WorkspacePtr{ - Drafts: []*Document{doc}, - Archive: map[string]*Document{}, - } - - patch := MustDiff(ws, target, DiffDetectMoves(true)) - moveCount := 0 - patch.Walk(func(path string, op OpKind, old, new any) error { - if op == OpMove { - moveCount++ - } - return nil - }) - if moveCount != 1 { - t.Errorf("Expected 1 move, got %d", moveCount) - } - - final, _ := Copy(ws) - err := patch.ApplyChecked(&final) - if err != nil { - t.Fatalf("Apply failed: %v", err) - } - if len(final.Drafts) != 1 || len(final.Archive) != 0 || final.Drafts[0].Title != doc.Title { - t.Errorf("Final state incorrect: %+v", final) - } - }) } - diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..bd4494a --- /dev/null +++ b/doc.go @@ -0,0 +1,60 @@ +// Package deep provides high-performance, type-safe deep diff, copy, equality, +// and patch-apply operations for Go values. +// +// # Architecture +// +// Deep operates on [Patch] values — flat, serializable lists of [Operation] +// records describing changes between two values of the same type. The four core +// operations are: +// +// - [Diff] computes the patch from a to b. +// - [Apply] applies a patch to a target pointer. +// - [Equal] reports whether two values are deeply equal. +// - [Clone] returns a deep copy of a value. +// +// # Code Generation +// +// For production use, run deep-gen to generate reflection-free implementations +// of all four operations for your types: +// +// //go:generate go run github.com/brunoga/deep/v5/cmd/deep-gen -type=MyType . +// +// Generated code is 4–14x faster than the reflection fallback and is used +// automatically — no API changes required. The reflection engine remains as a +// transparent fallback for types without generated code. +// +// # Patch Construction +// +// Patches can be computed via [Diff] or built manually with [Edit]. +// Typed operation constructors ([Set], [Add], [Remove], [Move], [Copy]) return +// an [Op] value that can be passed to [Builder.With] for a fluent, type-safe chain: +// +// patch := deep.Edit(&user). +// With( +// deep.Set(nameField, "Alice"), +// deep.Set(ageField, 30).If(deep.Gt(ageField, 0)), +// ). +// Guard(deep.Gt(ageField, 18)). +// Build() +// +// [Field] creates type-safe path selectors from struct field accessors. +// [At] and [MapKey] extend paths into slices and maps with full type safety. +// +// # Conditions +// +// Per-operation guards are attached to [Op] values via [Op.If] and [Op.Unless]. +// A global patch guard is set via [Builder.Guard] or [Patch.WithGuard]. Conditions +// are serializable and survive JSON round-trips. +// +// # Causality and CRDTs +// +// The [crdt] sub-package provides [crdt.LWW], a generic Last-Write-Wins +// register; [crdt.CRDT], a concurrency-safe wrapper for any type; and +// [crdt.Text], a convergent collaborative text type. +// +// # Serialization +// +// [Patch] marshals to/from JSON natively. [Patch.ToJSONPatch] and +// [ParseJSONPatch] interoperate with RFC 6902 JSON Patch (with deep +// extensions for conditions and log operations). +package deep diff --git a/engine.go b/engine.go new file mode 100644 index 0000000..7a2f1b3 --- /dev/null +++ b/engine.go @@ -0,0 +1,146 @@ +package deep + +import ( + "fmt" + "log/slog" + "reflect" + "sort" + + "github.com/brunoga/deep/v5/condition" + "github.com/brunoga/deep/v5/internal/engine" +) + +type applyConfig struct { + logger *slog.Logger +} + +func newApplyConfig(opts ...ApplyOption) applyConfig { + cfg := applyConfig{logger: slog.Default()} + for _, o := range opts { + o(&cfg) + } + return cfg +} + +// ApplyOption configures the behaviour of [Apply]. +type ApplyOption func(*applyConfig) + +// WithLogger sets the [slog.Logger] used for [OpLog] operations within a +// single [Apply] call. If not provided, [slog.Default] is used. +func WithLogger(l *slog.Logger) ApplyOption { + return func(c *applyConfig) { c.logger = l } +} + +// Apply applies a Patch to a target pointer. +// v5 prioritizes the generated Patch method but falls back to reflection if needed. +// +// Note: when a Patch has been serialized to JSON and decoded, numeric values in +// Operation.Old and Operation.New will be float64 regardless of the original type. +// This affects strict-mode Old-value checks. +func Apply[T any](target *T, p Patch[T], opts ...ApplyOption) error { + v := reflect.ValueOf(target) + if v.Kind() != reflect.Pointer || v.IsNil() { + return fmt.Errorf("target must be a non-nil pointer") + } + + cfg := newApplyConfig(opts...) + + // Dispatch to generated Patch method if available. + if patcher, ok := any(target).(interface { + Patch(Patch[T], *slog.Logger) error + }); ok { + return patcher.Patch(p, cfg.logger) + } + + // Reflection fallback. + + if p.Guard != nil { + ok, err := condition.Evaluate(v.Elem(), p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + + var errors []error + for _, op := range p.Operations { + op.Strict = p.Strict + if err := engine.ApplyOpReflectionValue(v.Elem(), op, cfg.logger); err != nil { + errors = append(errors, err) + } + } + + if len(errors) > 0 { + return &ApplyError{Errors: errors} + } + return nil +} + +// ConflictResolver defines how to resolve merge conflicts. +type ConflictResolver interface { + Resolve(path string, local, remote any) any +} + +// Merge combines two patches into a single patch, resolving conflicts. +// Operations are deduplicated by path. When both patches modify the same path, +// r.Resolve is called if r is non-nil; otherwise other's operation wins over +// base. The output operations are sorted by path for deterministic ordering. +func Merge[T any](base, other Patch[T], r ConflictResolver) Patch[T] { + latest := make(map[string]Operation, len(base.Operations)+len(other.Operations)) + + mergeOps := func(ops []Operation, isOther bool) { + for _, op := range ops { + existing, ok := latest[op.Path] + if !ok { + latest[op.Path] = op + continue + } + if r != nil { + resolvedVal := r.Resolve(op.Path, existing.New, op.New) + op.New = resolvedVal + latest[op.Path] = op + } else if isOther { + // other wins over base on conflict + latest[op.Path] = op + } + } + } + + mergeOps(base.Operations, false) + mergeOps(other.Operations, true) + + res := Patch[T]{} + res.Operations = make([]Operation, 0, len(latest)) + for _, op := range latest { + res.Operations = append(res.Operations, op) + } + sort.Slice(res.Operations, func(i, j int) bool { + return res.Operations[i].Path < res.Operations[j].Path + }) + return res +} + +// Equal returns true if a and b are deeply equal. +func Equal[T any](a, b T) bool { + if equallable, ok := any(&a).(interface { + Equal(*T) bool + }); ok { + return equallable.Equal(&b) + } + + return engine.Equal(a, b) +} + +// Clone returns a deep copy of v. +func Clone[T any](v T) T { + if copyable, ok := any(&v).(interface { + Clone() *T + }); ok { + return *copyable.Clone() + } + + res, _ := engine.Copy(v) + return res +} diff --git a/engine_test.go b/engine_test.go new file mode 100644 index 0000000..b4ecb0b --- /dev/null +++ b/engine_test.go @@ -0,0 +1,320 @@ +package deep_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/crdt" + "github.com/brunoga/deep/v5/crdt/hlc" + "github.com/brunoga/deep/v5/internal/testmodels" +) + +func TestCausality(t *testing.T) { + type Doc struct { + Title string + Score int + } + + nodeA := crdt.NewCRDT(Doc{Title: "Original", Score: 0}, "node-a") + nodeB := crdt.NewCRDT(Doc{Title: "Original", Score: 0}, "node-b") + + // Node A updates Title; node B updates Score concurrently. + deltaA := nodeA.Edit(func(d *Doc) { d.Title = "Updated" }) + deltaB := nodeB.Edit(func(d *Doc) { d.Score = 42 }) + + // Both nodes apply both deltas — should converge. + nodeA.ApplyDelta(deltaB) + nodeB.ApplyDelta(deltaA) + + vA, vB := nodeA.View(), nodeB.View() + if vA != vB { + t.Errorf("nodes did not converge: A=%+v B=%+v", vA, vB) + } + if vA.Title != "Updated" || vA.Score != 42 { + t.Errorf("wrong converged state: %+v", vA) + } + + // Stale delta: applying an older edit after a newer one should be a no-op. + stale := nodeA.Edit(func(d *Doc) { d.Title = "Stale" }) + _ = nodeA.Edit(func(d *Doc) { d.Title = "Definitive" }) + nodeA.ApplyDelta(stale) + if nodeA.View().Title != "Definitive" { + t.Errorf("stale delta overwrote newer update") + } +} + +func TestApplyOperation(t *testing.T) { + u := testmodels.User{ + ID: 1, + Name: "Alice", + Bio: crdt.Text{{Value: "Hello"}}, + } + + p := deep.Patch[testmodels.User]{} + p.Operations = append(p.Operations, deep.Operation{ + Kind: deep.OpReplace, + Path: "/full_name", + New: "Bob", + }) + + if err := deep.Apply(&u, p); err != nil { + t.Fatalf("Apply failed: %v", err) + } + + if u.Name != "Bob" { + t.Errorf("expected Bob, got %s", u.Name) + } +} + +func TestApplyError(t *testing.T) { + err1 := fmt.Errorf("error 1") + err2 := fmt.Errorf("error 2") + ae := &deep.ApplyError{Errors: []error{err1, err2}} + + s := ae.Error() + if !strings.Contains(s, "2 errors during apply") { + t.Errorf("expected 2 errors message, got %s", s) + } + if !strings.Contains(s, "error 1") || !strings.Contains(s, "error 2") { + t.Errorf("missing individual errors in message: %s", s) + } + + aeSingle := &deep.ApplyError{Errors: []error{err1}} + if aeSingle.Error() != "error 1" { + t.Errorf("expected error 1, got %s", aeSingle.Error()) + } +} + +func TestNilMapDiff(t *testing.T) { + type S struct { + M map[string]int + } + // nil source map → all keys should produce OpAdd, not OpReplace + a := S{M: nil} + b := S{M: map[string]int{"x": 1, "y": 2}} + p, err := deep.Diff(a, b) + if err != nil { + t.Fatalf("Diff failed: %v", err) + } + for _, op := range p.Operations { + if op.Kind != deep.OpReplace && op.Kind != deep.OpAdd { + continue + } + if op.Kind == deep.OpReplace { + t.Errorf("Diff with nil source map should emit OpAdd, got OpReplace at %s", op.Path) + } + } +} + +func TestReflectionEngineAdvanced(t *testing.T) { + type Data struct { + A int + B int + } + d := &Data{A: 1, B: 2} + + p := deep.Patch[Data]{} + p.Operations = []deep.Operation{ + {Kind: deep.OpMove, Path: "/B", Old: "/A"}, + {Kind: deep.OpCopy, Path: "/A", Old: "/B"}, + {Kind: deep.OpRemove, Path: "/A"}, + } + + if err := deep.Apply(d, p); err != nil { + t.Errorf("Apply failed: %v", err) + } +} + +func TestEngineFailures(t *testing.T) { + u := &testmodels.User{} + + // Move from non-existent + p1 := deep.Patch[testmodels.User]{} + p1.Operations = []deep.Operation{{Kind: deep.OpMove, Path: "/id", Old: "/nonexistent"}} + deep.Apply(u, p1) + + // Copy from non-existent + p2 := deep.Patch[testmodels.User]{} + p2.Operations = []deep.Operation{{Kind: deep.OpCopy, Path: "/id", Old: "/nonexistent"}} + deep.Apply(u, p2) + + // Apply to nil + if err := deep.Apply((*testmodels.User)(nil), p1); err == nil { + t.Error("Apply to nil should fail") + } +} + +func TestFinalPush(t *testing.T) { + // 1. All deep.OpKinds + for i := 0; i < 10; i++ { + _ = deep.OpKind(i).String() + } + + // 2. Nested delegation failure (nil field) + type NestedNil struct { + User *testmodels.User + } + nn := &NestedNil{} + deep.Apply(nn, deep.Patch[NestedNil]{Operations: []deep.Operation{{Kind: deep.OpReplace, Path: "/User/id", New: 1}}}) +} + +func TestReflectionEqualCopy(t *testing.T) { + type Simple struct { + A int + } + s1 := Simple{A: 1} + s2 := Simple{A: 2} + + if deep.Equal(s1, s2) { + t.Error("deep.Equal failed for different simple structs") + } + + s3 := deep.Clone(s1) + if s3.A != 1 { + t.Error("deep.Clone failed for simple struct") + } +} + +func TestTextAdvanced(t *testing.T) { + clock := hlc.NewClock("node-a") + t1 := clock.Now() + t2 := clock.Now() + + // Complex ordering + text := crdt.Text{ + {ID: t2, Value: "world", Prev: t1}, + {ID: t1, Value: "hello "}, + } + + s := text.String() + if s != "hello world" { + t.Errorf("expected hello world, got %q", s) + } + + text2 := crdt.Text{{Value: "old"}} + p2 := deep.Patch[crdt.Text]{Operations: []deep.Operation{ + {Kind: deep.OpReplace, Path: "/", New: crdt.Text{{Value: "new"}}}, + }} + text2.Patch(p2, nil) +} + +func BenchmarkDiffGenerated(b *testing.B) { + u1 := testmodels.User{ID: 1, Name: "Alice", Roles: []string{"admin", "user"}} + u2 := testmodels.User{ID: 1, Name: "Bob", Roles: []string{"admin"}} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + deep.Diff(u1, u2) + } +} + +func BenchmarkDiffReflection(b *testing.B) { + type SimpleData struct { + A int + B string + C []string + } + a := SimpleData{A: 1, B: "Alice", C: []string{"admin", "user"}} + c := SimpleData{A: 1, B: "Bob", C: []string{"admin"}} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + deep.Diff(a, c) + } +} + +func BenchmarkEqualGenerated(b *testing.B) { + u1 := testmodels.User{ID: 1, Name: "Alice", Roles: []string{"admin", "user"}} + u2 := testmodels.User{ID: 1, Name: "Alice", Roles: []string{"admin", "user"}} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + deep.Equal(u1, u2) + } +} + +func BenchmarkEqualReflection(b *testing.B) { + type SimpleData struct { + A int + B string + C []string + } + a := SimpleData{A: 1, B: "Alice", C: []string{"admin", "user"}} + c := SimpleData{A: 1, B: "Alice", C: []string{"admin", "user"}} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + deep.Equal(a, c) + } +} + +func BenchmarkCopyGenerated(b *testing.B) { + u := testmodels.User{ + ID: 1, + Name: "Alice", + Roles: []string{"admin", "user"}, + Score: map[string]int{"chess": 1500, "go": 2000}, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + deep.Clone(u) + } +} + +func BenchmarkCopyReflection(b *testing.B) { + type SimpleData struct { + A int + B string + C []string + D map[string]int + } + a := SimpleData{ + A: 1, + B: "Alice", + C: []string{"admin", "user"}, + D: map[string]int{"chess": 1500, "go": 2000}, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + deep.Clone(a) + } +} + +func BenchmarkApplyGenerated(b *testing.B) { + u1 := testmodels.User{ID: 1, Name: "Alice"} + u2 := testmodels.User{ID: 1, Name: "Bob"} + p, err := deep.Diff(u1, u2) + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + u3 := u1 + deep.Apply(&u3, p) + } +} + +func BenchmarkApplyReflection(b *testing.B) { + // SimpleData has no generated code, forcing the reflection engine path. + type SimpleData struct { + A int + B string + } + a := SimpleData{A: 1, B: "hello"} + c := SimpleData{A: 2, B: "world"} + p, err := deep.Diff(a, c) + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + d := a + deep.Apply(&d, p) + } +} diff --git a/equal_test.go b/equal_test.go deleted file mode 100644 index f9722a8..0000000 --- a/equal_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package deep - -import ( - "testing" -) - -func TestEqual_Basic(t *testing.T) { - if !Equal(1, 1) { - t.Error("1 != 1") - } - if Equal(1, 2) { - t.Error("1 == 2") - } -} - -func TestEqual_Options(t *testing.T) { - type S struct { - A int - B string - } - s1 := S{A: 1, B: "foo"} - s2 := S{A: 1, B: "bar"} - - if Equal(s1, s2) { - t.Error("s1 == s2 without ignore") - } - - if !Equal(s1, s2, IgnorePath("B")) { - t.Error("s1 != s2 with ignore B") - } - - // Test pointer - if !Equal(&s1, &s2, IgnorePath("B")) { - t.Error("&s1 != &s2 with ignore B") - } -} - -func TestEqual_Map(t *testing.T) { - m1 := map[string]int{"a": 1, "b": 2} - m2 := map[string]int{"a": 1, "b": 3} - - if Equal(m1, m2) { - t.Error("m1 == m2 without ignore") - } - - if !Equal(m1, m2, IgnorePath("/b")) { - t.Error("m1 != m2 with ignore /b") - } -} - -func TestEqual_Slice(t *testing.T) { - sl1 := []int{1, 2, 3} - sl2 := []int{1, 4, 3} - - if Equal(sl1, sl2) { - t.Error("sl1 == sl2 without ignore") - } - - if !Equal(sl1, sl2, IgnorePath("/1")) { - t.Error("sl1 != sl2 with ignore /1") - } -} diff --git a/examples/atomic_config/main.go b/examples/atomic_config/main.go new file mode 100644 index 0000000..3e7edff --- /dev/null +++ b/examples/atomic_config/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "github.com/brunoga/deep/v5" + "log" +) + +type ProxyConfig struct { + Host string `json:"host"` + Port int `json:"port"` +} + +type SystemMeta struct { + ClusterID string `deep:"readonly" json:"cid"` + Settings ProxyConfig `deep:"atomic" json:"proxy"` +} + +func main() { + meta := SystemMeta{ + ClusterID: "PROD-US-1", + Settings: ProxyConfig{Host: "localhost", Port: 8080}, + } + + fmt.Println("--- INITIAL STATE ---") + fmt.Printf("%+v\n", meta) + + // 1. Attempt to change the read-only field. + p1 := deep.Patch[SystemMeta]{} + p1.Operations = append(p1.Operations, deep.Operation{ + Kind: deep.OpReplace, Path: "/cid", New: "HACKED-CLUSTER", + }) + + fmt.Println("\n--- READ-ONLY ENFORCEMENT ---") + if err := deep.Apply(&meta, p1); err != nil { + fmt.Printf("REJECTED: %v\n", err) + } + + // 2. Demonstrate atomic update: deep:"atomic" causes the entire Settings + // block to be replaced as a unit rather than field-by-field. + newSettings := ProxyConfig{Host: "proxy.internal", Port: 9000} + p2, err := deep.Diff(meta, SystemMeta{ClusterID: meta.ClusterID, Settings: newSettings}) + if err != nil { + log.Fatal(err) + } + + fmt.Println("\n--- ATOMIC SETTINGS UPDATE ---") + fmt.Println(p2) + + deep.Apply(&meta, p2) + fmt.Printf("Result: %+v\n", meta) +} diff --git a/examples/atomic_config/proxyconfig_deep.go b/examples/atomic_config/proxyconfig_deep.go new file mode 100644 index 0000000..2efca95 --- /dev/null +++ b/examples/atomic_config/proxyconfig_deep.go @@ -0,0 +1,490 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" +) + +// Patch applies p to t using the generated fast path. +func (t *ProxyConfig) Patch(p deep.Patch[ProxyConfig], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *ProxyConfig) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(ProxyConfig)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(ProxyConfig); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/host", "/Host": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Host) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Host != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Host) + } + } + if v, ok := op.New.(string); ok { + t.Host = v + return true, nil + } + case "/port", "/Port": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Port) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Port == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Port) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Port) + } + } + if v, ok := op.New.(int); ok { + t.Port = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Port = int(f) + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *ProxyConfig) Diff(other *ProxyConfig) deep.Patch[ProxyConfig] { + p := deep.Patch[ProxyConfig]{} + if t.Host != other.Host { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/host", Old: t.Host, New: other.Host}) + } + if t.Port != other.Port { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/port", Old: t.Port, New: other.Port}) + } + + return p +} + +func (t *ProxyConfig) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/host", "/Host": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Host, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Host)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Host") + } + switch c.Op { + case "==": + return t.Host == _sv, nil + case "!=": + return t.Host != _sv, nil + case ">": + return t.Host > _sv, nil + case "<": + return t.Host < _sv, nil + case ">=": + return t.Host >= _sv, nil + case "<=": + return t.Host <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Host == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Host == sv { + return true, nil + } + } + } + return false, nil + } + case "/port", "/Port": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Port, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Port)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Port") + } + _fv := float64(t.Port) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Port == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Port == iv { + return true, nil + } + case float64: + if float64(t.Port) == iv { + return true, nil + } + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *ProxyConfig) Equal(other *ProxyConfig) bool { + if t.Host != other.Host { + return false + } + if t.Port != other.Port { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *ProxyConfig) Clone() *ProxyConfig { + res := &ProxyConfig{ + Host: t.Host, + Port: t.Port, + } + return res +} + +// Patch applies p to t using the generated fast path. +func (t *SystemMeta) Patch(p deep.Patch[SystemMeta], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *SystemMeta) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(SystemMeta)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(SystemMeta); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/cid", "/ClusterID": + return true, fmt.Errorf("field %s is read-only", op.Path) + case "/proxy", "/Settings": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Settings) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.(ProxyConfig); !ok || !deep.Equal(t.Settings, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Settings) + } + } + if v, ok := op.New.(ProxyConfig); ok { + t.Settings = v + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *SystemMeta) Diff(other *SystemMeta) deep.Patch[SystemMeta] { + p := deep.Patch[SystemMeta]{} + if t.ClusterID != other.ClusterID { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/cid", Old: t.ClusterID, New: other.ClusterID}) + } + if t.Settings != other.Settings { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/proxy", Old: t.Settings, New: other.Settings}) + } + + return p +} + +func (t *SystemMeta) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/cid", "/ClusterID": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.ClusterID, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.ClusterID)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field ClusterID") + } + switch c.Op { + case "==": + return t.ClusterID == _sv, nil + case "!=": + return t.ClusterID != _sv, nil + case ">": + return t.ClusterID > _sv, nil + case "<": + return t.ClusterID < _sv, nil + case ">=": + return t.ClusterID >= _sv, nil + case "<=": + return t.ClusterID <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.ClusterID == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.ClusterID == sv { + return true, nil + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *SystemMeta) Equal(other *SystemMeta) bool { + if t.ClusterID != other.ClusterID { + return false + } + if !(&t.Settings).Equal((&other.Settings)) { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *SystemMeta) Clone() *SystemMeta { + res := &SystemMeta{ + ClusterID: t.ClusterID, + } + res.Settings = *(&t.Settings).Clone() + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/audit_logging/main.go b/examples/audit_logging/main.go index 683c163..d809557 100644 --- a/examples/audit_logging/main.go +++ b/examples/audit_logging/main.go @@ -1,76 +1,78 @@ +//go:generate go run github.com/brunoga/deep/v5/cmd/deep-gen -type=User . + package main import ( "fmt" - "strings" + "log" + "log/slog" + "os" - "github.com/brunoga/deep/v4" + "github.com/brunoga/deep/v5" ) -// User represents a typical user profile in a system. type User struct { - ID int - Name string - Email string - Roles []string + Name string `json:"name"` + Email string `json:"email"` + Tags map[string]bool `json:"tags"` } func main() { - // 1. Initial state of the user. - userA := User{ - ID: 1, + u1 := User{ Name: "Alice", Email: "alice@example.com", - Roles: []string{"user"}, + Tags: map[string]bool{"user": true}, } - // 2. Modified state of the user. - // We've changed the name, email, and added a role. - userB := User{ - ID: 1, + u2 := User{ Name: "Alice Smith", Email: "alice.smith@example.com", - Roles: []string{"user", "admin"}, + Tags: map[string]bool{"user": true, "admin": true}, } - // 3. Generate a patch representing the difference. - patch := deep.MustDiff(userA, userB) - if patch == nil { - fmt.Println("No changes detected.") - return + // --- Pattern 1: diff-based audit trail --- + // Diff captures the old and new values for every changed field. + patch, err := deep.Diff(u1, u2) + if err != nil { + log.Fatal(err) } - // 4. Use the Walk API to generate an audit log. - // This is much better than just printing the struct, as it tells us - // exactly WHAT changed, from WHAT, to WHAT. - fmt.Println("AUDIT LOG:") - fmt.Println("----------") - - err := patch.Walk(func(path string, op deep.OpKind, old, new any) error { - switch op { + fmt.Println("--- AUDIT LOG ---") + for _, op := range patch.Operations { + switch op.Kind { case deep.OpReplace: - fmt.Printf("Modified field '%s': %v -> %v\n", path, old, new) + fmt.Printf(" MODIFY %s: %v → %v\n", op.Path, op.Old, op.New) case deep.OpAdd: - // For slice elements, path will look like "Roles[1]" - if strings.Contains(path, "[") { - fmt.Printf("Added to list '%s': %v\n", path, new) - } else { - fmt.Printf("Set new field '%s': %v\n", path, new) - } + fmt.Printf(" ADD %s: %v\n", op.Path, op.New) case deep.OpRemove: - fmt.Printf("Removed field/item '%s' (was: %v)\n", path, old) + fmt.Printf(" REMOVE %s: %v\n", op.Path, op.Old) } - return nil - }) - - if err != nil { - fmt.Printf("Error walking patch: %v\n", err) } - // Output should look like: - // AUDIT LOG: - // ---------- - // Modified field 'Name': Alice -> Alice Smith - // Modified field 'Email': alice@example.com -> alice.smith@example.com - // Added to list 'Roles[1]': admin + // --- Pattern 2: embedded OpLog + injectable logger --- + // OpLog operations fire structured log messages during Apply. + // WithLogger routes them to any slog.Logger — useful for tracing, + // per-request loggers, or test capture. + namePath := deep.Field(func(u *User) *string { return &u.Name }) + + tracePatch := deep.Edit(&u1). + Log("applying name update"). + With(deep.Set(namePath, "Alice Smith")). + Log("name update complete"). + Build() + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + return slog.Attr{} // omit timestamp for stable output + } + return a + }, + })) + + fmt.Println("\n--- TRACED APPLY ---") + if err := deep.Apply(&u1, tracePatch, deep.WithLogger(logger)); err != nil { + log.Fatal(err) + } + fmt.Printf("Result: %+v\n", u1) } diff --git a/examples/audit_logging/user_deep.go b/examples/audit_logging/user_deep.go new file mode 100644 index 0000000..2ee2ee2 --- /dev/null +++ b/examples/audit_logging/user_deep.go @@ -0,0 +1,338 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" + "strings" +) + +// Patch applies p to t using the generated fast path. +func (t *User) Patch(p deep.Patch[User], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *User) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(User)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(User); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/name", "/Name": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Name) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Name != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Name) + } + } + if v, ok := op.New.(string); ok { + t.Name = v + return true, nil + } + case "/email", "/Email": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Email) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Email != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Email) + } + } + if v, ok := op.New.(string); ok { + t.Email = v + return true, nil + } + case "/tags", "/Tags": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Tags) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.(map[string]bool); !ok || !deep.Equal(t.Tags, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Tags) + } + } + if v, ok := op.New.(map[string]bool); ok { + t.Tags = v + return true, nil + } + default: + if strings.HasPrefix(op.Path, "/tags/") { + parts := strings.Split(op.Path[len("/tags/"):], "/") + key := parts[0] + if op.Kind == deep.OpRemove { + delete(t.Tags, key) + return true, nil + } + if t.Tags == nil { + t.Tags = make(map[string]bool) + } + if v, ok := op.New.(bool); ok { + t.Tags[key] = v + return true, nil + } + } + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *User) Diff(other *User) deep.Patch[User] { + p := deep.Patch[User]{} + if t.Name != other.Name { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/name", Old: t.Name, New: other.Name}) + } + if t.Email != other.Email { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/email", Old: t.Email, New: other.Email}) + } + if other.Tags != nil { + for k, v := range other.Tags { + if t.Tags == nil { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: fmt.Sprintf("/tags/%v", k), New: v}) + continue + } + if oldV, ok := t.Tags[k]; !ok || v != oldV { + kind := deep.OpReplace + if !ok { + kind = deep.OpAdd + } + p.Operations = append(p.Operations, deep.Operation{Kind: kind, Path: fmt.Sprintf("/tags/%v", k), Old: oldV, New: v}) + } + } + } + if t.Tags != nil { + for k, v := range t.Tags { + if other.Tags == nil || !contains(other.Tags, k) { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpRemove, Path: fmt.Sprintf("/tags/%v", k), Old: v}) + } + } + } + + return p +} + +func (t *User) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/name", "/Name": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Name, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Name)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Name") + } + switch c.Op { + case "==": + return t.Name == _sv, nil + case "!=": + return t.Name != _sv, nil + case ">": + return t.Name > _sv, nil + case "<": + return t.Name < _sv, nil + case ">=": + return t.Name >= _sv, nil + case "<=": + return t.Name <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Name == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Name == sv { + return true, nil + } + } + } + return false, nil + } + case "/email", "/Email": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Email, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Email)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Email") + } + switch c.Op { + case "==": + return t.Email == _sv, nil + case "!=": + return t.Email != _sv, nil + case ">": + return t.Email > _sv, nil + case "<": + return t.Email < _sv, nil + case ">=": + return t.Email >= _sv, nil + case "<=": + return t.Email <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Email == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Email == sv { + return true, nil + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *User) Equal(other *User) bool { + if t.Name != other.Name { + return false + } + if t.Email != other.Email { + return false + } + if len(t.Tags) != len(other.Tags) { + return false + } + for k, v := range t.Tags { + vOther, ok := other.Tags[k] + if !ok { + return false + } + if v != vOther { + return false + } + } + return true +} + +// Clone returns a deep copy of t. +func (t *User) Clone() *User { + res := &User{ + Name: t.Name, + Email: t.Email, + } + if t.Tags != nil { + res.Tags = make(map[string]bool) + for k, v := range t.Tags { + res.Tags[k] = v + } + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/business_rules/main.go b/examples/business_rules/main.go deleted file mode 100644 index 98dd31c..0000000 --- a/examples/business_rules/main.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/brunoga/deep/v4" -) - -// Account represents a financial account. -type Account struct { - ID string - Balance float64 - Status string // "Pending", "Active", "Suspended" -} - -func main() { - // 1. Current state: An account with zero balance. - acc := Account{ - ID: "ACC-123", - Balance: 0.0, - Status: "Pending", - } - - // 2. We want to update the account status to "Active". - // But we have a business rule: Status can only be "Active" IF balance > 0. - - // We use the Builder API to construct a conditional patch. - builder := deep.NewPatchBuilder[Account]() - - // Set the new status - builder.Field("Status").Set("Pending", "Active") - - // Attach a condition: ONLY apply this field update if Balance > 0. - builder.AddCondition("/Balance > 0.0") - - patch, err := builder.Build() - if err != nil { - fmt.Printf("Error building patch: %v\n", err) - return - } - - // 3. Attempt to apply the patch. - fmt.Printf("Initial Account: %+v\n", acc) - fmt.Println("Attempting activation with 0.0 balance...") - - err = patch.ApplyChecked(&acc) - if err != nil { - // This SHOULD fail because Balance is 0.0 - fmt.Printf("Update Rejected: %v\n", err) - } else { - fmt.Println("Update Successful!") - } - - // 4. Now let's update the balance and try again. - // Reset to initial for clean demonstration (since ApplyChecked is not atomic across fields) - acc = Account{ - ID: "ACC-123", - Balance: 100.0, - Status: "Pending", - } - fmt.Printf("\nUpdated Account Balance: %+v\n", acc) - fmt.Println("Attempting activation with 100.0 balance...") - - err = patch.ApplyChecked(&acc) - if err != nil { - fmt.Printf("Update Rejected: %v\n", err) - } else { - // This SHOULD succeed now. - fmt.Printf("Update Successful! New Status: %s\n", acc.Status) - } -} diff --git a/examples/concurrent_updates/main.go b/examples/concurrent_updates/main.go new file mode 100644 index 0000000..66ca5a0 --- /dev/null +++ b/examples/concurrent_updates/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "log" + + "github.com/brunoga/deep/v5" +) + +type Stock struct { + SKU string `json:"sku"` + Quantity int `json:"q"` +} + +func main() { + s := Stock{SKU: "BOLT-1", Quantity: 100} + + // User A generates a patch to decrease stock by 10. + // WithStrict(true) records current values so the patch fails if the + // state has changed by the time it is applied (optimistic locking). + rawPatch, err := deep.Diff(s, Stock{SKU: "BOLT-1", Quantity: 90}) + if err != nil { + log.Fatal(err) + } + patchA := rawPatch.AsStrict() + + // User B concurrently updates stock to 50. + s.Quantity = 50 + + fmt.Println("--- INITIAL STATE ---") + fmt.Printf("Stock: %+v (User B set quantity to 50)\n", s) + + // User A's patch was generated when quantity was 100 — it should be rejected. + fmt.Println("\n--- APPLYING STALE PATCH ---") + if err = deep.Apply(&s, patchA); err != nil { + fmt.Printf("REJECTED (optimistic lock): %v\n", err) + } else { + fmt.Printf("Applied: new quantity = %d\n", s.Quantity) + } +} diff --git a/examples/concurrent_updates/stock_deep.go b/examples/concurrent_updates/stock_deep.go new file mode 100644 index 0000000..ece9251 --- /dev/null +++ b/examples/concurrent_updates/stock_deep.go @@ -0,0 +1,294 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" +) + +// Patch applies p to t using the generated fast path. +func (t *Stock) Patch(p deep.Patch[Stock], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *Stock) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(Stock)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(Stock); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/sku", "/SKU": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.SKU) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.SKU != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.SKU) + } + } + if v, ok := op.New.(string); ok { + t.SKU = v + return true, nil + } + case "/q", "/Quantity": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Quantity) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Quantity == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Quantity) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Quantity) + } + } + if v, ok := op.New.(int); ok { + t.Quantity = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Quantity = int(f) + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *Stock) Diff(other *Stock) deep.Patch[Stock] { + p := deep.Patch[Stock]{} + if t.SKU != other.SKU { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/sku", Old: t.SKU, New: other.SKU}) + } + if t.Quantity != other.Quantity { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/q", Old: t.Quantity, New: other.Quantity}) + } + + return p +} + +func (t *Stock) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/sku", "/SKU": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.SKU, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.SKU)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field SKU") + } + switch c.Op { + case "==": + return t.SKU == _sv, nil + case "!=": + return t.SKU != _sv, nil + case ">": + return t.SKU > _sv, nil + case "<": + return t.SKU < _sv, nil + case ">=": + return t.SKU >= _sv, nil + case "<=": + return t.SKU <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.SKU == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.SKU == sv { + return true, nil + } + } + } + return false, nil + } + case "/q", "/Quantity": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Quantity, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Quantity)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Quantity") + } + _fv := float64(t.Quantity) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Quantity == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Quantity == iv { + return true, nil + } + case float64: + if float64(t.Quantity) == iv { + return true, nil + } + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *Stock) Equal(other *Stock) bool { + if t.SKU != other.SKU { + return false + } + if t.Quantity != other.Quantity { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *Stock) Clone() *Stock { + res := &Stock{ + SKU: t.SKU, + Quantity: t.Quantity, + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/config_manager/config_deep.go b/examples/config_manager/config_deep.go new file mode 100644 index 0000000..838a797 --- /dev/null +++ b/examples/config_manager/config_deep.go @@ -0,0 +1,455 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" + "strings" +) + +// Patch applies p to t using the generated fast path. +func (t *Config) Patch(p deep.Patch[Config], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *Config) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(Config)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(Config); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/version", "/Version": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Version) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Version == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Version) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Version) + } + } + if v, ok := op.New.(int); ok { + t.Version = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Version = int(f) + return true, nil + } + case "/env", "/Environment": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Environment) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Environment != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Environment) + } + } + if v, ok := op.New.(string); ok { + t.Environment = v + return true, nil + } + case "/timeout", "/Timeout": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Timeout) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Timeout == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Timeout) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Timeout) + } + } + if v, ok := op.New.(int); ok { + t.Timeout = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Timeout = int(f) + return true, nil + } + case "/features", "/Features": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Features) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.(map[string]bool); !ok || !deep.Equal(t.Features, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Features) + } + } + if v, ok := op.New.(map[string]bool); ok { + t.Features = v + return true, nil + } + default: + if strings.HasPrefix(op.Path, "/features/") { + parts := strings.Split(op.Path[len("/features/"):], "/") + key := parts[0] + if op.Kind == deep.OpRemove { + delete(t.Features, key) + return true, nil + } + if t.Features == nil { + t.Features = make(map[string]bool) + } + if v, ok := op.New.(bool); ok { + t.Features[key] = v + return true, nil + } + } + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *Config) Diff(other *Config) deep.Patch[Config] { + p := deep.Patch[Config]{} + if t.Version != other.Version { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/version", Old: t.Version, New: other.Version}) + } + if t.Environment != other.Environment { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/env", Old: t.Environment, New: other.Environment}) + } + if t.Timeout != other.Timeout { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/timeout", Old: t.Timeout, New: other.Timeout}) + } + if other.Features != nil { + for k, v := range other.Features { + if t.Features == nil { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: fmt.Sprintf("/features/%v", k), New: v}) + continue + } + if oldV, ok := t.Features[k]; !ok || v != oldV { + kind := deep.OpReplace + if !ok { + kind = deep.OpAdd + } + p.Operations = append(p.Operations, deep.Operation{Kind: kind, Path: fmt.Sprintf("/features/%v", k), Old: oldV, New: v}) + } + } + } + if t.Features != nil { + for k, v := range t.Features { + if other.Features == nil || !contains(other.Features, k) { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpRemove, Path: fmt.Sprintf("/features/%v", k), Old: v}) + } + } + } + + return p +} + +func (t *Config) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/version", "/Version": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Version, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Version)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Version") + } + _fv := float64(t.Version) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Version == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Version == iv { + return true, nil + } + case float64: + if float64(t.Version) == iv { + return true, nil + } + } + } + } + return false, nil + } + case "/env", "/Environment": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Environment, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Environment)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Environment") + } + switch c.Op { + case "==": + return t.Environment == _sv, nil + case "!=": + return t.Environment != _sv, nil + case ">": + return t.Environment > _sv, nil + case "<": + return t.Environment < _sv, nil + case ">=": + return t.Environment >= _sv, nil + case "<=": + return t.Environment <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Environment == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Environment == sv { + return true, nil + } + } + } + return false, nil + } + case "/timeout", "/Timeout": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Timeout, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Timeout)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Timeout") + } + _fv := float64(t.Timeout) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Timeout == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Timeout == iv { + return true, nil + } + case float64: + if float64(t.Timeout) == iv { + return true, nil + } + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *Config) Equal(other *Config) bool { + if t.Version != other.Version { + return false + } + if t.Environment != other.Environment { + return false + } + if t.Timeout != other.Timeout { + return false + } + if len(t.Features) != len(other.Features) { + return false + } + for k, v := range t.Features { + vOther, ok := other.Features[k] + if !ok { + return false + } + if v != vOther { + return false + } + } + return true +} + +// Clone returns a deep copy of t. +func (t *Config) Clone() *Config { + res := &Config{ + Version: t.Version, + Environment: t.Environment, + Timeout: t.Timeout, + } + if t.Features != nil { + res.Features = make(map[string]bool) + for k, v := range t.Features { + res.Features[k] = v + } + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/config_manager/main.go b/examples/config_manager/main.go index 0ef9678..654868d 100644 --- a/examples/config_manager/main.go +++ b/examples/config_manager/main.go @@ -3,217 +3,50 @@ package main import ( "encoding/json" "fmt" - "strings" - "sync" + "log" - "github.com/brunoga/deep/v4" + "github.com/brunoga/deep/v5" ) -// --- CONFIGURATION SCHEMA --- - -// SystemConfig is the root configuration for our entire infrastructure. -type SystemConfig struct { - Version int `deep:"readonly" json:"version"` // Managed by server - Environment string `json:"environment"` - Server ServerConfig `json:"server"` - FeatureToggles map[string]bool `json:"feature_toggles"` - // Integrations is a keyed list. We use "Name" as the unique identifier. - Integrations []Integration `deep:"key" json:"integrations"` -} - -type ServerConfig struct { - Host string `json:"host"` - Port int `deep:"atomic" json:"port"` // Port must be updated as a whole unit - Timeout int `json:"timeout"` -} - -type Integration struct { - Name string `deep:"key" json:"name"` - URL string `json:"url"` - Enabled bool `json:"enabled"` -} - -// --- CONFIGURATION MANAGER --- - -// ConfigManager handles the live state, history, and validation of configurations. -type ConfigManager struct { - mu sync.RWMutex - - // live is the current active configuration. - live *SystemConfig - - // history stores the patches used to transition between versions. - // history[0] is the patch from v0 to v1, and so on. - history []deep.Patch[SystemConfig] -} - -func NewConfigManager(initial SystemConfig) *ConfigManager { - initial.Version = 1 - return &ConfigManager{ - live: &initial, - } -} - -// Update attempts to transition the live configuration to a new state. -func (m *ConfigManager) Update(newConfig SystemConfig) error { - m.mu.Lock() - defer m.mu.Unlock() - - // 1. Prepare the update. - // We increment the version automatically. - newConfig.Version = m.live.Version + 1 - - // 2. Generate the patch. - patch := deep.MustDiff(*m.live, newConfig) - if patch == nil { - return fmt.Errorf("no changes detected") - } - - // 3. Define Validation Rules. - // In a real app, these might be loaded from a policy engine. - // Rule A: Timeout must not exceed 60 seconds. - // Rule B: Port must be in the "safe" range ( > 1024). - builder := deep.NewPatchBuilder[SystemConfig]() - builder.AddCondition("/Server/Timeout <= 60") - builder.AddCondition("/Server/Port > 1024") - - validationRules, err := builder.Build() - if err != nil { - return fmt.Errorf("failed to build validation rules: %w", err) - } - - // We combine the user patch with our validation rules. - // Note: In this architecture, we apply the patch to a COPY first to validate. - testCopy := deep.MustCopy(*m.live) - patch.Apply(&testCopy) - - // Check validation rules. - if validationRules != nil { - if err := validationRules.ApplyChecked(&testCopy); err != nil { - return fmt.Errorf("validation failed: %w", err) - } - } - - // 4. Generate Audit Log before applying. - fmt.Printf("\n[Version %d] PROPOSING CHANGES:\n", newConfig.Version) - _ = patch.Walk(func(path string, op deep.OpKind, old, new any) error { - fmt.Printf(" - %s %s: %v -> %v\n", strings.ToUpper(op.String()), path, old, new) - return nil - }) - - // 5. Apply the patch to live state. - patch.Apply(m.live) - m.live.Version = newConfig.Version - - // 6. Record history for rollback capability. - m.history = append(m.history, patch) - - fmt.Printf("[Version %d] System state synchronized successfully.\n", m.live.Version) - return nil -} - -// Rollback reverts the live configuration to the previous version. -func (m *ConfigManager) Rollback() error { - m.mu.Lock() - defer m.mu.Unlock() - - if len(m.history) == 0 { - return fmt.Errorf("no history to rollback") - } - - // 1. Get the last patch. - lastPatch := m.history[len(m.history)-1] - - // 2. Reverse it. - undoPatch := lastPatch.Reverse() - - // 3. Apply it to live state. - undoPatch.Apply(m.live) - m.live.Version-- - - // 4. Clean up history. - m.history = m.history[:len(m.history)-1] - - fmt.Printf("\n[ROLLBACK] System reverted to Version %d.\n", m.live.Version) - return nil +type Config struct { + Version int `json:"version"` + Environment string `json:"env"` + Timeout int `json:"timeout"` + Features map[string]bool `json:"features"` } -func (m *ConfigManager) Current() string { - m.mu.RLock() - defer m.mu.RUnlock() - data, err := json.MarshalIndent(m.live, "", " ") - if err != nil { - fmt.Printf("State serialization failed: %v\n", err) - return "" - } - return string(data) -} - -// --- MAIN APPLICATION LOOP --- - func main() { - fmt.Println("=== CLOUD CONFIGURATION MANAGER STARTING ===") - - // 1. Boot system with initial configuration. - manager := NewConfigManager(SystemConfig{ + v1 := Config{ + Version: 1, Environment: "production", - Server: ServerConfig{ - Host: "api.prod.local", - Port: 8080, - Timeout: 30, - }, - FeatureToggles: map[string]bool{ - "billing_v2": false, - }, - Integrations: []Integration{ - {Name: "S3", URL: "https://s3.aws.com", Enabled: true}, - }, - }) - - fmt.Println("Initial Configuration (v1):") - fmt.Println(manager.Current()) - - // 2. Scenario: Valid Update (Adding a role and changing timeout). - update1 := deep.MustCopy(*manager.live) - update1.Server.Timeout = 45 - update1.FeatureToggles["billing_v2"] = true - update1.Integrations = append(update1.Integrations, Integration{ - Name: "Stripe", URL: "https://api.stripe.com", Enabled: true, - }) - - if err := manager.Update(update1); err != nil { - fmt.Printf("Error: %v\n", err) + Timeout: 30, + Features: map[string]bool{"billing": false}, } - // 3. Scenario: REJECTED Update (Violating business rules). - update2 := deep.MustCopy(*manager.live) - update2.Server.Timeout = 120 // TOO HIGH! (Max is 60) - update2.Server.Port = 80 // TOO LOW! (Min is 1024) + // Propose changes on a deep copy so v1 is not mutated. + v2 := deep.Clone(v1) + v2.Version = 2 + v2.Timeout = 45 + v2.Features["billing"] = true - fmt.Println("\n--- ATTEMPTING INVALID UPDATE (Timeout=120, Port=80) ---") - if err := manager.Update(update2); err != nil { - fmt.Printf("REJECTED: %v\n", err) + patch, err := deep.Diff(v1, v2) + if err != nil { + log.Fatal(err) } - // 4. Scenario: Complex reordering and modification of integrations. - update3 := deep.MustCopy(*manager.live) - // Swap order of Stripe and S3, and update Stripe URL. - update3.Integrations = []Integration{ - update3.Integrations[1], // Stripe - update3.Integrations[0], // S3 - } - update3.Integrations[0].URL = "https://stripe.com/v3" + fmt.Println("--- PROPOSED CHANGES ---") + fmt.Println(patch) - if err := manager.Update(update3); err != nil { - fmt.Printf("Error: %v\n", err) - } + // Apply to a copy of the live state. + state := deep.Clone(v1) + deep.Apply(&state, patch) + fmt.Printf("--- SYNCHRONIZED (version %d) ---\n", state.Version) - // 5. Final state check. - fmt.Println("\nFinal Configuration State:") - fmt.Println(manager.Current()) + // Rollback using the patch's own reverse. + rollback := patch.Reverse() + deep.Apply(&state, rollback) - // 6. Demonstrate Rollback. - _ = manager.Rollback() - fmt.Println("\nConfiguration State after Rollback:") - fmt.Println(manager.Current()) + fmt.Println("--- ROLLED BACK ---") + out, _ := json.MarshalIndent(state, "", " ") + fmt.Println(string(out)) } diff --git a/examples/crdt_sync/main.go b/examples/crdt_sync/main.go index 1d9015e..c0043d2 100644 --- a/examples/crdt_sync/main.go +++ b/examples/crdt_sync/main.go @@ -1,81 +1,47 @@ package main import ( - "encoding/json" "fmt" - "github.com/brunoga/deep/v4/crdt" + "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/crdt" ) -type Config struct { - Title string - Options map[string]string - Users []User `deep:"key"` -} - -type User struct { - ID int `deep:"key"` - Name string - Role string +type SharedDoc struct { + Title string `json:"title"` + Content string `json:"content"` } func main() { - initial := Config{ - Title: "Global Config", - Options: map[string]string{"theme": "light"}, - Users: []User{{ID: 1, Name: "Alice", Role: "Admin"}}, - } + initial := SharedDoc{Title: "Untitled", Content: ""} nodeA := crdt.NewCRDT(initial, "node-a") nodeB := crdt.NewCRDT(initial, "node-b") - fmt.Println("--- Initial State ---") - printState(nodeA) - - fmt.Println("\n--- Node A Edits ---") - deltaA := nodeA.Edit(func(c *Config) { - c.Title = "Updated Title (A)" - c.Options["font"] = "mono" + // Concurrent edits: A edits the title, B edits the content. + deltaA := nodeA.Edit(func(d *SharedDoc) { + d.Title = "My Document" }) - printState(nodeA) - - fmt.Println("\n--- Node B Edits (Concurrent) ---") - nodeB.Edit(func(c *Config) { - c.Title = "Final Title (B)" - c.Users[0].Role = "SuperAdmin" - c.Users = append(c.Users, User{ID: 2, Name: "Bob", Role: "User"}) + deltaB := nodeB.Edit(func(d *SharedDoc) { + d.Content = "Hello, World!" }) - printState(nodeB) - fmt.Println("\n--- Syncing Node A -> Node B ---") - if !nodeB.ApplyDelta(deltaA) { - fmt.Println("ApplyDelta failed!") - return - } - printState(nodeB) + fmt.Println("--- CONCURRENT EDITS ---") + fmt.Println("Node A: title → \"My Document\"") + fmt.Println("Node B: content → \"Hello, World!\"") - fmt.Println("\n--- Syncing Node B -> Node A (Full Merge) ---") - if !nodeA.Merge(nodeB) { - fmt.Println("Merge failed!") - return - } - printState(nodeA) + // Exchange deltas (simulate network delivery). + nodeA.ApplyDelta(deltaB) + nodeB.ApplyDelta(deltaA) - fmt.Println("\n--- Serialized State ---") - data, err := json.MarshalIndent(nodeA, "", " ") - if err != nil { - fmt.Printf("State serialization failed: %v\n", err) - return - } - fmt.Println(string(data)) -} + viewA := nodeA.View() + viewB := nodeB.View() + + fmt.Println("\n--- AFTER SYNC ---") + fmt.Printf("Node A: %+v\n", viewA) + fmt.Printf("Node B: %+v\n", viewB) -func printState[T any](c *crdt.CRDT[T]) { - val := c.View() - data, err := json.Marshal(val) - if err != nil { - fmt.Printf("Serialization failed: %v\n", err) - return + if deep.Equal(viewA, viewB) { + fmt.Println("\nSUCCESS: Both nodes converged!") } - fmt.Printf("[%s] Value: %s\n", c.NodeID(), string(data)) } diff --git a/examples/custom_types/main.go b/examples/custom_types/main.go deleted file mode 100644 index 2898f4d..0000000 --- a/examples/custom_types/main.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "fmt" - "time" - "github.com/brunoga/deep/v4" -) - -type Audit struct { - User string - Timestamp time.Time -} - -func main() { - // 1. Initial State - base := Audit{ - User: "admin", - Timestamp: time.Now(), - } - - // 2. Target State - target := base - target.Timestamp = base.Timestamp.Add(1 * time.Hour) - - // 3. Setup global custom diff logic for time.Time - deep.RegisterCustomDiff(func(a, b time.Time) (deep.Patch[time.Time], error) { - if a.Equal(b) { - return nil, nil - } - // Return an atomic replacement patch - builder := deep.NewPatchBuilder[time.Time]() - builder.Set(a, b) - return builder.Build() - }) - - fmt.Println("--- COMPARING WITH CUSTOM TYPE REGISTRY ---") - patch := deep.MustDiff(base, target) - - fmt.Println("Patch Summary:") - fmt.Println(patch.Summary()) - fmt.Println() - - // 4. Verify Application - final := base - err := patch.ApplyChecked(&final) - if err != nil { - fmt.Printf("Apply failed: %v\n", err) - return - } - - fmt.Printf("Initial: %v\n", base.Timestamp.Format(time.Kitchen)) - fmt.Printf("Final: %v\n", final.Timestamp.Format(time.Kitchen)) -} diff --git a/examples/http_patch_api/main.go b/examples/http_patch_api/main.go index d3963d0..6fca396 100644 --- a/examples/http_patch_api/main.go +++ b/examples/http_patch_api/main.go @@ -5,103 +5,59 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "net/http/httptest" - "github.com/brunoga/deep/v4" + "github.com/brunoga/deep/v5" ) -// Resource is the data we want to manage over the network. type Resource struct { ID string `json:"id"` Data string `json:"data"` Value int `json:"value"` } -// ServerState represents the server's local database. -var ServerState = map[string]*Resource{ +var serverState = map[string]*Resource{ "res-1": {ID: "res-1", Data: "Initial Data", Value: 100}, } func main() { - // --- PART 1: THE SERVER --- - // We set up a mock server that accepts patches via POST. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } + body, _ := io.ReadAll(r.Body) - // 1. Read the serialized patch from the request body. - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read request body", http.StatusBadRequest) + var patch deep.Patch[Resource] + if err := json.Unmarshal(body, &patch); err != nil { + http.Error(w, "Invalid patch", http.StatusBadRequest) return } - // 2. Deserialize the patch. - // NOTE: We use deep.NewPatch[Resource]() to get a typed patch object - // that knows how to unmarshal itself. - patch := deep.NewPatch[Resource]() - if err := json.Unmarshal(body, patch); err != nil { - http.Error(w, "Invalid patch format", http.StatusBadRequest) - return - } - - // 3. Find the local resource to update. id := r.URL.Query().Get("id") - res, ok := ServerState[id] - if !ok { - http.Error(w, "Resource not found", http.StatusNotFound) - return - } - - // 4. Apply the patch to the server's local state. - // We use ApplyChecked to ensure the update is consistent with what the client saw. - if err := patch.ApplyChecked(res); err != nil { - http.Error(w, fmt.Sprintf("Apply failed: %v", err), http.StatusConflict) + if err := deep.Apply(serverState[id], patch); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) return } - fmt.Fprintf(w, "Resource %s updated successfully", id) + fmt.Fprintf(w, "OK") })) defer server.Close() - // --- PART 2: THE CLIENT --- - // The client has a local copy of the resource. - clientLocalCopy := Resource{ID: "res-1", Data: "Initial Data", Value: 100} - - // The client makes some changes locally. - updatedCopy := clientLocalCopy - updatedCopy.Data = "Network Modified Data" - updatedCopy.Value = 250 + // Client: compute patch and send it. + c1 := Resource{ID: "res-1", Data: "Initial Data", Value: 100} + c2 := Resource{ID: "res-1", Data: "Network Modified Data", Value: 250} - // Generate a patch representing those changes. - patch := deep.MustDiff(clientLocalCopy, updatedCopy) - - // Serialize the patch to JSON for transmission. - patchJSON, err := json.Marshal(patch) + patch, err := deep.Diff(c1, c2) if err != nil { - fmt.Printf("Failed to marshal patch: %v\n", err) - return + log.Fatal(err) } + data, _ := json.Marshal(patch) - fmt.Printf("Client: Sending patch to server (%d bytes)\n", len(patchJSON)) + fmt.Println("--- CLIENT ---") + fmt.Printf("Sending patch (%d bytes)\n", len(data)) - // Send the patch via HTTP POST. - resp, err := http.Post(server.URL+"?id=res-1", "application/json", bytes.NewBuffer(patchJSON)) - if err != nil { - fmt.Printf("Client Error: %v\n", err) - return - } - defer resp.Body.Close() + resp, _ := http.Post(server.URL+"?id=res-1", "application/json", bytes.NewBuffer(data)) + io.ReadAll(resp.Body) - // --- PART 3: VERIFICATION --- - status, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Printf("Failed to read response: %v\n", err) - return - } - fmt.Printf("Server Response: %s\n", string(status)) - fmt.Printf("Server Final State for res-1: %+v\n", ServerState["res-1"]) + fmt.Println("\n--- SERVER STATE AFTER PATCH ---") + fmt.Printf("%+v\n", *serverState["res-1"]) } diff --git a/examples/http_patch_api/resource_deep.go b/examples/http_patch_api/resource_deep.go new file mode 100644 index 0000000..f1d7672 --- /dev/null +++ b/examples/http_patch_api/resource_deep.go @@ -0,0 +1,359 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" +) + +// Patch applies p to t using the generated fast path. +func (t *Resource) Patch(p deep.Patch[Resource], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *Resource) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(Resource)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(Resource); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/id", "/ID": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.ID) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.ID != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.ID) + } + } + if v, ok := op.New.(string); ok { + t.ID = v + return true, nil + } + case "/data", "/Data": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Data) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Data != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Data) + } + } + if v, ok := op.New.(string); ok { + t.Data = v + return true, nil + } + case "/value", "/Value": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Value) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Value == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Value) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Value) + } + } + if v, ok := op.New.(int); ok { + t.Value = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Value = int(f) + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *Resource) Diff(other *Resource) deep.Patch[Resource] { + p := deep.Patch[Resource]{} + if t.ID != other.ID { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/id", Old: t.ID, New: other.ID}) + } + if t.Data != other.Data { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/data", Old: t.Data, New: other.Data}) + } + if t.Value != other.Value { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/value", Old: t.Value, New: other.Value}) + } + + return p +} + +func (t *Resource) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/id", "/ID": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.ID, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.ID)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field ID") + } + switch c.Op { + case "==": + return t.ID == _sv, nil + case "!=": + return t.ID != _sv, nil + case ">": + return t.ID > _sv, nil + case "<": + return t.ID < _sv, nil + case ">=": + return t.ID >= _sv, nil + case "<=": + return t.ID <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.ID == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.ID == sv { + return true, nil + } + } + } + return false, nil + } + case "/data", "/Data": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Data, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Data)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Data") + } + switch c.Op { + case "==": + return t.Data == _sv, nil + case "!=": + return t.Data != _sv, nil + case ">": + return t.Data > _sv, nil + case "<": + return t.Data < _sv, nil + case ">=": + return t.Data >= _sv, nil + case "<=": + return t.Data <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Data == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Data == sv { + return true, nil + } + } + } + return false, nil + } + case "/value", "/Value": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Value, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Value)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Value") + } + _fv := float64(t.Value) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Value == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Value == iv { + return true, nil + } + case float64: + if float64(t.Value) == iv { + return true, nil + } + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *Resource) Equal(other *Resource) bool { + if t.ID != other.ID { + return false + } + if t.Data != other.Data { + return false + } + if t.Value != other.Value { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *Resource) Clone() *Resource { + res := &Resource{ + ID: t.ID, + Data: t.Data, + Value: t.Value, + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/json_interop/main.go b/examples/json_interop/main.go index cdbab3c..45a740d 100644 --- a/examples/json_interop/main.go +++ b/examples/json_interop/main.go @@ -3,55 +3,46 @@ package main import ( "encoding/json" "fmt" + "log" - "github.com/brunoga/deep/v4" + "github.com/brunoga/deep/v5" ) -// UIState represents something typically shared between a Go backend and a JS frontend. type UIState struct { - Theme string `json:"theme"` - SidebarOpen bool `json:"sidebarOpen"` - UserCount int `json:"userCount"` + Theme string `json:"theme"` + Open bool `json:"sidebar_open"` } func main() { - // 1. Backend has the current state. - stateA := UIState{ - Theme: "dark", - SidebarOpen: true, - UserCount: 10, - } + s1 := UIState{Theme: "dark", Open: false} + s2 := UIState{Theme: "light", Open: true} - // 2. State changes after some events. - stateB := UIState{ - Theme: "light", // Theme changed - SidebarOpen: true, - UserCount: 11, // One user joined + patch, err := deep.Diff(s1, s2) + if err != nil { + log.Fatal(err) } - // 3. Backend calculates the diff. - patch := deep.MustDiff(stateA, stateB) + // Native v5 JSON: compact wire format. + data, _ := json.MarshalIndent(patch, "", " ") + fmt.Println("--- NATIVE V5 JSON ---") + fmt.Println(string(data)) - // 4. Backend wants to send this patch to a JavaScript frontend. - // We use ToJSONPatch() which generates an RFC 6902 compliant list of ops. - jsonPatchBytes, err := patch.ToJSONPatch() + // RFC 6902 JSON Patch: human-readable, interoperable with other tools. + rfc, err := patch.ToJSONPatch() if err != nil { - fmt.Printf("Error: %v\n", err) - return + log.Fatal(err) } + fmt.Println("--- RFC 6902 JSON PATCH ---") + fmt.Println(string(rfc)) - // Print the JSON that would be sent over the wire. - fmt.Println("RFC 6902 JSON PATCH (sent to frontend):") - fmt.Println(string(jsonPatchBytes)) - - // 5. Demonstrate normal JSON serialization of the Patch object itself. - // This is useful if you want to save the patch in a database (like MongoDB) - // and restore it later in another Go service. - serializedPatch, err := json.MarshalIndent(patch, "", " ") - if err != nil { - fmt.Printf("Marshal failed: %v\n", err) - return + // Round-trip: unmarshal the native format and reapply. + var p2 deep.Patch[UIState] + if err := json.Unmarshal(data, &p2); err != nil { + log.Fatal(err) } - fmt.Println("\nINTERNAL DEEP JSON REPRESENTATION (for persistence):") - fmt.Println(string(serializedPatch)) + s3 := s1 + deep.Apply(&s3, p2) + + fmt.Println("--- ROUND-TRIP RESULT ---") + fmt.Printf("Theme: %s, Open: %v\n", s3.Theme, s3.Open) } diff --git a/examples/json_interop/uistate_deep.go b/examples/json_interop/uistate_deep.go new file mode 100644 index 0000000..4ba2fd6 --- /dev/null +++ b/examples/json_interop/uistate_deep.go @@ -0,0 +1,244 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" +) + +// Patch applies p to t using the generated fast path. +func (t *UIState) Patch(p deep.Patch[UIState], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *UIState) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(UIState)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(UIState); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/theme", "/Theme": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Theme) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Theme != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Theme) + } + } + if v, ok := op.New.(string); ok { + t.Theme = v + return true, nil + } + case "/sidebar_open", "/Open": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Open) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(bool); !ok || t.Open != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Open) + } + } + if v, ok := op.New.(bool); ok { + t.Open = v + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *UIState) Diff(other *UIState) deep.Patch[UIState] { + p := deep.Patch[UIState]{} + if t.Theme != other.Theme { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/theme", Old: t.Theme, New: other.Theme}) + } + if t.Open != other.Open { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/sidebar_open", Old: t.Open, New: other.Open}) + } + + return p +} + +func (t *UIState) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/theme", "/Theme": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Theme, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Theme)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Theme") + } + switch c.Op { + case "==": + return t.Theme == _sv, nil + case "!=": + return t.Theme != _sv, nil + case ">": + return t.Theme > _sv, nil + case "<": + return t.Theme < _sv, nil + case ">=": + return t.Theme >= _sv, nil + case "<=": + return t.Theme <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Theme == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Theme == sv { + return true, nil + } + } + } + return false, nil + } + case "/sidebar_open", "/Open": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Open, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Open)) + } + _bv, _ok := c.Value.(bool) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Open") + } + switch c.Op { + case "==": + return t.Open == _bv, nil + case "!=": + return t.Open != _bv, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *UIState) Equal(other *UIState) bool { + if t.Theme != other.Theme { + return false + } + if t.Open != other.Open { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *UIState) Clone() *UIState { + res := &UIState{ + Theme: t.Theme, + Open: t.Open, + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/key_normalization/main.go b/examples/key_normalization/main.go deleted file mode 100644 index c3d6965..0000000 --- a/examples/key_normalization/main.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "fmt" - "github.com/brunoga/deep/v4" -) - -// ResourceID represents a complex key that might have transient state. -type ResourceID struct { - Namespace string - Name string - // SessionID is transient and should not be used for logical identity - SessionID int -} - -// CanonicalKey implements deep.Keyer to normalize the key for diffing. -func (r ResourceID) CanonicalKey() any { - return fmt.Sprintf("%s:%s", r.Namespace, r.Name) -} - -func main() { - // 1. Base map with specific SessionIDs - m1 := map[ResourceID]string{ - {Namespace: "prod", Name: "api", SessionID: 100}: "Running", - } - - // 2. Target map where SessionIDs have changed (transient), but state is updated - m2 := map[ResourceID]string{ - {Namespace: "prod", Name: "api", SessionID: 200}: "Suspended", - } - - fmt.Println("--- COMPARING MAPS WITH SEMANTIC KEYS ---") - - // Without Keyer, Diff would see this as a Remove(SessionID:100) and Add(SessionID:200). - // With Keyer, it sees it as an Update to the "prod:api" resource. - patch := deep.MustDiff(m1, m2) - - fmt.Println("Patch Summary:") - fmt.Println(patch.Summary()) - fmt.Println() - - // 3. Apply the patch - final := m1 - err := patch.ApplyChecked(&final) - if err != nil { - fmt.Printf("Apply failed: %v\n", err) - } - - fmt.Println("--- FINAL MAP STATE ---") - for k, v := range final { - fmt.Printf("Key: %+v, Value: %s\n", k, v) - } -} diff --git a/examples/keyed_inventory/inventory_deep.go b/examples/keyed_inventory/inventory_deep.go new file mode 100644 index 0000000..16fafcb --- /dev/null +++ b/examples/keyed_inventory/inventory_deep.go @@ -0,0 +1,457 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" +) + +// Patch applies p to t using the generated fast path. +func (t *Item) Patch(p deep.Patch[Item], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *Item) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(Item)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(Item); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/sku", "/SKU": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.SKU) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.SKU != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.SKU) + } + } + if v, ok := op.New.(string); ok { + t.SKU = v + return true, nil + } + case "/q", "/Quantity": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Quantity) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Quantity == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Quantity) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Quantity) + } + } + if v, ok := op.New.(int); ok { + t.Quantity = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Quantity = int(f) + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *Item) Diff(other *Item) deep.Patch[Item] { + p := deep.Patch[Item]{} + if t.SKU != other.SKU { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/sku", Old: t.SKU, New: other.SKU}) + } + if t.Quantity != other.Quantity { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/q", Old: t.Quantity, New: other.Quantity}) + } + + return p +} + +func (t *Item) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/sku", "/SKU": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.SKU, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.SKU)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field SKU") + } + switch c.Op { + case "==": + return t.SKU == _sv, nil + case "!=": + return t.SKU != _sv, nil + case ">": + return t.SKU > _sv, nil + case "<": + return t.SKU < _sv, nil + case ">=": + return t.SKU >= _sv, nil + case "<=": + return t.SKU <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.SKU == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.SKU == sv { + return true, nil + } + } + } + return false, nil + } + case "/q", "/Quantity": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Quantity, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Quantity)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Quantity") + } + _fv := float64(t.Quantity) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Quantity == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Quantity == iv { + return true, nil + } + case float64: + if float64(t.Quantity) == iv { + return true, nil + } + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *Item) Equal(other *Item) bool { + if t.SKU != other.SKU { + return false + } + if t.Quantity != other.Quantity { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *Item) Clone() *Item { + res := &Item{ + SKU: t.SKU, + Quantity: t.Quantity, + } + return res +} + +// Patch applies p to t using the generated fast path. +func (t *Inventory) Patch(p deep.Patch[Inventory], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *Inventory) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(Inventory)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(Inventory); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/items", "/Items": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Items) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.([]Item); !ok || !deep.Equal(t.Items, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Items) + } + } + if v, ok := op.New.([]Item); ok { + t.Items = v + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *Inventory) Diff(other *Inventory) deep.Patch[Inventory] { + p := deep.Patch[Inventory]{} + otherByKey := make(map[any]int) + for i, v := range other.Items { + otherByKey[v.SKU] = i + } + for _, v := range t.Items { + if _, ok := otherByKey[v.SKU]; !ok { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpRemove, Path: fmt.Sprintf("/items/%v", v.SKU), Old: v}) + } + } + tByKey := make(map[any]int) + for i, v := range t.Items { + tByKey[v.SKU] = i + } + for _, v := range other.Items { + if _, ok := tByKey[v.SKU]; !ok { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpAdd, Path: fmt.Sprintf("/items/%v", v.SKU), New: v}) + } + } + + return p +} + +func (t *Inventory) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *Inventory) Equal(other *Inventory) bool { + if len(t.Items) != len(other.Items) { + return false + } + for i := range t.Items { + if t.Items[i] != other.Items[i] { + return false + } + } + return true +} + +// Clone returns a deep copy of t. +func (t *Inventory) Clone() *Inventory { + res := &Inventory{ + Items: append([]Item(nil), t.Items...), + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/keyed_inventory/main.go b/examples/keyed_inventory/main.go index d9c9674..d1237f4 100644 --- a/examples/keyed_inventory/main.go +++ b/examples/keyed_inventory/main.go @@ -2,62 +2,38 @@ package main import ( "fmt" - - "github.com/brunoga/deep/v4" + "github.com/brunoga/deep/v5" + "log" ) -// Product represents an item in an inventory. -type Product struct { - // The SKU is the unique identity of this product. - // By tagging it with `deep:"key"`, we tell the library to use this field - // to align slices instead of using indices. - SKU string `deep:"key"` - Quantity int - Price float64 +type Item struct { + SKU string `deep:"key" json:"sku"` + Quantity int `json:"q"` +} + +type Inventory struct { + Items []Item `json:"items"` } func main() { - // 1. Initial inventory (A list of products). - inventoryA := []Product{ - {SKU: "P1", Quantity: 10, Price: 19.99}, - {SKU: "P2", Quantity: 5, Price: 29.99}, - {SKU: "P3", Quantity: 100, Price: 5.99}, + inv1 := Inventory{ + Items: []Item{ + {SKU: "P1", Quantity: 10}, + {SKU: "P2", Quantity: 5}, + }, } - - // 2. New inventory state: - // - P2 moved to the front. - // - P1 had a price change and quantity change. - // - P3 was removed. - // - P4 was added. - inventoryB := []Product{ - {SKU: "P2", Quantity: 5, Price: 29.99}, - {SKU: "P1", Quantity: 8, Price: 17.99}, - {SKU: "P4", Quantity: 50, Price: 9.99}, + inv2 := Inventory{ + Items: []Item{ + {SKU: "P2", Quantity: 5}, + {SKU: "P3", Quantity: 20}, + }, } - // 3. Diff the inventories. - // Because of the `deep:"key"` tag, this diff will be SEMANTIC. - // It will understand that P1 is the same object even though it's at index 1 now. - patch := deep.MustDiff(inventoryA, inventoryB) - - // 4. Use the Walk API to see the semantic operations. - fmt.Println("INVENTORY UPDATE PLAN:") - fmt.Println("----------------------") - - _ = patch.Walk(func(path string, op deep.OpKind, old, new any) error { - switch op { - case deep.OpReplace: - fmt.Printf("[Update] Item '%s' value changed: %v -> %v\n", path, old, new) - case deep.OpAdd: - fmt.Printf("[Add] New item at '%s': %+v\n", path, new) - case deep.OpRemove: - fmt.Printf("[Remove] Item at '%s' (Value was: %+v)\n", path, old) - } - return nil - }) - - // 5. Apply the update. - deep.MustDiff(inventoryA, inventoryB).Apply(&inventoryA) + patch, err := deep.Diff(inv1, inv2) + if err != nil { + log.Fatal(err) + } - fmt.Printf("\nFinal Inventory: %+v\n", inventoryA) + fmt.Println("--- INVENTORY UPDATE ---") + fmt.Println(patch) } diff --git a/examples/lww_fields/main.go b/examples/lww_fields/main.go new file mode 100644 index 0000000..0aeaf5f --- /dev/null +++ b/examples/lww_fields/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "fmt" + + "github.com/brunoga/deep/v5/crdt" + "github.com/brunoga/deep/v5/crdt/hlc" +) + +// Profile uses per-field LWW[T] registers so that concurrent updates to +// independent fields can always be merged: the later timestamp wins. +type Profile struct { + Name crdt.LWW[string] `json:"name"` + Score crdt.LWW[int] `json:"score"` +} + +func main() { + clock := hlc.NewClock("server") + ts0 := clock.Now() + + base := Profile{ + Name: crdt.LWW[string]{Value: "Alice", Timestamp: ts0}, + Score: crdt.LWW[int]{Value: 0, Timestamp: ts0}, + } + + // Client A renames the profile; apply via LWW.Set which only accepts + // the update if the incoming timestamp is strictly newer. + tsA := clock.Now() + profileA := base + profileA.Name.Set("Alice Smith", tsA) + + // Client B increments the score concurrently. + tsB := clock.Now() + profileB := base + profileB.Score.Set(42, tsB) + + fmt.Println("--- CONCURRENT EDITS ---") + fmt.Printf("Client A: name → %q\n", profileA.Name.Value) + fmt.Printf("Client B: score → %d\n", profileB.Score.Value) + + // Manual merge: take the newer value for each field. + merged := base + merged.Name.Set(profileA.Name.Value, profileA.Name.Timestamp) + merged.Score.Set(profileB.Score.Value, profileB.Score.Timestamp) + + fmt.Println("\n--- CONVERGED RESULT ---") + fmt.Printf("Name: %s\n", merged.Name.Value) + fmt.Printf("Score: %d\n", merged.Score.Value) +} diff --git a/examples/move_detection/main.go b/examples/move_detection/main.go index ba03e9c..b4a5097 100644 --- a/examples/move_detection/main.go +++ b/examples/move_detection/main.go @@ -2,80 +2,41 @@ package main import ( "fmt" - "github.com/brunoga/deep/v4" + "log" + + "github.com/brunoga/deep/v5" ) +// Document has a draft field and a published field. +// The Move operation promotes a draft to published atomically: +// it reads the source value, clears the source, and writes to the destination. type Document struct { - Title string - Content string -} - -type Workspace struct { - Drafts []Document - Archive map[string]Document + Draft string `json:"draft"` + Published string `json:"published"` } func main() { - // 1. Initial state: A document in Drafts doc := Document{ - Title: "Breaking Changes v2", - Content: "Standardize on JSON Pointers and add Differ object...", + Draft: "My Article", + Published: "", } - ws := Workspace{ - Drafts: []Document{doc}, - Archive: make(map[string]Document), - } + fmt.Println("--- BEFORE ---") + fmt.Printf("Draft: %q Published: %q\n", doc.Draft, doc.Published) - fmt.Println("--- INITIAL WORKSPACE ---") - fmt.Printf("Drafts: %d, Archive: %d\n\n", len(ws.Drafts), len(ws.Archive)) + // Build a Move patch: /draft → /published + draftPath := deep.Field(func(d *Document) *string { return &d.Draft }) + pubPath := deep.Field(func(d *Document) *string { return &d.Published }) - // 2. Target state: Move the document from Drafts to Archive - target := Workspace{ - Drafts: []Document{}, - Archive: map[string]Document{ - "v2-release": doc, - }, - } + patch := deep.Edit(&doc).With(deep.Move(draftPath, pubPath)).Build() - // 3. Generate Patch - // The Differ will index 'ws' and find 'doc' at '/Drafts/0' - // When it sees 'doc' at '/Archive/v2-release' in 'target', it emits a Copy. - patch := deep.MustDiff(ws, target, deep.DiffDetectMoves(true)) + fmt.Println("\n--- PATCH ---") + fmt.Println(patch) - fmt.Println("--- GENERATED PATCH SUMMARY ---") - fmt.Println(patch.Summary()) - fmt.Println() - - // 4. Verify semantic efficiency - fmt.Println("--- PATCH OPERATIONS (Walk) ---") - err := patch.Walk(func(path string, op deep.OpKind, old, new any) error { - if op == deep.OpCopy { - fmt.Printf("[%s] Op: %s, From: %v\n", path, op, old) - } else { - fmt.Printf("[%s] Op: %s\n", path, op) - } - return nil - }) - if err != nil { - fmt.Printf("Walk failed: %v\n", err) - return - } - fmt.Println() - - // 5. Apply and Verify - final, err := deep.Copy(ws) - if err != nil { - fmt.Printf("Copy failed: %v\n", err) - return - } - err = patch.ApplyChecked(&final) - if err != nil { - fmt.Printf("Apply failed: %v\n", err) - return + if err := deep.Apply(&doc, patch); err != nil { + log.Fatal(err) } - fmt.Println("--- FINAL WORKSPACE ---") - fmt.Printf("Drafts: %d, Archive: %d\n", len(final.Drafts), len(final.Archive)) - fmt.Printf("Archived Doc: %s\n", final.Archive["v2-release"].Title) + fmt.Println("--- AFTER ---") + fmt.Printf("Draft: %q Published: %q\n", doc.Draft, doc.Published) } diff --git a/examples/multi_error/main.go b/examples/multi_error/main.go index 0c4f8bd..254ad4a 100644 --- a/examples/multi_error/main.go +++ b/examples/multi_error/main.go @@ -2,60 +2,32 @@ package main import ( "fmt" - "github.com/brunoga/deep/v4" + + "github.com/brunoga/deep/v5" ) -type UserProfile struct { - Username string - Age int - Email string +type StrictUser struct { + Name string `json:"name"` + Age int `json:"age"` } func main() { - // 1. Initial State - user := UserProfile{ - Username: "alice", - Age: 30, - Email: "alice@example.com", - } - - fmt.Printf("Initial User: %+v\n\n", user) - - // 2. Propose a patch with multiple "strict" expectations that are wrong. - // We'll use the Builder to create a patch that expects different values than what's there. - builder := deep.NewPatchBuilder[UserProfile]() - - // Error 1: Wrong current age expectation - builder.Field("Age").Set(25, 31) // Expects 25, but it's actually 30 - - // Error 2: Wrong current email expectation - builder.Field("Email").Set("wrong@example.com", "new@example.com") - - // Error 3: Add a condition that will also fail - builder.Field("Username").Put("bob") - // This condition will fail because Username is currently "alice" - // We use builder.AddCondition which automatically finds the right node - builder.AddCondition("Username == 'admin'") - - patch, err := builder.Build() - if err != nil { - fmt.Printf("Build failed: %v\n", err) - return + u := StrictUser{Name: "Alice", Age: 30} + + fmt.Println("--- INITIAL STATE ---") + fmt.Printf("%+v\n", u) + + // A patch with two operations referencing non-existent fields. + // Apply collects all errors rather than stopping at the first. + patch := deep.Patch[StrictUser]{ + Operations: []deep.Operation{ + {Kind: deep.OpReplace, Path: "/nonexistent", New: "fail"}, + {Kind: deep.OpReplace, Path: "/wrong_type", New: 123.456}, + }, } - // 3. Apply the patch - fmt.Println("Applying patch with multiple conflicting expectations...") - err = patch.ApplyChecked(&user) - - if err != nil { - fmt.Printf("\nPatch Application Failed with Multiple Errors:\n") - fmt.Println(err.Error()) - - // We can also inspect the individual errors if needed - if applyErr, ok := err.(*deep.ApplyError); ok { - fmt.Printf("Individual error count: %d\n", len(applyErr.Errors())) - } - } else { - fmt.Println("Patch applied successfully (unexpected!)") + fmt.Println("\n--- APPLY (invalid paths) ---") + if err := deep.Apply(&u, patch); err != nil { + fmt.Printf("ERRORS:\n%v\n", err) } } diff --git a/examples/multi_error/strictuser_deep.go b/examples/multi_error/strictuser_deep.go new file mode 100644 index 0000000..b2442db --- /dev/null +++ b/examples/multi_error/strictuser_deep.go @@ -0,0 +1,294 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" +) + +// Patch applies p to t using the generated fast path. +func (t *StrictUser) Patch(p deep.Patch[StrictUser], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *StrictUser) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(StrictUser)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(StrictUser); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/name", "/Name": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Name) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Name != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Name) + } + } + if v, ok := op.New.(string); ok { + t.Name = v + return true, nil + } + case "/age", "/Age": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Age) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Age == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Age) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Age) + } + } + if v, ok := op.New.(int); ok { + t.Age = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Age = int(f) + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *StrictUser) Diff(other *StrictUser) deep.Patch[StrictUser] { + p := deep.Patch[StrictUser]{} + if t.Name != other.Name { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/name", Old: t.Name, New: other.Name}) + } + if t.Age != other.Age { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/age", Old: t.Age, New: other.Age}) + } + + return p +} + +func (t *StrictUser) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/name", "/Name": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Name, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Name)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Name") + } + switch c.Op { + case "==": + return t.Name == _sv, nil + case "!=": + return t.Name != _sv, nil + case ">": + return t.Name > _sv, nil + case "<": + return t.Name < _sv, nil + case ">=": + return t.Name >= _sv, nil + case "<=": + return t.Name <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Name == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Name == sv { + return true, nil + } + } + } + return false, nil + } + case "/age", "/Age": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Age, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Age)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Age") + } + _fv := float64(t.Age) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Age == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Age == iv { + return true, nil + } + case float64: + if float64(t.Age) == iv { + return true, nil + } + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *StrictUser) Equal(other *StrictUser) bool { + if t.Name != other.Name { + return false + } + if t.Age != other.Age { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *StrictUser) Clone() *StrictUser { + res := &StrictUser{ + Name: t.Name, + Age: t.Age, + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/policy_engine/employee_deep.go b/examples/policy_engine/employee_deep.go new file mode 100644 index 0000000..48b96b2 --- /dev/null +++ b/examples/policy_engine/employee_deep.go @@ -0,0 +1,450 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" +) + +// Patch applies p to t using the generated fast path. +func (t *Employee) Patch(p deep.Patch[Employee], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *Employee) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(Employee)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(Employee); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/id", "/ID": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.ID) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.ID == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.ID) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.ID) + } + } + if v, ok := op.New.(int); ok { + t.ID = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.ID = int(f) + return true, nil + } + case "/name", "/Name": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Name) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Name != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Name) + } + } + if v, ok := op.New.(string); ok { + t.Name = v + return true, nil + } + case "/role", "/Role": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Role) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Role != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Role) + } + } + if v, ok := op.New.(string); ok { + t.Role = v + return true, nil + } + case "/rating", "/Rating": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Rating) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Rating == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Rating) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Rating) + } + } + if v, ok := op.New.(int); ok { + t.Rating = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Rating = int(f) + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *Employee) Diff(other *Employee) deep.Patch[Employee] { + p := deep.Patch[Employee]{} + if t.ID != other.ID { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/id", Old: t.ID, New: other.ID}) + } + if t.Name != other.Name { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/name", Old: t.Name, New: other.Name}) + } + if t.Role != other.Role { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/role", Old: t.Role, New: other.Role}) + } + if t.Rating != other.Rating { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/rating", Old: t.Rating, New: other.Rating}) + } + + return p +} + +func (t *Employee) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/id", "/ID": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.ID, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.ID)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field ID") + } + _fv := float64(t.ID) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.ID == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.ID == iv { + return true, nil + } + case float64: + if float64(t.ID) == iv { + return true, nil + } + } + } + } + return false, nil + } + case "/name", "/Name": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Name, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Name)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Name") + } + switch c.Op { + case "==": + return t.Name == _sv, nil + case "!=": + return t.Name != _sv, nil + case ">": + return t.Name > _sv, nil + case "<": + return t.Name < _sv, nil + case ">=": + return t.Name >= _sv, nil + case "<=": + return t.Name <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Name == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Name == sv { + return true, nil + } + } + } + return false, nil + } + case "/role", "/Role": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Role, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Role)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Role") + } + switch c.Op { + case "==": + return t.Role == _sv, nil + case "!=": + return t.Role != _sv, nil + case ">": + return t.Role > _sv, nil + case "<": + return t.Role < _sv, nil + case ">=": + return t.Role >= _sv, nil + case "<=": + return t.Role <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Role == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Role == sv { + return true, nil + } + } + } + return false, nil + } + case "/rating", "/Rating": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Rating, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Rating)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Rating") + } + _fv := float64(t.Rating) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Rating == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Rating == iv { + return true, nil + } + case float64: + if float64(t.Rating) == iv { + return true, nil + } + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *Employee) Equal(other *Employee) bool { + if t.ID != other.ID { + return false + } + if t.Name != other.Name { + return false + } + if t.Role != other.Role { + return false + } + if t.Rating != other.Rating { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *Employee) Clone() *Employee { + res := &Employee{ + ID: t.ID, + Name: t.Name, + Role: t.Role, + Rating: t.Rating, + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/policy_engine/main.go b/examples/policy_engine/main.go new file mode 100644 index 0000000..50c730a --- /dev/null +++ b/examples/policy_engine/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + + "github.com/brunoga/deep/v5" +) + +type Employee struct { + ID int `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + Rating int `json:"rating"` +} + +func main() { + e := Employee{ID: 101, Name: "John Doe", Role: "Junior", Rating: 5} + + // Policy: promote to "Senior" only if (role=="Junior" AND rating==5) + // OR name ends with "Superstar". + policy := deep.Or( + deep.And( + deep.Eq(deep.Field(func(e *Employee) *string { return &e.Role }), "Junior"), + deep.Eq(deep.Field(func(e *Employee) *int { return &e.Rating }), 5), + ), + deep.Matches(deep.Field(func(e *Employee) *string { return &e.Name }), ".*Superstar$"), + ) + + patch := deep.Patch[Employee]{}.WithGuard(policy) + patch.Operations = append(patch.Operations, deep.Operation{ + Kind: deep.OpReplace, Path: "/role", New: "Senior", + }) + + fmt.Println("--- PROMOTION ATTEMPT (rating=5) ---") + fmt.Printf("Employee: %+v\n", e) + if err := deep.Apply(&e, patch); err != nil { + fmt.Printf("REJECTED: %v\n", err) + } else { + fmt.Printf("ACCEPTED: new role = %s\n", e.Role) + } + + e.Rating = 3 + fmt.Println("\n--- PROMOTION ATTEMPT (rating=3) ---") + fmt.Printf("Employee: %+v\n", e) + if err := deep.Apply(&e, patch); err != nil { + fmt.Printf("REJECTED: %v\n", err) + } else { + fmt.Printf("ACCEPTED: new role = %s\n", e.Role) + } +} diff --git a/examples/state_management/docstate_deep.go b/examples/state_management/docstate_deep.go new file mode 100644 index 0000000..ff200b5 --- /dev/null +++ b/examples/state_management/docstate_deep.go @@ -0,0 +1,338 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" + "strings" +) + +// Patch applies p to t using the generated fast path. +func (t *DocState) Patch(p deep.Patch[DocState], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *DocState) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(DocState)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(DocState); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/title", "/Title": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Title) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Title != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Title) + } + } + if v, ok := op.New.(string); ok { + t.Title = v + return true, nil + } + case "/content", "/Content": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Content) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Content != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Content) + } + } + if v, ok := op.New.(string); ok { + t.Content = v + return true, nil + } + case "/metadata", "/Metadata": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Metadata) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.(map[string]string); !ok || !deep.Equal(t.Metadata, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Metadata) + } + } + if v, ok := op.New.(map[string]string); ok { + t.Metadata = v + return true, nil + } + default: + if strings.HasPrefix(op.Path, "/metadata/") { + parts := strings.Split(op.Path[len("/metadata/"):], "/") + key := parts[0] + if op.Kind == deep.OpRemove { + delete(t.Metadata, key) + return true, nil + } + if t.Metadata == nil { + t.Metadata = make(map[string]string) + } + if v, ok := op.New.(string); ok { + t.Metadata[key] = v + return true, nil + } + } + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *DocState) Diff(other *DocState) deep.Patch[DocState] { + p := deep.Patch[DocState]{} + if t.Title != other.Title { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/title", Old: t.Title, New: other.Title}) + } + if t.Content != other.Content { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/content", Old: t.Content, New: other.Content}) + } + if other.Metadata != nil { + for k, v := range other.Metadata { + if t.Metadata == nil { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: fmt.Sprintf("/metadata/%v", k), New: v}) + continue + } + if oldV, ok := t.Metadata[k]; !ok || v != oldV { + kind := deep.OpReplace + if !ok { + kind = deep.OpAdd + } + p.Operations = append(p.Operations, deep.Operation{Kind: kind, Path: fmt.Sprintf("/metadata/%v", k), Old: oldV, New: v}) + } + } + } + if t.Metadata != nil { + for k, v := range t.Metadata { + if other.Metadata == nil || !contains(other.Metadata, k) { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpRemove, Path: fmt.Sprintf("/metadata/%v", k), Old: v}) + } + } + } + + return p +} + +func (t *DocState) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/title", "/Title": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Title, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Title)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Title") + } + switch c.Op { + case "==": + return t.Title == _sv, nil + case "!=": + return t.Title != _sv, nil + case ">": + return t.Title > _sv, nil + case "<": + return t.Title < _sv, nil + case ">=": + return t.Title >= _sv, nil + case "<=": + return t.Title <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Title == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Title == sv { + return true, nil + } + } + } + return false, nil + } + case "/content", "/Content": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Content, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Content)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Content") + } + switch c.Op { + case "==": + return t.Content == _sv, nil + case "!=": + return t.Content != _sv, nil + case ">": + return t.Content > _sv, nil + case "<": + return t.Content < _sv, nil + case ">=": + return t.Content >= _sv, nil + case "<=": + return t.Content <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Content == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Content == sv { + return true, nil + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *DocState) Equal(other *DocState) bool { + if t.Title != other.Title { + return false + } + if t.Content != other.Content { + return false + } + if len(t.Metadata) != len(other.Metadata) { + return false + } + for k, v := range t.Metadata { + vOther, ok := other.Metadata[k] + if !ok { + return false + } + if v != vOther { + return false + } + } + return true +} + +// Clone returns a deep copy of t. +func (t *DocState) Clone() *DocState { + res := &DocState{ + Title: t.Title, + Content: t.Content, + } + if t.Metadata != nil { + res.Metadata = make(map[string]string) + for k, v := range t.Metadata { + res.Metadata[k] = v + } + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/state_management/main.go b/examples/state_management/main.go index e38c69d..6c380d3 100644 --- a/examples/state_management/main.go +++ b/examples/state_management/main.go @@ -2,64 +2,59 @@ package main import ( "fmt" + "log" - "github.com/brunoga/deep/v4" + "github.com/brunoga/deep/v5" ) -// Document represents a document being edited. -type Document struct { - Title string - Content string - Metadata map[string]string +type DocState struct { + Title string `json:"title"` + Content string `json:"content"` + Metadata map[string]string `json:"metadata"` } func main() { - // 1. Initial Document. - doc := Document{ - Title: "Draft 1", - Content: "Hello World", - Metadata: map[string]string{ - "author": "Alice", - }, + current := DocState{ + Title: "Draft 1", + Content: "Hello World", + Metadata: map[string]string{"author": "Alice"}, } - // 2. Keep a history of patches for "Undo" functionality. - var history []deep.Patch[Document] - - // 3. User makes an edit: Change title and content. - fmt.Println("Action 1: Edit title and content") - original := deep.MustCopy(doc) // Snapshot current state - doc.Title = "Final Version" - doc.Content = "Goodbye World" - - // Record the change in history - history = append(history, deep.MustDiff(original, doc)) - - // 4. User makes another edit: Add metadata. - fmt.Println("Action 2: Add metadata") - original = deep.MustCopy(doc) - doc.Metadata["tags"] = "go,library" - - history = append(history, deep.MustDiff(original, doc)) - - fmt.Printf("\nCurrent State: %+v\n", doc) - - // 5. UNDO! - // To undo, we take the last patch and REVERSE it. - fmt.Println("\n--- UNDO ACTION 2 ---") - lastPatch := history[len(history)-1] - undoPatch := lastPatch.Reverse() - undoPatch.Apply(&doc) - - fmt.Printf("After Undo 2: %+v\n", doc) - - // 6. UNDO again! - fmt.Println("\n--- UNDO ACTION 1 ---") - firstPatch := history[len(history)-2] - undoFirstPatch := firstPatch.Reverse() - undoFirstPatch.Apply(&doc) - - fmt.Printf("After Undo 1: %+v\n", doc) + // Each edit records a reverse patch for undo. + var undoStack []deep.Patch[DocState] + + edit := func(fn func(*DocState)) { + next := deep.Clone(current) + fn(&next) + patch, err := deep.Diff(current, next) + if err != nil { + log.Fatal(err) + } + undoStack = append(undoStack, patch.Reverse()) + current = next + } - // Notice we are back to the initial state! + edit(func(d *DocState) { + d.Title = "Final Version" + d.Content = "Goodbye World" + }) + + edit(func(d *DocState) { + d.Metadata["tags"] = "go,library" + }) + + fmt.Println("--- CURRENT STATE ---") + fmt.Printf("%+v\n", current) + + // Undo edit 2. + deep.Apply(¤t, undoStack[len(undoStack)-1]) + undoStack = undoStack[:len(undoStack)-1] + fmt.Println("\n--- AFTER UNDO (edit 2) ---") + fmt.Printf("%+v\n", current) + + // Undo edit 1. + deep.Apply(¤t, undoStack[len(undoStack)-1]) + undoStack = undoStack[:len(undoStack)-1] + fmt.Println("\n--- AFTER UNDO (edit 1) ---") + fmt.Printf("%+v\n", current) } diff --git a/examples/struct_map_keys/fleet_deep.go b/examples/struct_map_keys/fleet_deep.go new file mode 100644 index 0000000..165dbc6 --- /dev/null +++ b/examples/struct_map_keys/fleet_deep.go @@ -0,0 +1,190 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" +) + +// Patch applies p to t using the generated fast path. +func (t *Fleet) Patch(p deep.Patch[Fleet], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *Fleet) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(Fleet)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(Fleet); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/devices", "/Devices": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Devices) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.(map[DeviceID]string); !ok || !deep.Equal(t.Devices, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Devices) + } + } + if v, ok := op.New.(map[DeviceID]string); ok { + t.Devices = v + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *Fleet) Diff(other *Fleet) deep.Patch[Fleet] { + p := deep.Patch[Fleet]{} + if other.Devices != nil { + for k, v := range other.Devices { + if t.Devices == nil { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: fmt.Sprintf("/devices/%v", k), New: v}) + continue + } + if oldV, ok := t.Devices[k]; !ok || v != oldV { + kind := deep.OpReplace + if !ok { + kind = deep.OpAdd + } + p.Operations = append(p.Operations, deep.Operation{Kind: kind, Path: fmt.Sprintf("/devices/%v", k), Old: oldV, New: v}) + } + } + } + if t.Devices != nil { + for k, v := range t.Devices { + if other.Devices == nil || !contains(other.Devices, k) { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpRemove, Path: fmt.Sprintf("/devices/%v", k), Old: v}) + } + } + } + + return p +} + +func (t *Fleet) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *Fleet) Equal(other *Fleet) bool { + if len(t.Devices) != len(other.Devices) { + return false + } + for k, v := range t.Devices { + vOther, ok := other.Devices[k] + if !ok { + return false + } + if v != vOther { + return false + } + } + return true +} + +// Clone returns a deep copy of t. +func (t *Fleet) Clone() *Fleet { + res := &Fleet{} + if t.Devices != nil { + res.Devices = make(map[DeviceID]string) + for k, v := range t.Devices { + res.Devices[k] = v + } + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/struct_map_keys/main.go b/examples/struct_map_keys/main.go new file mode 100644 index 0000000..31c4979 --- /dev/null +++ b/examples/struct_map_keys/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "log" + + "github.com/brunoga/deep/v5" +) + +// DeviceID is a non-string map key. deep uses its String() representation +// to build JSON Pointer paths, so map[DeviceID]string produces paths like +// /devices/prod:1 rather than requiring string keys. +type DeviceID struct { + Namespace string + ID int +} + +func (d DeviceID) String() string { + return fmt.Sprintf("%s:%d", d.Namespace, d.ID) +} + +type Fleet struct { + Devices map[DeviceID]string `json:"devices"` +} + +func main() { + f1 := Fleet{ + Devices: map[DeviceID]string{ + {"prod", 1}: "running", + {"prod", 2}: "running", + }, + } + f2 := Fleet{ + Devices: map[DeviceID]string{ + {"prod", 1}: "suspended", + {"prod", 3}: "running", + }, + } + + patch, err := deep.Diff(f1, f2) + if err != nil { + log.Fatal(err) + } + + fmt.Println("--- FLEET DIFF (non-string map keys) ---") + for _, op := range patch.Operations { + switch op.Kind { + case deep.OpReplace: + fmt.Printf(" %-8s %s: %v → %v\n", op.Kind, op.Path, op.Old, op.New) + case deep.OpAdd: + fmt.Printf(" %-8s %s: %v\n", op.Kind, op.Path, op.New) + case deep.OpRemove: + fmt.Printf(" %-8s %s: %v\n", op.Kind, op.Path, op.Old) + } + } +} diff --git a/examples/text_sync/main.go b/examples/text_sync/main.go index 2048b3f..2a371e7 100644 --- a/examples/text_sync/main.go +++ b/examples/text_sync/main.go @@ -3,93 +3,50 @@ package main import ( "fmt" - "github.com/brunoga/deep/v4/crdt" + "github.com/brunoga/deep/v5/crdt" + "github.com/brunoga/deep/v5/crdt/hlc" ) -// Document represents a text document using the specialized CRDT Text type. -type Document struct { - Content crdt.Text -} - func main() { - // Initialize two documents. - docA := crdt.NewCRDT(Document{Content: crdt.Text{}}, "A") - docB := crdt.NewCRDT(Document{Content: crdt.Text{}}, "B") - - fmt.Println("--- Initial State: Empty ---") - - // User A types "Hello" - fmt.Println("\n--- A types 'Hello' ---") - deltaA1 := docA.Edit(func(d *Document) { - // Insert "Hello" at position 0 - d.Content = d.Content.Insert(0, "Hello", docA.Clock()) - }) - fmt.Printf("Doc A: %s\n", docA.View().Content) - - // B receives "Hello" - docB.ApplyDelta(deltaA1) - fmt.Printf("Doc B: %s\n", docB.View().Content) - - // Concurrent Editing! - fmt.Println("\n--- Concurrent Edits ---") - fmt.Println("A appends ' World'") - fmt.Println("B inserts '!' at index 5") + clockA := hlc.NewClock("node-a") + clockB := hlc.NewClock("node-b") - // A appends " World" at index 5 (after "Hello") - deltaA2 := docA.Edit(func(d *Document) { - d.Content = d.Content.Insert(5, " World", docA.Clock()) - }) + // Two nodes start with the same empty document. + docA := crdt.Text{} + docB := crdt.Text{} - // B inserts "!" at index 5 (after "Hello") - deltaB1 := docB.Edit(func(d *Document) { - d.Content = d.Content.Insert(5, "!", docB.Clock()) - }) + // --- Step 1: Node A inserts "Hello" --- + docA = docA.Insert(0, "Hello", clockA) - fmt.Printf("Doc A (local): %s\n", docA.View().Content) - fmt.Printf("Doc B (local): %s\n", docB.View().Content) - - // Sync - fmt.Println("\n--- Syncing ---") - - // A receives B's insertion - docA.ApplyDelta(deltaB1) - fmt.Printf("Doc A (after B): %s\n", docA.View().Content) - - // B receives A's appending - docB.ApplyDelta(deltaA2) - fmt.Printf("Doc B (after A): %s\n", docB.View().Content) - - if docA.View().Content.String() == docB.View().Content.String() { - fmt.Println("SUCCESS: Documents converged!") - } else { - fmt.Println("FAILURE: Divergence!") - } + // Propagate A's full state to B via MergeTextRuns. + docB = crdt.MergeTextRuns(docB, docA) - // More complex: Interleaved insertion at the same position - fmt.Println("\n--- Concurrent Insertion at Same Position ---") + fmt.Println("After A types 'Hello':") + fmt.Printf(" Doc A: %q\n", docA.String()) + fmt.Printf(" Doc B: %q\n", docB.String()) - // Both insert at the end - pos := len(docA.View().Content.String()) + // --- Step 2: Concurrent edits (network partition) --- + // A appends " World" + docA = docA.Insert(5, " World", clockA) - // A inserts "X" - deltaA3 := docA.Edit(func(d *Document) { - d.Content = d.Content.Insert(pos, "X", docA.Clock()) - }) + // B inserts "!" at position 5 (after "Hello") + docB = docB.Insert(5, "!", clockB) - // B inserts "Y" - deltaB2 := docB.Edit(func(d *Document) { - d.Content = d.Content.Insert(pos, "Y", docB.Clock()) - }) + fmt.Println("\nAfter concurrent edits (partition):") + fmt.Printf(" Doc A: %q\n", docA.String()) + fmt.Printf(" Doc B: %q\n", docB.String()) - docA.ApplyDelta(deltaB2) - docB.ApplyDelta(deltaA3) + // --- Step 3: Merge (partition heals) --- + mergedA := crdt.MergeTextRuns(docA, docB) + mergedB := crdt.MergeTextRuns(docB, docA) - fmt.Printf("Doc A: %s\n", docA.View().Content) - fmt.Printf("Doc B: %s\n", docB.View().Content) + fmt.Println("\nAfter merge:") + fmt.Printf(" Doc A: %q\n", mergedA.String()) + fmt.Printf(" Doc B: %q\n", mergedB.String()) - if docA.View().Content.String() == docB.View().Content.String() { - fmt.Println("SUCCESS: Converged (deterministic order)! ") + if mergedA.String() == mergedB.String() { + fmt.Println("\nSUCCESS: both nodes converged.") } else { - fmt.Println("FAILURE: Divergence!") + fmt.Println("\nFAILURE: nodes diverged!") } } diff --git a/examples/three_way_merge/main.go b/examples/three_way_merge/main.go index 0c15566..7df19f8 100644 --- a/examples/three_way_merge/main.go +++ b/examples/three_way_merge/main.go @@ -2,85 +2,51 @@ package main import ( "fmt" - "github.com/brunoga/deep/v4" + + "github.com/brunoga/deep/v5" ) type SystemConfig struct { - AppName string - MaxThreads int - Debug bool - Endpoints map[string]string + AppName string `json:"app"` + MaxThreads int `json:"threads"` + Endpoints map[string]string `json:"endpoints"` +} + +type Resolver struct{} + +func (r *Resolver) Resolve(path string, local, remote any) any { + fmt.Printf("- conflict at %s: %v vs %v (picking remote)\n", path, local, remote) + return remote } func main() { - // 1. Common Base State base := SystemConfig{ AppName: "CoreAPI", MaxThreads: 10, - Debug: false, Endpoints: map[string]string{"auth": "https://auth.local"}, } - fmt.Printf("--- BASE STATE ---\n%+v\n\n", base) - - // 2. User A: Changes AppName and adds an Endpoint - userA := base - userA.AppName = "CoreAPI-v2" - // We must deep copy or re-initialize maps to avoid shared state in the example - userA.Endpoints = map[string]string{ - "auth": "https://auth.internal", - "metrics": "https://metrics.local", - } - patchA := deep.MustDiff(base, userA) - - fmt.Println("--- PATCH A (User A) ---") - fmt.Println(patchA.Summary()) - fmt.Println() + // User A changes Endpoints/auth. + patchA := deep.Patch[SystemConfig]{} + patchA.Operations = append(patchA.Operations, deep.Operation{ + Kind: deep.OpReplace, Path: "/endpoints/auth", New: "https://auth.internal", + }) - // 3. User B: Changes MaxThreads and Debug mode - userB := base - userB.MaxThreads = 20 - userB.Debug = true - userB.Endpoints = map[string]string{ - "auth": "https://auth.remote", - } - // Copy other endpoints from base to avoid accidental removal - for k, v := range base.Endpoints { - if _, ok := userB.Endpoints[k]; !ok { - userB.Endpoints[k] = v - } - } - patchB := deep.MustDiff(base, userB) + // User B also changes Endpoints/auth — conflict. + patchB := deep.Patch[SystemConfig]{} + patchB.Operations = append(patchB.Operations, deep.Operation{ + Kind: deep.OpReplace, Path: "/endpoints/auth", New: "https://auth.remote", + }) - fmt.Println("--- PATCH B (User B) ---") - fmt.Println(patchB.Summary()) - fmt.Println() + fmt.Println("--- BASE STATE ---") + fmt.Printf("%+v\n", base) - // 4. Merge Patch A and Patch B - fmt.Println("--- MERGING PATCHES ---") - merged, conflicts, err := deep.Merge(patchA, patchB) - if err != nil { - fmt.Printf("Merge failed error: %v\n", err) - return - } + fmt.Println("\n--- MERGING PATCHES (Custom Resolution) ---") + merged := deep.Merge(patchA, patchB, &Resolver{}) - if len(conflicts) > 0 { - fmt.Println("Detected Conflicts (A wins by default):") - for _, c := range conflicts { - fmt.Printf("- %s\n", c.String()) - } - fmt.Println() - } - - fmt.Printf("Merged Patch Summary:\n%s\n\n", merged.Summary()) - - // 5. Apply Merged Patch to Base - finalState := base - err = merged.ApplyChecked(&finalState) - if err != nil { - fmt.Printf("Apply failed: %v\n", err) - } + final := base + deep.Apply(&final, merged) - fmt.Println("--- FINAL STATE (After Merge & Apply) ---") - fmt.Printf("%+v\n", finalState) + fmt.Println("\n--- FINAL STATE ---") + fmt.Printf("%+v\n", final) } diff --git a/examples/three_way_merge/systemconfig_deep.go b/examples/three_way_merge/systemconfig_deep.go new file mode 100644 index 0000000..f14d549 --- /dev/null +++ b/examples/three_way_merge/systemconfig_deep.go @@ -0,0 +1,364 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" + "strings" +) + +// Patch applies p to t using the generated fast path. +func (t *SystemConfig) Patch(p deep.Patch[SystemConfig], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *SystemConfig) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(SystemConfig)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(SystemConfig); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/app", "/AppName": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.AppName) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.AppName != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.AppName) + } + } + if v, ok := op.New.(string); ok { + t.AppName = v + return true, nil + } + case "/threads", "/MaxThreads": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.MaxThreads) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.MaxThreads == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.MaxThreads) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.MaxThreads) + } + } + if v, ok := op.New.(int); ok { + t.MaxThreads = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.MaxThreads = int(f) + return true, nil + } + case "/endpoints", "/Endpoints": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Endpoints) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.(map[string]string); !ok || !deep.Equal(t.Endpoints, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Endpoints) + } + } + if v, ok := op.New.(map[string]string); ok { + t.Endpoints = v + return true, nil + } + default: + if strings.HasPrefix(op.Path, "/endpoints/") { + parts := strings.Split(op.Path[len("/endpoints/"):], "/") + key := parts[0] + if op.Kind == deep.OpRemove { + delete(t.Endpoints, key) + return true, nil + } + if t.Endpoints == nil { + t.Endpoints = make(map[string]string) + } + if v, ok := op.New.(string); ok { + t.Endpoints[key] = v + return true, nil + } + } + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *SystemConfig) Diff(other *SystemConfig) deep.Patch[SystemConfig] { + p := deep.Patch[SystemConfig]{} + if t.AppName != other.AppName { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/app", Old: t.AppName, New: other.AppName}) + } + if t.MaxThreads != other.MaxThreads { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/threads", Old: t.MaxThreads, New: other.MaxThreads}) + } + if other.Endpoints != nil { + for k, v := range other.Endpoints { + if t.Endpoints == nil { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: fmt.Sprintf("/endpoints/%v", k), New: v}) + continue + } + if oldV, ok := t.Endpoints[k]; !ok || v != oldV { + kind := deep.OpReplace + if !ok { + kind = deep.OpAdd + } + p.Operations = append(p.Operations, deep.Operation{Kind: kind, Path: fmt.Sprintf("/endpoints/%v", k), Old: oldV, New: v}) + } + } + } + if t.Endpoints != nil { + for k, v := range t.Endpoints { + if other.Endpoints == nil || !contains(other.Endpoints, k) { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpRemove, Path: fmt.Sprintf("/endpoints/%v", k), Old: v}) + } + } + } + + return p +} + +func (t *SystemConfig) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/app", "/AppName": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.AppName, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.AppName)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field AppName") + } + switch c.Op { + case "==": + return t.AppName == _sv, nil + case "!=": + return t.AppName != _sv, nil + case ">": + return t.AppName > _sv, nil + case "<": + return t.AppName < _sv, nil + case ">=": + return t.AppName >= _sv, nil + case "<=": + return t.AppName <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.AppName == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.AppName == sv { + return true, nil + } + } + } + return false, nil + } + case "/threads", "/MaxThreads": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.MaxThreads, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.MaxThreads)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field MaxThreads") + } + _fv := float64(t.MaxThreads) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.MaxThreads == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.MaxThreads == iv { + return true, nil + } + case float64: + if float64(t.MaxThreads) == iv { + return true, nil + } + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *SystemConfig) Equal(other *SystemConfig) bool { + if t.AppName != other.AppName { + return false + } + if t.MaxThreads != other.MaxThreads { + return false + } + if len(t.Endpoints) != len(other.Endpoints) { + return false + } + for k, v := range t.Endpoints { + vOther, ok := other.Endpoints[k] + if !ok { + return false + } + if v != vOther { + return false + } + } + return true +} + +// Clone returns a deep copy of t. +func (t *SystemConfig) Clone() *SystemConfig { + res := &SystemConfig{ + AppName: t.AppName, + MaxThreads: t.MaxThreads, + } + if t.Endpoints != nil { + res.Endpoints = make(map[string]string) + for k, v := range t.Endpoints { + res.Endpoints[k] = v + } + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/websocket_sync/gameworld_deep.go b/examples/websocket_sync/gameworld_deep.go new file mode 100644 index 0000000..9aad386 --- /dev/null +++ b/examples/websocket_sync/gameworld_deep.go @@ -0,0 +1,668 @@ +// Code generated by deep-gen. DO NOT EDIT. +package main + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" + "strings" +) + +// Patch applies p to t using the generated fast path. +func (t *GameWorld) Patch(p deep.Patch[GameWorld], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *GameWorld) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(GameWorld)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(GameWorld); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/players", "/Players": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Players) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.(map[string]Player); !ok || !deep.Equal(t.Players, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Players) + } + } + if v, ok := op.New.(map[string]Player); ok { + t.Players = v + return true, nil + } + case "/time", "/Time": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Time) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Time == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Time) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Time) + } + } + if v, ok := op.New.(int); ok { + t.Time = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Time = int(f) + return true, nil + } + default: + if strings.HasPrefix(op.Path, "/players/") { + parts := strings.Split(op.Path[len("/players/"):], "/") + key := parts[0] + if op.Kind == deep.OpRemove { + delete(t.Players, key) + return true, nil + } + if t.Players == nil { + t.Players = make(map[string]Player) + } + if v, ok := op.New.(Player); ok { + t.Players[key] = v + return true, nil + } + } + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *GameWorld) Diff(other *GameWorld) deep.Patch[GameWorld] { + p := deep.Patch[GameWorld]{} + if other.Players != nil { + for k, v := range other.Players { + if t.Players == nil { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: fmt.Sprintf("/players/%v", k), New: v}) + continue + } + if oldV, ok := t.Players[k]; !ok || v != oldV { + kind := deep.OpReplace + if !ok { + kind = deep.OpAdd + } + p.Operations = append(p.Operations, deep.Operation{Kind: kind, Path: fmt.Sprintf("/players/%v", k), Old: oldV, New: v}) + } + } + } + if t.Players != nil { + for k, v := range t.Players { + if other.Players == nil || !contains(other.Players, k) { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpRemove, Path: fmt.Sprintf("/players/%v", k), Old: v}) + } + } + } + if t.Time != other.Time { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/time", Old: t.Time, New: other.Time}) + } + + return p +} + +func (t *GameWorld) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/time", "/Time": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Time, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Time)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Time") + } + _fv := float64(t.Time) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Time == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Time == iv { + return true, nil + } + case float64: + if float64(t.Time) == iv { + return true, nil + } + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *GameWorld) Equal(other *GameWorld) bool { + if len(t.Players) != len(other.Players) { + return false + } + for k, v := range t.Players { + vOther, ok := other.Players[k] + if !ok { + return false + } + if v != vOther { + return false + } + } + if t.Time != other.Time { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *GameWorld) Clone() *GameWorld { + res := &GameWorld{ + Time: t.Time, + } + if t.Players != nil { + res.Players = make(map[string]Player) + for k, v := range t.Players { + res.Players[k] = v + } + } + return res +} + +// Patch applies p to t using the generated fast path. +func (t *Player) Patch(p deep.Patch[Player], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *Player) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(Player)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(Player); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/x", "/X": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.X) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.X == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.X) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.X) + } + } + if v, ok := op.New.(int); ok { + t.X = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.X = int(f) + return true, nil + } + case "/y", "/Y": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Y) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Y == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Y) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Y) + } + } + if v, ok := op.New.(int); ok { + t.Y = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Y = int(f) + return true, nil + } + case "/name", "/Name": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Name) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Name != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Name) + } + } + if v, ok := op.New.(string); ok { + t.Name = v + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *Player) Diff(other *Player) deep.Patch[Player] { + p := deep.Patch[Player]{} + if t.X != other.X { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/x", Old: t.X, New: other.X}) + } + if t.Y != other.Y { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/y", Old: t.Y, New: other.Y}) + } + if t.Name != other.Name { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/name", Old: t.Name, New: other.Name}) + } + + return p +} + +func (t *Player) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/x", "/X": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.X, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.X)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field X") + } + _fv := float64(t.X) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.X == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.X == iv { + return true, nil + } + case float64: + if float64(t.X) == iv { + return true, nil + } + } + } + } + return false, nil + } + case "/y", "/Y": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Y, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Y)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Y") + } + _fv := float64(t.Y) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Y == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Y == iv { + return true, nil + } + case float64: + if float64(t.Y) == iv { + return true, nil + } + } + } + } + return false, nil + } + case "/name", "/Name": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Name, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Name)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Name") + } + switch c.Op { + case "==": + return t.Name == _sv, nil + case "!=": + return t.Name != _sv, nil + case ">": + return t.Name > _sv, nil + case "<": + return t.Name < _sv, nil + case ">=": + return t.Name >= _sv, nil + case "<=": + return t.Name <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Name == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Name == sv { + return true, nil + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *Player) Equal(other *Player) bool { + if t.X != other.X { + return false + } + if t.Y != other.Y { + return false + } + if t.Name != other.Name { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *Player) Clone() *Player { + res := &Player{ + X: t.X, + Y: t.Y, + Name: t.Name, + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/examples/websocket_sync/main.go b/examples/websocket_sync/main.go index dd09992..b29d72f 100644 --- a/examples/websocket_sync/main.go +++ b/examples/websocket_sync/main.go @@ -1,16 +1,18 @@ +//go:generate go run github.com/brunoga/deep/v5/cmd/deep-gen -type=GameWorld,Player . + package main import ( "encoding/json" "fmt" + "log" - "github.com/brunoga/deep/v4" + "github.com/brunoga/deep/v5" ) -// GameWorld represents a shared state (e.g., in a real-time game or collab tool). type GameWorld struct { - Players map[string]*Player `json:"players"` - Time int `json:"time"` + Players map[string]Player `json:"players"` + Time int `json:"time"` } type Player struct { @@ -20,52 +22,47 @@ type Player struct { } func main() { - // 1. Initial server state. serverState := GameWorld{ - Players: map[string]*Player{ + Players: map[string]Player{ "p1": {X: 0, Y: 0, Name: "Hero"}, }, Time: 0, } - // 2. Simulation: A client has the same initial state. - clientState := deep.MustCopy(serverState) - - fmt.Println("Initial Server State:", serverState.Players["p1"]) - fmt.Println("Initial Client State:", clientState.Players["p1"]) + clientState := deep.Clone(serverState) - // 3. SERVER TICK: Something changes. - // Player moves and time advances. - previousState := deep.MustCopy(serverState) // Keep track of old state for diffing + fmt.Println("--- INITIAL STATE ---") + fmt.Printf("Server: %+v\n", serverState.Players["p1"]) + fmt.Printf("Client: %+v\n", clientState.Players["p1"]) - serverState.Players["p1"].X += 5 - serverState.Players["p1"].Y += 10 + // Server tick: move player and advance time. + previousState := deep.Clone(serverState) + p := serverState.Players["p1"] + p.X += 5 + p.Y += 10 + serverState.Players["p1"] = p serverState.Time++ - // 4. BROADCAST: Instead of sending the WHOLE GameWorld (which could be large), - // we only send the Patch. - patch := deep.MustDiff(previousState, serverState) - - // Network transport (simulated). - wireData, err := json.Marshal(patch) + // Compute and broadcast the patch (only the changed fields). + patch, err := deep.Diff(previousState, serverState) if err != nil { - fmt.Printf("Broadcast failed: %v\n", err) - return + log.Fatal(err) } - fmt.Printf("\n[Network] Broadcasting Patch (%d bytes): %s\n", len(wireData), string(wireData)) + wireData, _ := json.Marshal(patch) - // 5. CLIENT RECEIVE: The client receives the wire data. - receivedPatch := deep.NewPatch[GameWorld]() - _ = json.Unmarshal(wireData, receivedPatch) + fmt.Println("\n--- SERVER BROADCAST ---") + fmt.Printf("Patch (%d bytes): %s\n", len(wireData), string(wireData)) - // Client applies the patch to its local copy. - receivedPatch.Apply(&clientState) + // Client receives and applies. + var receivedPatch deep.Patch[GameWorld] + json.Unmarshal(wireData, &receivedPatch) + deep.Apply(&clientState, receivedPatch) - fmt.Printf("\nClient State after receiving patch: %v\n", clientState.Players["p1"]) - fmt.Printf("Client Game Time: %d\n", clientState.Time) + fmt.Println("\n--- CLIENT STATE AFTER SYNC ---") + fmt.Printf("Player: %+v\n", clientState.Players["p1"]) + fmt.Printf("Time: %d\n", clientState.Time) - // 6. Verification: Both should be identical again. if clientState.Players["p1"].X == serverState.Players["p1"].X { - fmt.Println("\nSynchronization Successful!") + fmt.Println("\nSUCCESS: Client synchronized!") } } diff --git a/go.mod b/go.mod index 6a6dc7d..ddec6b7 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/brunoga/deep/v4 +module github.com/brunoga/deep/v5 -go 1.20.0 +go 1.21 diff --git a/go.sum b/go.sum deleted file mode 100644 index e69de29..0000000 diff --git a/internal/core/cache.go b/internal/core/cache.go index 714f2f8..cfa6cf8 100644 --- a/internal/core/cache.go +++ b/internal/core/cache.go @@ -2,13 +2,15 @@ package core import ( "reflect" + "strings" "sync" ) type FieldInfo struct { - Index int - Name string - Tag StructTag + Index int + Name string + JSONTag string + Tag StructTag } type TypeInfo struct { @@ -32,10 +34,15 @@ func GetTypeInfo(typ reflect.Type) *TypeInfo { for i := 0; i < typ.NumField(); i++ { field := typ.Field(i) tag := ParseTag(field) + jsonTag := field.Tag.Get("json") + if jsonTag != "" { + jsonTag = strings.Split(jsonTag, ",")[0] + } info.Fields = append(info.Fields, FieldInfo{ - Index: i, - Name: field.Name, - Tag: tag, + Index: i, + Name: field.Name, + JSONTag: jsonTag, + Tag: tag, }) if tag.Key { info.KeyFieldIndex = i diff --git a/internal/core/cache_test.go b/internal/core/cache_test.go index f8c81e8..bab1d12 100644 --- a/internal/core/cache_test.go +++ b/internal/core/cache_test.go @@ -10,20 +10,20 @@ func TestGetTypeInfo(t *testing.T) { A int B string `deep:"-"` } - + info := GetTypeInfo(reflect.TypeOf(S{})) if len(info.Fields) != 2 { t.Errorf("Expected 2 fields, got %d", len(info.Fields)) } - + if info.Fields[0].Name != "A" || info.Fields[0].Tag.Ignore { t.Errorf("Field A incorrect: %+v", info.Fields[0]) } - + if info.Fields[1].Name != "B" || !info.Fields[1].Tag.Ignore { t.Errorf("Field B incorrect: %+v", info.Fields[1]) } - + // Cache hit check (implicit) info2 := GetTypeInfo(reflect.TypeOf(S{})) if info != info2 { diff --git a/internal/core/copy.go b/internal/core/copy.go index 86d39c3..aa33515 100644 --- a/internal/core/copy.go +++ b/internal/core/copy.go @@ -7,7 +7,7 @@ import ( "strings" "sync" - "github.com/brunoga/deep/v4/internal/unsafe" + "github.com/brunoga/deep/v5/internal/unsafe" ) // Copier is an interface that types can implement to provide their own @@ -164,12 +164,12 @@ func copyInternal(v reflect.Value, config *copyConfig) (reflect.Value, error) { func recursiveCopy(v reflect.Value, pointers pointersMap, config *copyConfig, path string, atomic bool) (reflect.Value, error) { - + checkPath := path if checkPath == "" { checkPath = "/" } - + if config.ignoredPaths != nil && config.ignoredPaths[checkPath] { return reflect.Zero(v.Type()), nil } @@ -330,7 +330,7 @@ func recursiveCopyMap(v reflect.Value, pointers pointersMap, kStr := fmt.Sprintf("%v", key.Interface()) kStr = strings.ReplaceAll(kStr, "~", "~0") kStr = strings.ReplaceAll(kStr, "/", "~1") - + if path == "" { keyPath = "/" + kStr } else { diff --git a/internal/core/equal.go b/internal/core/equal.go index 30193ab..c4e4571 100644 --- a/internal/core/equal.go +++ b/internal/core/equal.go @@ -7,7 +7,7 @@ import ( "strings" "sync" - "github.com/brunoga/deep/v4/internal/unsafe" + "github.com/brunoga/deep/v5/internal/unsafe" ) // EqualOption allows configuring the behavior of the Equal function. @@ -139,7 +139,7 @@ func equalRecursive(a, b reflect.Value, visited map[VisitKey]bool, config *equal if ptrA == ptrB { return true } - + k := VisitKey{ptrA, ptrB, a.Type()} if visited[k] { return true @@ -165,7 +165,7 @@ func equalRecursive(a, b reflect.Value, visited map[VisitKey]bool, config *equal if !fB.CanInterface() { unsafe.DisableRO(&fB) } - + var newStack []string if pathStack != nil { newStack = append(pathStack, fInfo.Name) @@ -203,7 +203,7 @@ func equalRecursive(a, b reflect.Value, visited map[VisitKey]bool, config *equal if !valB.IsValid() { return false } - + var newStack []string if pathStack != nil { // Normalize map key path construction as in copy.go diff --git a/internal/core/path.go b/internal/core/path.go index 5439cf6..4e5f1f1 100644 --- a/internal/core/path.go +++ b/internal/core/path.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/brunoga/deep/v4/internal/unsafe" + "github.com/brunoga/deep/v5/internal/unsafe" ) // DeepPath represents a path to a field or element within a structure. @@ -27,11 +27,11 @@ func (p DeepPath) ResolveParentPath() (DeepPath, PathPart, error) { return "", PathPart{}, fmt.Errorf("path is empty") } last := parts[len(parts)-1] - + if len(parts) == 1 { return "", last, nil } - + parentParts := parts[:len(parts)-1] var b strings.Builder for _, part := range parentParts { @@ -69,10 +69,24 @@ func (p DeepPath) Navigate(v reflect.Value, parts []PathPart) (reflect.Value, Pa } if part.IsIndex && (current.Kind() == reflect.Slice || current.Kind() == reflect.Array) { - if part.Index < 0 || part.Index >= current.Len() { - return reflect.Value{}, PathPart{}, fmt.Errorf("index out of bounds: %d", part.Index) + // Check whether the element type uses a keyed-collection tag. + // If so, treat the numeric segment as a key value, not an array index. + if keyIdx, found := sliceKeyField(current.Type()); found { + keyStr := part.Key + if keyStr == "" { + keyStr = strconv.Itoa(part.Index) + } + elem, ok := findSliceElemByKey(current, keyIdx, keyStr) + if !ok { + return reflect.Value{}, PathPart{}, fmt.Errorf("element with key %s not found", keyStr) + } + current = elem + } else { + if part.Index < 0 || part.Index >= current.Len() { + return reflect.Value{}, PathPart{}, fmt.Errorf("index out of bounds: %d", part.Index) + } + current = current.Index(part.Index) } - current = current.Index(part.Index) } else if current.Kind() == reflect.Map { keyType := current.Type().Key() var keyVal reflect.Value @@ -107,10 +121,20 @@ func (p DeepPath) Navigate(v reflect.Value, parts []PathPart) (reflect.Value, Pa key = strconv.Itoa(part.Index) } - f := current.FieldByName(key) - if !f.IsValid() { + info := GetTypeInfo(current.Type()) + var fieldIdx = -1 + for _, fInfo := range info.Fields { + if fInfo.Name == key || (fInfo.JSONTag != "" && fInfo.JSONTag == key) { + fieldIdx = fInfo.Index + break + } + } + + if fieldIdx == -1 { return reflect.Value{}, PathPart{}, fmt.Errorf("field %s not found", key) } + f := current.Field(fieldIdx) + if !f.CanInterface() { unsafe.DisableRO(&f) } @@ -158,6 +182,24 @@ func (p DeepPath) Set(v reflect.Value, val reflect.Value) error { parent.SetMapIndex(keyVal, ConvertValue(val, parent.Type().Elem())) return nil case reflect.Slice: + // For keyed slices, find by key and update or append; for plain slices, use positional index. + if keyIdx, found := sliceKeyField(parent.Type()); found { + key := lastPart.Key + if key == "" && lastPart.IsIndex { + key = strconv.Itoa(lastPart.Index) + } + converted := ConvertValue(val, parent.Type().Elem()) + for i := 0; i < parent.Len(); i++ { + elem := parent.Index(i) + if keyFieldStr(elem, keyIdx) == key { + elem.Set(converted) + return nil + } + } + // Key not found: append the new element. + parent.Set(reflect.Append(parent, converted)) + return nil + } idx := lastPart.Index if !lastPart.IsIndex { var err error @@ -180,10 +222,18 @@ func (p DeepPath) Set(v reflect.Value, val reflect.Value) error { if key == "" && lastPart.IsIndex { key = strconv.Itoa(lastPart.Index) } - f := parent.FieldByName(key) - if !f.IsValid() { + info := GetTypeInfo(parent.Type()) + var fieldIdx = -1 + for _, fInfo := range info.Fields { + if fInfo.Name == key || (fInfo.JSONTag != "" && fInfo.JSONTag == key) { + fieldIdx = fInfo.Index + break + } + } + if fieldIdx == -1 { return fmt.Errorf("field %s not found", key) } + f := parent.Field(fieldIdx) if !f.CanSet() { unsafe.DisableRO(&f) } @@ -220,6 +270,21 @@ func (p DeepPath) Delete(v reflect.Value) error { parent.SetMapIndex(keyVal, reflect.Value{}) return nil case reflect.Slice: + // For keyed slices, find by key and remove; for plain slices, use positional index. + if keyIdx, found := sliceKeyField(parent.Type()); found { + key := lastPart.Key + if key == "" && lastPart.IsIndex { + key = strconv.Itoa(lastPart.Index) + } + for i := 0; i < parent.Len(); i++ { + if keyFieldStr(parent.Index(i), keyIdx) == key { + newSlice := reflect.AppendSlice(parent.Slice(0, i), parent.Slice(i+1, parent.Len())) + parent.Set(newSlice) + return nil + } + } + return fmt.Errorf("element with key %s not found", key) + } idx := lastPart.Index if !lastPart.IsIndex { var err error @@ -239,10 +304,18 @@ func (p DeepPath) Delete(v reflect.Value) error { if key == "" && lastPart.IsIndex { key = strconv.Itoa(lastPart.Index) } - f := parent.FieldByName(key) - if !f.IsValid() { + info := GetTypeInfo(parent.Type()) + var fieldIdx = -1 + for _, fInfo := range info.Fields { + if fInfo.Name == key || (fInfo.JSONTag != "" && fInfo.JSONTag == key) { + fieldIdx = fInfo.Index + break + } + } + if fieldIdx == -1 { return fmt.Errorf("field %s not found", key) } + f := parent.Field(fieldIdx) if !f.CanSet() { unsafe.DisableRO(&f) } @@ -328,51 +401,8 @@ 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 DeepPath) StripParts(prefix []PathPart) DeepPath { - 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 "" - } - var res strings.Builder - for _, part := range remaining { - res.WriteByte('/') - if part.IsIndex { - res.WriteString(strconv.Itoa(part.Index)) - } else { - res.WriteString(EscapeKey(part.Key)) - } - } - return DeepPath(res.String()) -} - -// ParsePath parses a JSON Pointer path. -// It assumes the path starts with "/" or is empty. +// ParsePath parses a JSON Pointer path (RFC 6901). func ParsePath(path string) []PathPart { - if path == "" { - return nil - } - if !strings.HasPrefix(path, "/") { - return ParseJSONPointer(path) - } return ParseJSONPointer(path) } @@ -380,7 +410,7 @@ func ParseJSONPointer(path string) []PathPart { if path == "" || path == "/" { return nil } - + // Handle paths not starting with / (treat as relative/simple key) var tokens []string if strings.HasPrefix(path, "/") { @@ -452,13 +482,35 @@ func JoinPath(parent, child string) string { return res } -func ToReflectValue(v any) reflect.Value { - if rv, ok := v.(reflect.Value); ok { - return rv +// sliceKeyField returns the index of the deep:"key" field on the element type of +// a slice type, together with a found flag. Returns -1, false for non-keyed slices. +func sliceKeyField(sliceType reflect.Type) (int, bool) { + elemType := sliceType.Elem() + for elemType.Kind() == reflect.Pointer { + elemType = elemType.Elem() + } + return GetKeyField(elemType) +} + +// keyFieldStr returns the string representation of the key field at fieldIdx in elem. +func keyFieldStr(elem reflect.Value, fieldIdx int) string { + for elem.Kind() == reflect.Pointer { + if elem.IsNil() { + return "" + } + elem = elem.Elem() } - rv := reflect.ValueOf(v) - for rv.Kind() == reflect.Pointer { - rv = rv.Elem() + f := elem.Field(fieldIdx) + return fmt.Sprintf("%v", f.Interface()) +} + +// findSliceElemByKey searches s for the element whose key field equals keyStr, +// returning the element value and true on success. +func findSliceElemByKey(s reflect.Value, keyIdx int, keyStr string) (reflect.Value, bool) { + for i := 0; i < s.Len(); i++ { + if keyFieldStr(s.Index(i), keyIdx) == keyStr { + return s.Index(i), true + } } - return rv + return reflect.Value{}, false } diff --git a/internal/core/path_test.go b/internal/core/path_test.go index 58a9eae..1cac008 100644 --- a/internal/core/path_test.go +++ b/internal/core/path_test.go @@ -12,13 +12,13 @@ func TestDeepPath_Resolve(t *testing.T) { } s := S{A: 1, B: []int{2}} v := reflect.ValueOf(s) - + // JSON Pointer val, err := DeepPath("/A").Resolve(v) if err != nil || val.Int() != 1 { t.Errorf("Resolve /A failed: %v, %v", val, err) } - + val, err = DeepPath("/B/0").Resolve(v) if err != nil || val.Int() != 2 { t.Errorf("Resolve /B/0 failed: %v, %v", val, err) @@ -27,17 +27,17 @@ func TestDeepPath_Resolve(t *testing.T) { func TestDeepPath_ResolveParentPath(t *testing.T) { tests := []struct { - path string - parent string - key string - index int + path string + parent string + key string + index int isIndex bool }{ {"/A/B", "/A", "B", 0, false}, {"/A/0", "/A", "", 0, true}, {"/A/B", "/A", "B", 0, false}, } - + for _, tt := range tests { parent, part, err := DeepPath(tt.path).ResolveParentPath() if err != nil { diff --git a/internal/core/util.go b/internal/core/util.go index 95aa5c7..4dcde66 100644 --- a/internal/core/util.go +++ b/internal/core/util.go @@ -4,7 +4,7 @@ import ( "encoding/json" "reflect" - "github.com/brunoga/deep/v4/internal/unsafe" + "github.com/brunoga/deep/v5/internal/unsafe" ) func ConvertValue(v reflect.Value, targetType reflect.Type) reflect.Value { @@ -92,13 +92,6 @@ func ValueToInterface(v reflect.Value) any { return v.Interface() } -func InterfaceToValue(i any) reflect.Value { - if i == nil { - return reflect.Value{} - } - return reflect.ValueOf(i) -} - func ExtractKey(v reflect.Value, fieldIdx int) any { if v.Kind() == reflect.Pointer { if v.IsNil() { diff --git a/internal/core/util_test.go b/internal/core/util_test.go index 8733ad2..8baa42b 100644 --- a/internal/core/util_test.go +++ b/internal/core/util_test.go @@ -14,7 +14,7 @@ func TestConvertValue(t *testing.T) { {int(1), int64(0), int64(1)}, {float64(1.0), int(0), int(1)}, // int to string = rune conversion - {65, "", "A"}, + {65, "", "A"}, // bool to string = not convertible, returns original {true, "", true}, } @@ -33,15 +33,15 @@ func TestExtractKey(t *testing.T) { type Keyed struct { ID int `deep:"key"` } - + k := Keyed{ID: 10} v := reflect.ValueOf(k) - + key := ExtractKey(v, 0) if key.(int) != 10 { t.Errorf("ExtractKey failed: %v", key) } - + // Pointer kp := &k vp := reflect.ValueOf(kp) diff --git a/internal/engine/apply_reflection.go b/internal/engine/apply_reflection.go new file mode 100644 index 0000000..18ac799 --- /dev/null +++ b/internal/engine/apply_reflection.go @@ -0,0 +1,98 @@ +package engine + +import ( + "fmt" + "log/slog" + "reflect" + + "github.com/brunoga/deep/v5/condition" + icore "github.com/brunoga/deep/v5/internal/core" +) + +// ApplyOpReflection applies a single operation to target using reflection. +// It is called by generated Patch methods for operations the generated fast-path does not handle +// (e.g. slice index or map key paths). Direct use is not intended. +func ApplyOpReflection[T any](target *T, op Operation, logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + return ApplyOpReflectionValue(reflect.ValueOf(target).Elem(), op, logger) +} + +// ApplyOpReflectionValue applies op to the already-reflected value v. +func ApplyOpReflectionValue(v reflect.Value, op Operation, logger *slog.Logger) error { + // Strict check. + if op.Strict && (op.Kind == OpReplace || op.Kind == OpRemove) { + current, err := icore.DeepPath(op.Path).Resolve(v) + if err == nil && current.IsValid() { + if !icore.Equal(current.Interface(), op.Old) { + return fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, current.Interface()) + } + } + } + + // Per-operation conditions. + if op.If != nil { + ok, err := condition.Evaluate(v, op.If) + if err != nil || !ok { + return nil + } + } + if op.Unless != nil { + ok, err := condition.Evaluate(v, op.Unless) + if err != nil || ok { + return nil + } + } + + // Struct tag enforcement. + if v.Kind() == reflect.Struct { + parts := icore.ParsePath(op.Path) + if len(parts) > 0 { + info := icore.GetTypeInfo(v.Type()) + for _, fInfo := range info.Fields { + if fInfo.Name == parts[0].Key || (fInfo.JSONTag != "" && fInfo.JSONTag == parts[0].Key) { + if fInfo.Tag.Ignore { + return nil + } + if fInfo.Tag.ReadOnly && op.Kind != OpLog { + return fmt.Errorf("field %s is read-only", op.Path) + } + break + } + } + } + } + + var err error + switch op.Kind { + case OpAdd, OpReplace: + err = icore.DeepPath(op.Path).Set(v, reflect.ValueOf(op.New)) + case OpRemove: + err = icore.DeepPath(op.Path).Delete(v) + case OpMove: + fromPath := op.Old.(string) + var val reflect.Value + val, err = icore.DeepPath(fromPath).Resolve(v) + if err == nil { + copied := reflect.New(val.Type()).Elem() + copied.Set(val) + if err = icore.DeepPath(fromPath).Delete(v); err == nil { + err = icore.DeepPath(op.Path).Set(v, copied) + } + } + case OpCopy: + fromPath := op.Old.(string) + var val reflect.Value + val, err = icore.DeepPath(fromPath).Resolve(v) + if err == nil { + err = icore.DeepPath(op.Path).Set(v, val) + } + case OpLog: + logger.Info("deep log", "message", op.New, "path", op.Path) + } + if err != nil { + return fmt.Errorf("failed to apply %s at %s: %w", op.Kind, op.Path, err) + } + return nil +} diff --git a/bench_test.go b/internal/engine/bench_test.go similarity index 99% rename from bench_test.go rename to internal/engine/bench_test.go index c051fd3..d627713 100644 --- a/bench_test.go +++ b/internal/engine/bench_test.go @@ -1,4 +1,4 @@ -package deep +package engine import ( "fmt" diff --git a/copy.go b/internal/engine/copy.go similarity index 77% rename from copy.go rename to internal/engine/copy.go index c5b0bd2..bee8c8a 100644 --- a/copy.go +++ b/internal/engine/copy.go @@ -1,7 +1,7 @@ -package deep +package engine import ( - "github.com/brunoga/deep/v4/internal/core" + icore "github.com/brunoga/deep/v5/internal/core" ) // Copier is an interface that types can implement to provide their own @@ -15,11 +15,11 @@ type Copier[T any] interface { // // It correctly handles cyclic references and unexported fields. func Copy[T any](src T, opts ...CopyOption) (T, error) { - coreOpts := make([]core.CopyOption, len(opts)) + coreOpts := make([]icore.CopyOption, len(opts)) for i, opt := range opts { coreOpts[i] = opt.asCoreCopyOption() } - return core.Copy(src, coreOpts...) + return icore.Copy(src, coreOpts...) } // MustCopy creates a deep copy of src. It returns the copy on success or panics @@ -27,9 +27,9 @@ func Copy[T any](src T, opts ...CopyOption) (T, error) { // // It correctly handles cyclic references and unexported fields. func MustCopy[T any](src T, opts ...CopyOption) T { - coreOpts := make([]core.CopyOption, len(opts)) + coreOpts := make([]icore.CopyOption, len(opts)) for i, opt := range opts { coreOpts[i] = opt.asCoreCopyOption() } - return core.MustCopy(src, coreOpts...) + return icore.MustCopy(src, coreOpts...) } diff --git a/copy_test.go b/internal/engine/copy_test.go similarity index 99% rename from copy_test.go rename to internal/engine/copy_test.go index dca0073..80f3825 100644 --- a/copy_test.go +++ b/internal/engine/copy_test.go @@ -1,4 +1,4 @@ -package deep +package engine import ( "fmt" diff --git a/internal/engine/diff.go b/internal/engine/diff.go new file mode 100644 index 0000000..8f66519 --- /dev/null +++ b/internal/engine/diff.go @@ -0,0 +1,1108 @@ +package engine + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "sync" + + icore "github.com/brunoga/deep/v5/internal/core" + "github.com/brunoga/deep/v5/internal/unsafe" +) + +// DiffOption allows configuring the behavior of the Diff function. +// Note: DiffOption is defined in options.go + +type diffConfig struct { + ignoredPaths map[string]bool + detectMoves bool +} + +type diffOptionFunc func(*diffConfig) + +func (f diffOptionFunc) applyDiffOption() {} + +// DiffDetectMoves returns an option that enables move and copy detection. +func DiffDetectMoves(enable bool) DiffOption { + return diffOptionFunc(func(c *diffConfig) { + c.detectMoves = enable + }) +} + +// Keyer is an interface that types can implement to provide a canonical +// representation for map keys. This allows semantic equality checks for +// complex map keys. +type Keyer interface { + CanonicalKey() any +} + +// Differ is a stateless engine for calculating patches between two values. +type Differ struct { + config *diffConfig + customDiffs map[reflect.Type]func(a, b reflect.Value, ctx *diffContext) (diffPatch, error) +} + +// NewDiffer creates a new Differ with the given options. +func NewDiffer(opts ...DiffOption) *Differ { + config := &diffConfig{ + ignoredPaths: make(map[string]bool), + } + for _, opt := range opts { + if f, ok := opt.(diffOptionFunc); ok { + f(config) + } else if u, ok := opt.(unifiedOption); ok { + config.ignoredPaths[icore.NormalizePath(string(u))] = true + } + } + return &Differ{ + config: config, + customDiffs: make(map[reflect.Type]func(a, b reflect.Value, ctx *diffContext) (diffPatch, error)), + } +} + +// diffContext holds transient state for a single Diff execution. +type diffContext struct { + valueIndex map[any]string + movedPaths map[string]bool + visited map[icore.VisitKey]bool + pathStack []string + rootB reflect.Value +} + +var diffContextPool = sync.Pool{ + New: func() any { + return &diffContext{ + valueIndex: make(map[any]string), + movedPaths: make(map[string]bool), + visited: make(map[icore.VisitKey]bool), + pathStack: make([]string, 0, 32), + } + }, +} + +func getDiffContext() *diffContext { + return diffContextPool.Get().(*diffContext) +} + +func releaseDiffContext(ctx *diffContext) { + for k := range ctx.valueIndex { + delete(ctx.valueIndex, k) + } + for k := range ctx.movedPaths { + delete(ctx.movedPaths, k) + } + for k := range ctx.visited { + delete(ctx.visited, k) + } + ctx.pathStack = ctx.pathStack[:0] + ctx.rootB = reflect.Value{} + diffContextPool.Put(ctx) +} + +func (ctx *diffContext) buildPath() string { + var b strings.Builder + b.WriteByte('/') + for i, s := range ctx.pathStack { + if i > 0 { + b.WriteByte('/') + } + b.WriteString(icore.EscapeKey(s)) + } + return b.String() +} + +var ( + defaultDiffer = NewDiffer() + mu sync.RWMutex +) + +// RegisterCustomDiff registers a custom diff function for a specific type globally. +func RegisterCustomDiff[T any](fn func(a, b T) (Patch[T], error)) { + mu.Lock() + d := defaultDiffer + mu.Unlock() + + var t T + typ := reflect.TypeOf(t) + d.customDiffs[typ] = func(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { + p, err := fn(a.Interface().(T), b.Interface().(T)) + if err != nil { + return nil, err + } + if p == nil { + return nil, nil + } + if unwrapper, ok := p.(patchUnwrapper); ok { + return unwrapper.unwrap(), nil + } + return &customDiffPatch{patch: p}, nil + } +} + +func (d *Differ) detectMovesRecursive(v reflect.Value, ctx *diffContext) { + if !v.IsValid() { + return + } + + key := getHashKey(v) + if key != nil && isHashable(v) { + if fromPath, ok := ctx.valueIndex[key]; ok { + currentPath := ctx.buildPath() + if fromPath != currentPath { + if isMove, checked := ctx.movedPaths[fromPath]; checked { + // Already checked this source path. + _ = isMove + } else { + if !d.isValueAtTarget(fromPath, v.Interface(), ctx) { + ctx.movedPaths[fromPath] = true + } else { + ctx.movedPaths[fromPath] = false + } + } + } + } + } + + switch v.Kind() { + case reflect.Struct: + info := icore.GetTypeInfo(v.Type()) + for _, fInfo := range info.Fields { + if fInfo.Tag.Ignore { + continue + } + ctx.pathStack = append(ctx.pathStack, fInfo.Name) + d.detectMovesRecursive(v.Field(fInfo.Index), ctx) + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + } + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + ctx.pathStack = append(ctx.pathStack, strconv.Itoa(i)) + d.detectMovesRecursive(v.Index(i), ctx) + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + } + case reflect.Map: + iter := v.MapRange() + for iter.Next() { + k := iter.Key() + ck := k.Interface() + if keyer, ok := ck.(Keyer); ok { + ck = keyer.CanonicalKey() + } + ctx.pathStack = append(ctx.pathStack, fmt.Sprintf("%v", ck)) + d.detectMovesRecursive(iter.Value(), ctx) + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + } + case reflect.Pointer, reflect.Interface: + if !v.IsNil() { + d.detectMovesRecursive(v.Elem(), ctx) + } + } +} + +// Diff compares two values a and b and returns a Patch that can be applied. +// It returns an error if the comparison fails (e.g., due to custom diff failure). +func Diff[T any](a, b T, opts ...DiffOption) (Patch[T], error) { + var d *Differ + if len(opts) == 0 { + mu.RLock() + d = defaultDiffer + mu.RUnlock() + } else { + d = NewDiffer(opts...) + } + return DiffUsing(d, a, b) +} + +// DiffUsing compares two values a and b using the specified Differ and returns a Patch. +func DiffUsing[T any](d *Differ, a, b T) (Patch[T], error) { + va := reflect.ValueOf(&a).Elem() + vb := reflect.ValueOf(&b).Elem() + + ctx := getDiffContext() + defer releaseDiffContext(ctx) + ctx.rootB = vb + + if d.config.detectMoves { + d.indexValues(va, ctx) + d.detectMovesRecursive(vb, ctx) + } + + patch, err := d.diffRecursive(va, vb, false, ctx) + if err != nil { + return nil, err + } + + if patch == nil { + return nil, nil + } + + return &typedPatch[T]{ + inner: patch, + strict: true, + }, nil +} + +// MustDiff compares two values a and b and returns a Patch that can be applied. +// It panics if the comparison fails. +func MustDiff[T any](a, b T, opts ...DiffOption) Patch[T] { + p, err := Diff(a, b, opts...) + if err != nil { + panic(err) + } + return p +} + +func isHashable(v reflect.Value) bool { + kind := v.Kind() + switch kind { + case reflect.Slice, reflect.Map, reflect.Func: + return false + case reflect.Pointer, reflect.Interface: + if v.IsNil() { + return true + } + return isHashable(v.Elem()) + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + if !isHashable(v.Field(i)) { + return false + } + } + case reflect.Array: + for i := 0; i < v.Len(); i++ { + if !isHashable(v.Index(i)) { + return false + } + } + } + return true +} + +func isNilValue(v reflect.Value) bool { + if !v.IsValid() { + return true + } + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + } + return false +} + +func (d *Differ) tryDetectMove(v reflect.Value, path string, ctx *diffContext) (fromPath string, isMove bool, found bool) { + if !d.config.detectMoves { + return "", false, false + } + key := getHashKey(v) + if key == nil || !isHashable(v) { + return "", false, false + } + fromPath, found = ctx.valueIndex[key] + if !found || fromPath == path { + return "", false, false + } + isMove = ctx.movedPaths[fromPath] + return fromPath, isMove, true +} + +func getHashKey(v reflect.Value) any { + if !v.IsValid() { + return nil + } + if v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { + if v.IsNil() { + return nil + } + // For pointers/interfaces, use the pointer value as key to ensure + // consistent identity regardless of the interface type it's wrapped in. + if v.Kind() == reflect.Pointer { + return v.Pointer() + } + // For interfaces, recurse to get the underlying pointer or value. + return getHashKey(v.Elem()) + } + if v.CanInterface() { + return v.Interface() + } + return nil +} + +func (d *Differ) indexValues(v reflect.Value, ctx *diffContext) { + if !v.IsValid() { + return + } + + key := getHashKey(v) + if key != nil && isHashable(v) { + if _, ok := ctx.valueIndex[key]; !ok { + ctx.valueIndex[key] = ctx.buildPath() + } + } + + kind := v.Kind() + if kind == reflect.Pointer || kind == reflect.Interface { + if v.IsNil() { + return + } + if kind == reflect.Pointer { + ptr := v.Pointer() + if ctx.visited[icore.VisitKey{A: ptr}] { + return + } + ctx.visited[icore.VisitKey{A: ptr}] = true + } + d.indexValues(v.Elem(), ctx) + return + } + + switch kind { + case reflect.Struct: + info := icore.GetTypeInfo(v.Type()) + for _, fInfo := range info.Fields { + if fInfo.Tag.Ignore { + continue + } + ctx.pathStack = append(ctx.pathStack, fInfo.Name) + d.indexValues(v.Field(fInfo.Index), ctx) + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + } + case reflect.Slice, reflect.Array: + for i := 0; i < v.Len(); i++ { + ctx.pathStack = append(ctx.pathStack, strconv.Itoa(i)) + d.indexValues(v.Index(i), ctx) + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + } + case reflect.Map: + iter := v.MapRange() + for iter.Next() { + k := iter.Key() + ck := k.Interface() + if keyer, ok := ck.(Keyer); ok { + ck = keyer.CanonicalKey() + } + ctx.pathStack = append(ctx.pathStack, fmt.Sprintf("%v", ck)) + d.indexValues(iter.Value(), ctx) + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + } + } +} + +func (d *Differ) isValueAtTarget(path string, val any, ctx *diffContext) bool { + if !ctx.rootB.IsValid() { + return false + } + targetVal, err := icore.DeepPath(path).Resolve(ctx.rootB) + if err != nil { + return false + } + if !targetVal.IsValid() { + return false + } + return icore.Equal(targetVal.Interface(), val) +} + +func (d *Differ) diffRecursive(a, b reflect.Value, atomic bool, ctx *diffContext) (diffPatch, error) { + if len(d.config.ignoredPaths) > 0 { + if d.config.ignoredPaths[ctx.buildPath()] { + return nil, nil + } + } + + if !a.IsValid() && !b.IsValid() { + return nil, nil + } + + if atomic { + if icore.ValueEqual(a, b, nil) { + return nil, nil + } + return newValuePatch(icore.DeepCopyValue(a), icore.DeepCopyValue(b)), nil + } + + if !a.IsValid() || !b.IsValid() { + if !b.IsValid() { + return newValuePatch(a, reflect.Value{}), nil + } + return newValuePatch(a, b), nil + } + + if a.Type() != b.Type() { + return newValuePatch(a, b), nil + } + + if a.Kind() == reflect.Struct || a.Kind() == reflect.Map || a.Kind() == reflect.Slice { + if !atomic { + // Skip valueEqual and recurse + } else { + if icore.ValueEqual(a, b, nil) { + return nil, nil + } + } + } else { + // Basic types equality handled by Kind switch below. + } + + if fn, ok := d.customDiffs[a.Type()]; ok { + return fn(a, b, ctx) + } + + // Move/Copy Detection + if fromPath, isMove, ok := d.tryDetectMove(b, ctx.buildPath(), ctx); ok { + if isMove { + return &movePatch{from: fromPath, path: ctx.buildPath()}, nil + } + return ©Patch{from: fromPath}, nil + } + + if a.CanInterface() { + if a.Kind() == reflect.Struct || a.Kind() == reflect.Pointer { + method := a.MethodByName("Diff") + if method.IsValid() && method.Type().NumIn() == 1 && method.Type().NumOut() == 2 { + if method.Type().In(0) == a.Type() && + method.Type().Out(1).Implements(reflect.TypeOf((*error)(nil)).Elem()) { + res := method.Call([]reflect.Value{b}) + if !res[1].IsNil() { + return nil, res[1].Interface().(error) + } + if res[0].IsNil() { + return nil, nil + } + if unwrapper, ok := res[0].Interface().(patchUnwrapper); ok { + return unwrapper.unwrap(), nil + } + return &customDiffPatch{patch: res[0].Interface()}, nil + } + } + } + } + + switch a.Kind() { + case reflect.Bool: + if a.Bool() == b.Bool() { + return nil, nil + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if a.Int() == b.Int() { + return nil, nil + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + if a.Uint() == b.Uint() { + return nil, nil + } + case reflect.Float32, reflect.Float64: + if a.Float() == b.Float() { + return nil, nil + } + case reflect.Complex64, reflect.Complex128: + if a.Complex() == b.Complex() { + return nil, nil + } + case reflect.String: + if a.String() == b.String() { + return nil, nil + } + case reflect.Pointer: + return d.diffPtr(a, b, ctx) + case reflect.Interface: + return d.diffInterface(a, b, ctx) + case reflect.Struct: + return d.diffStruct(a, b, ctx) + case reflect.Slice: + return d.diffSlice(a, b, ctx) + case reflect.Map: + return d.diffMap(a, b, ctx) + case reflect.Array: + return d.diffArray(a, b, ctx) + default: + if a.Kind() == reflect.Func || a.Kind() == reflect.Chan || a.Kind() == reflect.UnsafePointer { + if a.IsNil() && b.IsNil() { + return nil, nil + } + } + } + + // Default: if types are basic and immutable, return valuePatch without deep copy. + k := a.Kind() + if (k >= reflect.Bool && k <= reflect.String) || k == reflect.Float32 || k == reflect.Float64 || k == reflect.Complex64 || k == reflect.Complex128 { + return newValuePatch(a, b), nil + } + return newValuePatch(icore.DeepCopyValue(a), icore.DeepCopyValue(b)), nil +} + +func (d *Differ) diffPtr(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { + if a.IsNil() && b.IsNil() { + return nil, nil + } + if a.IsNil() { + return newValuePatch(a, b), nil + } + if b.IsNil() { + return newValuePatch(icore.DeepCopyValue(a), reflect.Zero(a.Type())), nil + } + + if a.Pointer() == b.Pointer() { + return nil, nil + } + + k := icore.VisitKey{A: a.Pointer(), B: b.Pointer(), Typ: a.Type()} + if ctx.visited[k] { + return nil, nil + } + ctx.visited[k] = true + + elemPatch, err := d.diffRecursive(a.Elem(), b.Elem(), false, ctx) + if err != nil { + return nil, err + } + if elemPatch == nil { + return nil, nil + } + + return newPtrPatch(elemPatch), nil +} + +func (d *Differ) diffInterface(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { + if a.IsNil() && b.IsNil() { + return nil, nil + } + if a.IsNil() || b.IsNil() { + if !b.IsValid() { + return newValuePatch(a, reflect.Value{}), nil + } + return newValuePatch(a, b), nil + } + + if a.Elem().Type() != b.Elem().Type() { + return newValuePatch(a, b), nil + } + + elemPatch, err := d.diffRecursive(a.Elem(), b.Elem(), false, ctx) + if err != nil { + return nil, err + } + if elemPatch == nil { + return nil, nil + } + + return &interfacePatch{elemPatch: elemPatch}, nil +} + +func (d *Differ) diffStruct(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { + var fields map[string]diffPatch + info := icore.GetTypeInfo(b.Type()) + + for _, fInfo := range info.Fields { + if fInfo.Tag.Ignore { + continue + } + + var fA reflect.Value + if a.IsValid() { + fA = a.Field(fInfo.Index) + if !fA.CanInterface() { + unsafe.DisableRO(&fA) + } + } + fB := b.Field(fInfo.Index) + if !fB.CanInterface() { + unsafe.DisableRO(&fB) + } + + ctx.pathStack = append(ctx.pathStack, fInfo.Name) + patch, err := d.diffRecursive(fA, fB, fInfo.Tag.Atomic, ctx) + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + + if err != nil { + return nil, err + } + if patch != nil { + if fInfo.Tag.ReadOnly { + patch = &readOnlyPatch{inner: patch} + } + if fields == nil { + fields = make(map[string]diffPatch) + } + fields[fInfo.Name] = patch + } + } + + if fields == nil { + return nil, nil + } + + sp := newStructPatch() + for k, v := range fields { + sp.fields[k] = v + } + return sp, nil +} + +func (d *Differ) diffArray(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { + indices := make(map[int]diffPatch) + + for i := 0; i < b.Len(); i++ { + ctx.pathStack = append(ctx.pathStack, strconv.Itoa(i)) + var vA reflect.Value + if a.IsValid() && i < a.Len() { + vA = a.Index(i) + } + patch, err := d.diffRecursive(vA, b.Index(i), false, ctx) + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + + if err != nil { + return nil, err + } + if patch != nil { + indices[i] = patch + } + } + + if len(indices) == 0 { + return nil, nil + } + + return &arrayPatch{indices: indices}, nil +} + +func (d *Differ) diffMap(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { + if isNilValue(a) && isNilValue(b) { + return nil, nil + } + if isNilValue(a) || isNilValue(b) { + if isNilValue(b) { + return newValuePatch(a, reflect.Value{}), nil + } + if !d.config.detectMoves { + return newValuePatch(a, b), nil + } + } + + if a.IsValid() && b.IsValid() && a.Pointer() == b.Pointer() { + return nil, nil + } + + var added map[any]reflect.Value + var removed map[any]reflect.Value + var modified map[any]diffPatch + var originalKeys map[any]any + + getCanonical := func(v reflect.Value) any { + if v.CanInterface() { + val := v.Interface() + if k, ok := val.(Keyer); ok { + ck := k.CanonicalKey() + if originalKeys == nil { + originalKeys = make(map[any]any) + } + originalKeys[ck] = val + return ck + } + return val + } + return v.Interface() + } + + bByCanonical := make(map[any]reflect.Value) + iterB := b.MapRange() + for iterB.Next() { + bByCanonical[getCanonical(iterB.Key())] = iterB.Value() + } + + if a.IsValid() { + iterA := a.MapRange() + for iterA.Next() { + k := iterA.Key() + vA := iterA.Value() + ck := getCanonical(k) + + ctx.pathStack = append(ctx.pathStack, fmt.Sprintf("%v", ck)) + vB, found := bByCanonical[ck] + if !found { + currentPath := ctx.buildPath() + if (len(d.config.ignoredPaths) == 0 || !d.config.ignoredPaths[currentPath]) && !ctx.movedPaths[currentPath] { + if removed == nil { + removed = make(map[any]reflect.Value) + } + removed[ck] = icore.DeepCopyValue(vA) + } + } else { + patch, err := d.diffRecursive(vA, vB, false, ctx) + if err != nil { + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + return nil, err + } + if patch != nil { + if modified == nil { + modified = make(map[any]diffPatch) + } + modified[ck] = patch + } + delete(bByCanonical, ck) + } + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + } + } + + for ck, vB := range bByCanonical { + // Escape the key before joining + currentPath := icore.JoinPath(ctx.buildPath(), icore.EscapeKey(fmt.Sprintf("%v", ck))) + if len(d.config.ignoredPaths) == 0 || !d.config.ignoredPaths[currentPath] { + if fromPath, isMove, ok := d.tryDetectMove(vB, currentPath, ctx); ok { + if modified == nil { + modified = make(map[any]diffPatch) + } + if isMove { + modified[ck] = &movePatch{from: fromPath, path: currentPath} + } else { + modified[ck] = ©Patch{from: fromPath} + } + delete(bByCanonical, ck) + continue + } + if added == nil { + added = make(map[any]reflect.Value) + } + added[ck] = icore.DeepCopyValue(vB) + } + } + + if added == nil && removed == nil && modified == nil { + return nil, nil + } + + mp := newMapPatch(b.Type().Key()) + for k, v := range added { + mp.added[k] = v + } + for k, v := range removed { + mp.removed[k] = v + } + for k, v := range modified { + mp.modified[k] = v + } + for k, v := range originalKeys { + mp.originalKeys[k] = v + } + return mp, nil +} + +func (d *Differ) diffSlice(a, b reflect.Value, ctx *diffContext) (diffPatch, error) { + if isNilValue(a) && isNilValue(b) { + return nil, nil + } + if isNilValue(a) || isNilValue(b) { + if isNilValue(b) { + return newValuePatch(a, reflect.Value{}), nil + } + // If move detection is enabled, we don't want to just return a valuePatch + // because some elements in b might have been moved from elsewhere. + if !d.config.detectMoves { + return newValuePatch(a, b), nil + } + } + + if a.IsValid() && b.IsValid() && a.Pointer() == b.Pointer() { + return nil, nil + } + + lenA := 0 + if a.IsValid() { + lenA = a.Len() + } + lenB := b.Len() + + prefix := 0 + if a.IsValid() { + for prefix < lenA && prefix < lenB { + vA := a.Index(prefix) + vB := b.Index(prefix) + if icore.ValueEqual(vA, vB, nil) { + prefix++ + } else { + break + } + } + } + + suffix := 0 + if a.IsValid() { + for suffix < (lenA-prefix) && suffix < (lenB-prefix) { + vA := a.Index(lenA - 1 - suffix) + vB := b.Index(lenB - 1 - suffix) + if icore.ValueEqual(vA, vB, nil) { + suffix++ + } else { + break + } + } + } + + midAStart := prefix + midAEnd := lenA - suffix + midBStart := prefix + midBEnd := lenB - suffix + + keyField, hasKey := icore.GetKeyField(b.Type().Elem()) + + if midAStart == midAEnd && midBStart < midBEnd { + var ops []sliceOp + for i := midBStart; i < midBEnd; i++ { + var prevKey any + if hasKey { + if i > 0 { + prevKey = icore.ExtractKey(b.Index(i-1), keyField) + } + } + + // Move/Copy Detection + val := b.Index(i) + currentPath := icore.JoinPath(ctx.buildPath(), strconv.Itoa(i)) + if fromPath, isMove, ok := d.tryDetectMove(val, currentPath, ctx); ok { + var p diffPatch + if isMove { + p = &movePatch{from: fromPath, path: currentPath} + } else { + p = ©Patch{from: fromPath} + } + + op := sliceOp{ + Kind: OpCopy, + Index: i, + Patch: p, + PrevKey: prevKey, + } + if hasKey { + op.Key = icore.ExtractKey(val, keyField) + } + ops = append(ops, op) + continue + } + + op := sliceOp{ + Kind: OpAdd, + Index: i, + Val: icore.DeepCopyValue(b.Index(i)), + PrevKey: prevKey, + } + if hasKey { + op.Key = icore.ExtractKey(b.Index(i), keyField) + } + ops = append(ops, op) + } + return &slicePatch{ops: ops}, nil + } + + if midBStart == midBEnd && midAStart < midAEnd { + var ops []sliceOp + for i := midAEnd - 1; i >= midAStart; i-- { + currentPath := icore.JoinPath(ctx.buildPath(), strconv.Itoa(i)) + if ctx.movedPaths[currentPath] { + continue + } + op := sliceOp{ + Kind: OpRemove, + Index: i, + Val: icore.DeepCopyValue(a.Index(i)), + } + if hasKey { + op.Key = icore.ExtractKey(a.Index(i), keyField) + } + ops = append(ops, op) + } + return &slicePatch{ops: ops}, nil + } + + if midAStart >= midAEnd && midBStart >= midBEnd { + return nil, nil + } + + ops, err := d.computeSliceEdits(a, b, midAStart, midAEnd, midBStart, midBEnd, keyField, hasKey, ctx) + if err != nil { + return nil, err + } + + return &slicePatch{ops: ops}, nil +} + +func (d *Differ) computeSliceEdits(a, b reflect.Value, aStart, aEnd, bStart, bEnd, keyField int, hasKey bool, ctx *diffContext) ([]sliceOp, error) { + n := aEnd - aStart + m := bEnd - bStart + + same := func(v1, v2 reflect.Value) bool { + if hasKey { + k1 := v1 + k2 := v2 + if k1.Kind() == reflect.Pointer { + if k1.IsNil() || k2.IsNil() { + return k1.IsNil() && k2.IsNil() + } + k1 = k1.Elem() + k2 = k2.Elem() + } + return icore.ValueEqual(k1.Field(keyField), k2.Field(keyField), nil) + } + return icore.ValueEqual(v1, v2, nil) + } + + max := n + m + v := make([]int, 2*max+1) + offset := max + trace := [][]int{} + + for dStep := 0; dStep <= max; dStep++ { + vc := make([]int, 2*max+1) + copy(vc, v) + trace = append(trace, vc) + + for k := -dStep; k <= dStep; k += 2 { + var x int + if k == -dStep || (k != dStep && v[k-1+offset] < v[k+1+offset]) { + x = v[k+1+offset] + } else { + x = v[k-1+offset] + 1 + } + y := x - k + for x < n && y < m && same(a.Index(aStart+x), b.Index(bStart+y)) { + x++ + y++ + } + v[k+offset] = x + if x >= n && y >= m { + return d.backtrackMyers(a, b, aStart, aEnd, bStart, bEnd, keyField, hasKey, trace, ctx) + } + } + } + + return nil, nil +} + +func (d *Differ) backtrackMyers(a, b reflect.Value, aStart, aEnd, bStart, bEnd, keyField int, hasKey bool, trace [][]int, ctx *diffContext) ([]sliceOp, error) { + var ops []sliceOp + x, y := aEnd-aStart, bEnd-bStart + offset := (aEnd - aStart) + (bEnd - bStart) + + for dStep := len(trace) - 1; dStep > 0; dStep-- { + v := trace[dStep] + k := x - y + + var prevK int + if k == -dStep || (k != dStep && v[k-1+offset] < v[k+1+offset]) { + prevK = k + 1 + } else { + prevK = k - 1 + } + + prevX := v[prevK+offset] + prevY := prevX - prevK + + for x > prevX && y > prevY { + vA := a.Index(aStart + x - 1) + vB := b.Index(bStart + y - 1) + if !icore.ValueEqual(vA, vB, nil) { + ctx.pathStack = append(ctx.pathStack, fmt.Sprintf("%v", icore.ExtractKey(vA, keyField))) + p, err := d.diffRecursive(vA, vB, false, ctx) + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + if err != nil { + return nil, err + } + op := sliceOp{ + Kind: OpReplace, + Index: aStart + x - 1, + Patch: p, + } + if hasKey { + op.Key = icore.ExtractKey(vA, keyField) + } + ops = append(ops, op) + } + x-- + y-- + } + + if x > prevX { + currentPath := icore.JoinPath(ctx.buildPath(), strconv.Itoa(aStart+x-1)) + if !ctx.movedPaths[currentPath] { + op := sliceOp{ + Kind: OpRemove, + Index: aStart + x - 1, + Val: icore.DeepCopyValue(a.Index(aStart + x - 1)), + } + if hasKey { + op.Key = icore.ExtractKey(a.Index(aStart+x-1), keyField) + } + ops = append(ops, op) + } + } else if y > prevY { + var prevKey any + if hasKey && (bStart+y-2 >= 0) { + prevKey = icore.ExtractKey(b.Index(bStart+y-2), keyField) + } + val := b.Index(bStart + y - 1) + + currentPath := icore.JoinPath(ctx.buildPath(), strconv.Itoa(aStart+x)) + if fromPath, isMove, ok := d.tryDetectMove(val, currentPath, ctx); ok { + var p diffPatch + if isMove { + p = &movePatch{from: fromPath, path: currentPath} + } else { + p = ©Patch{from: fromPath} + } + op := sliceOp{ + Kind: OpCopy, + Index: aStart + x, + Patch: p, + PrevKey: prevKey, + } + if hasKey { + op.Key = icore.ExtractKey(val, keyField) + } + ops = append(ops, op) + x, y = prevX, prevY + continue + } + + op := sliceOp{ + Kind: OpAdd, + Index: aStart + x, + Val: icore.DeepCopyValue(val), + PrevKey: prevKey, + } + if hasKey { + op.Key = icore.ExtractKey(b.Index(bStart+y-1), keyField) + } + ops = append(ops, op) + } + x, y = prevX, prevY + } + + for x > 0 && y > 0 { + vA := a.Index(aStart + x - 1) + vB := b.Index(bStart + y - 1) + if !icore.ValueEqual(vA, vB, nil) { + ctx.pathStack = append(ctx.pathStack, fmt.Sprintf("%v", icore.ExtractKey(vA, keyField))) + p, err := d.diffRecursive(vA, vB, false, ctx) + ctx.pathStack = ctx.pathStack[:len(ctx.pathStack)-1] + if err != nil { + return nil, err + } + op := sliceOp{ + Kind: OpReplace, + Index: aStart + x - 1, + Patch: p, + } + if hasKey { + op.Key = icore.ExtractKey(vA, keyField) + } + ops = append(ops, op) + } + x-- + y-- + } + + for i := 0; i < len(ops)/2; i++ { + ops[i], ops[len(ops)-1-i] = ops[len(ops)-1-i], ops[i] + } + + return ops, nil +} diff --git a/internal/engine/diff_test.go b/internal/engine/diff_test.go new file mode 100644 index 0000000..4a1f266 --- /dev/null +++ b/internal/engine/diff_test.go @@ -0,0 +1,743 @@ +package engine + +import ( + "fmt" + "reflect" + "testing" +) + +func TestDiff_Basic(t *testing.T) { + tests := []struct { + name string + a, b int + }{ + {"Same", 1, 1}, + {"Different", 1, 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patch := MustDiff(tt.a, tt.b) + if tt.a == tt.b { + if patch != nil { + t.Errorf("Expected nil patch, got %v", patch) + } + } else { + if patch == nil { + t.Errorf("Expected non-nil patch") + } + val := tt.a + patch.Apply(&val) + if val != tt.b { + t.Errorf("Apply failed: expected %v, got %v", tt.b, val) + } + } + }) + } +} + +func TestDiff_Struct(t *testing.T) { + type S struct { + A int + B string + c int // unexported + } + a := S{A: 1, B: "one", c: 10} + b := S{A: 2, B: "one", c: 20} + patch := MustDiff(a, b) + if patch == nil { + t.Fatal("Expected patch") + } + patch.Apply(&a) + if a != b { + t.Errorf("Apply failed: expected %+v, got %+v", b, a) + } +} + +func TestDiff_Ptr(t *testing.T) { + v1 := 10 + v2 := 20 + tests := []struct { + name string + a, b *int + }{ + {"BothNil", nil, nil}, + {"NilToVal", nil, &v1}, + {"ValToNil", &v1, nil}, + {"ValToValSame", &v1, &v1}, + {"ValToValDiffAddr", &v1, &v2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var input *int + if tt.a != nil { + val := *tt.a + input = &val + } + target := tt.b + patch := MustDiff(input, target) + isEqual := false + if input == nil && target == nil { + isEqual = true + } else if input != nil && target != nil && *input == *target { + isEqual = true + } + if isEqual { + if patch != nil { + t.Errorf("Expected nil patch") + } + } else { + if patch == nil { + t.Fatal("Expected patch") + } + patch.Apply(&input) + if target == nil { + if input != nil { + t.Errorf("Expected nil, got %v", input) + } + } else { + if input == nil { + t.Errorf("Expected %v, got nil", *target) + } else if *input != *target { + t.Errorf("Expected %v, got %v", *target, *input) + } + } + } + }) + } +} + +func TestDiff_Map(t *testing.T) { + tests := []struct { + name string + a, b map[string]int + }{ + { + "Add", + map[string]int{"a": 1}, + map[string]int{"a": 1, "b": 2}, + }, + { + "Remove", + map[string]int{"a": 1, "b": 2}, + map[string]int{"a": 1}, + }, + { + "Modify", + map[string]int{"a": 1}, + map[string]int{"a": 2}, + }, + { + "Mixed", + map[string]int{"a": 1, "b": 2}, + map[string]int{"a": 2, "c": 3}, + }, + { + "NilToMap", + nil, + map[string]int{"a": 1}, + }, + { + "MapToNil", + map[string]int{"a": 1}, + nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var a map[string]int + if tt.a != nil { + a = make(map[string]int) + for k, v := range tt.a { + a[k] = v + } + } + patch := MustDiff(a, tt.b) + if patch == nil { + t.Fatal("Expected patch") + } + patch.Apply(&a) + if !reflect.DeepEqual(a, tt.b) { + t.Errorf("Apply failed: expected %v, got %v", tt.b, a) + } + }) + } +} + +func TestDiff_Slice(t *testing.T) { + tests := []struct { + name string + a, b []string + }{ + { + "Append", + []string{"a"}, + []string{"a", "b"}, + }, + { + "DeleteEnd", + []string{"a", "b"}, + []string{"a"}, + }, + { + "DeleteStart", + []string{"a", "b"}, + []string{"b"}, + }, + { + "InsertStart", + []string{"b"}, + []string{"a", "b"}, + }, + { + "InsertMiddle", + []string{"a", "c"}, + []string{"a", "b", "c"}, + }, + { + "Modify", + []string{"a", "b", "c"}, + []string{"a", "X", "c"}, + }, + { + "Complex", + []string{"a", "b", "c", "d"}, + []string{"a", "c", "E", "d", "f"}, + }, + { + "NilToSlice", + nil, + []string{"a"}, + }, + { + "SliceToNil", + []string{"a"}, + nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var a []string + if tt.a != nil { + a = make([]string, len(tt.a)) + copy(a, tt.a) + } + patch := MustDiff(a, tt.b) + if patch == nil { + t.Fatal("Expected patch") + } + patch.Apply(&a) + if !reflect.DeepEqual(a, tt.b) { + t.Errorf("Apply failed: expected %v, got %v", tt.b, a) + } + }) + } +} + +func TestDiff_Array(t *testing.T) { + a := [3]int{1, 2, 3} + b := [3]int{1, 4, 3} + patch := MustDiff(a, b) + patch.Apply(&a) + if a != b { + t.Errorf("Apply failed: expected %v, got %v", b, a) + } +} + +func TestDiff_Interface(t *testing.T) { + var a any = 1 + var b any = 2 + patch := MustDiff(a, b) + patch.Apply(&a) + if a != b { + t.Errorf("Apply failed: expected %v, got %v", b, a) + } + b = "string" + patch = MustDiff(a, b) + patch.Apply(&a) + if a != b { + t.Errorf("Apply failed: expected %v, got %v", b, a) + } +} + +func TestDiff_Nested(t *testing.T) { + type Child struct { + Name string + } + type Parent struct { + C *Child + L []int + } + a := Parent{ + C: &Child{Name: "old"}, + L: []int{1, 2}, + } + b := Parent{ + C: &Child{Name: "new"}, + L: []int{1, 2, 3}, + } + patch := MustDiff(a, b) + patch.Apply(&a) + if !reflect.DeepEqual(a, b) { + t.Errorf("Apply failed") + } +} + +func TestDiff_SliceStruct(t *testing.T) { + type S struct { + ID int + V string + } + a := []S{{1, "v1"}, {2, "v2"}} + b := []S{{1, "v1"}, {2, "v2-mod"}} + patch := MustDiff(a, b) + patch.Apply(&a) + if !reflect.DeepEqual(a, b) { + t.Errorf("Apply failed") + } +} + +func TestDiff_InterfaceExhaustive(t *testing.T) { + tests := []struct { + name string + a, b any + }{ + {"NilToNil", nil, nil}, + {"NilToVal", nil, 1}, + {"ValToNil", 1, nil}, + {"SameTypeDiffVal", 1, 2}, + {"DiffType", 1, "string"}, + {"SameTypeNestedDiff", map[string]int{"a": 1}, map[string]int{"a": 2}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := tt.a + patch := MustDiff(a, tt.b) + if tt.a == nil && tt.b == nil { + if patch != nil { + t.Errorf("Expected nil patch") + } + return + } + if patch == nil { + t.Fatal("Expected patch") + } + patch.Apply(&a) + if !reflect.DeepEqual(a, tt.b) { + t.Errorf("Apply failed: expected %v, got %v", tt.b, a) + } + }) + } +} + +func TestDiff_MapExhaustive(t *testing.T) { + type S struct{ A int } + tests := []struct { + name string + a, b map[string]S + }{ + { + "ModifiedValue", + map[string]S{"a": {1}}, + map[string]S{"a": {2}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := tt.a + patch := MustDiff(a, tt.b) + patch.Apply(&a) + if !reflect.DeepEqual(a, tt.b) { + t.Errorf("Apply failed: expected %v, got %v", tt.b, a) + } + }) + } +} + +type CustomTypeForDiffer struct { + Value int +} + +func (c CustomTypeForDiffer) Diff(other CustomTypeForDiffer) (Patch[CustomTypeForDiffer], error) { + if c.Value == other.Value { + return nil, nil + } + type internal CustomTypeForDiffer + p := MustDiff(internal(c), internal{Value: other.Value + 1000}) + return &typedPatch[CustomTypeForDiffer]{ + inner: p.(*typedPatch[internal]).inner, + strict: true, + }, nil +} + +func TestDiff_CustomDiffer_ValueReceiver(t *testing.T) { + a := CustomTypeForDiffer{Value: 10} + b := CustomTypeForDiffer{Value: 20} + + patch := MustDiff(a, b) + if patch == nil { + t.Fatal("Expected patch") + } + + patch.Apply(&a) + + expected := 20 + 1000 + if a.Value != expected { + t.Errorf("Custom Diff method was not called correctly: expected %d, got %d", expected, a.Value) + } +} + +type CustomPtrTypeForDiffer struct { + Value int +} + +func (c *CustomPtrTypeForDiffer) Diff(other *CustomPtrTypeForDiffer) (Patch[*CustomPtrTypeForDiffer], error) { + if (c == nil && other == nil) || (c != nil && other != nil && c.Value == other.Value) { + return nil, nil + } + + type internal CustomPtrTypeForDiffer + p := MustDiff((*internal)(c), &internal{Value: other.Value + 5000}) + return &typedPatch[*CustomPtrTypeForDiffer]{ + inner: p.(*typedPatch[*internal]).inner, + strict: true, + }, nil +} + +func TestDiff_CustomDiffer_PointerReceiver(t *testing.T) { + a := &CustomPtrTypeForDiffer{Value: 10} + b := &CustomPtrTypeForDiffer{Value: 20} + + patch := MustDiff(a, b) + if patch == nil { + t.Fatal("Expected patch") + } + + patch.Apply(&a) + + expected := 20 + 5000 + if a.Value != expected { + t.Errorf("Custom Diff method (ptr receiver) was not called correctly: expected %d, got %d", expected, a.Value) + } +} + +type CustomErrorDiffer struct { + Value int +} + +func (c CustomErrorDiffer) Diff(other CustomErrorDiffer) (Patch[CustomErrorDiffer], error) { + return nil, fmt.Errorf("custom error") +} + +func TestDiff_CustomDiffer_ErrorCase(t *testing.T) { + a := CustomErrorDiffer{Value: 1} + b := CustomErrorDiffer{Value: 2} + + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic due to custom error in Diff") + } else { + if fmt.Sprintf("%v", r) != "custom error" { + t.Errorf("Expected panic 'custom error', got '%v'", r) + } + } + }() + + MustDiff(a, b) +} + +func TestDiff_CustomDiffer_ToJSONPatch(t *testing.T) { + a := CustomTypeForDiffer{Value: 10} + b := CustomTypeForDiffer{Value: 20} + + patch := MustDiff(a, b) + if patch == nil { + t.Fatal("Expected patch") + } + + jsonPatch, err := patch.ToJSONPatch() + if err != nil { + t.Fatalf("ToJSONPatch failed: %v", err) + } + + expected := `[{"op":"replace","path":"/Value","value":1020}]` + if string(jsonPatch) != expected { + t.Errorf("Expected JSON patch %s, got %s", expected, string(jsonPatch)) + } +} + +type CustomNestedForJSON struct { + Inner CustomTypeForDiffer +} + +func TestDiff_CustomDiffer_ToJSONPatch_Nested(t *testing.T) { + a := CustomNestedForJSON{Inner: CustomTypeForDiffer{Value: 10}} + b := CustomNestedForJSON{Inner: CustomTypeForDiffer{Value: 20}} + + patch := MustDiff(a, b) + if patch == nil { + t.Fatal("Expected patch") + } + + jsonPatch, err := patch.ToJSONPatch() + if err != nil { + t.Fatalf("ToJSONPatch failed: %v", err) + } + + expected := `[{"op":"replace","path":"/Inner/Value","value":1020}]` + if string(jsonPatch) != expected { + t.Errorf("Expected JSON patch %s, got %s", expected, string(jsonPatch)) + } +} + +func TestRegisterCustomDiff(t *testing.T) { + type Custom struct { + Val string + } + + RegisterCustomDiff(func(a, b Custom) (Patch[Custom], error) { + if a.Val == b.Val { + return nil, nil + } + /* + builder := NewPatchBuilder[Custom]() + builder.Field("Val").Put("CUSTOM:" + b.Val) + return builder.Build() + */ + return &typedPatch[Custom]{ + inner: &structPatch{ + fields: map[string]diffPatch{ + "Val": &valuePatch{newVal: reflect.ValueOf("CUSTOM:" + b.Val)}, + }, + }, + }, nil + }) + + c1 := Custom{Val: "old"} + c2 := Custom{Val: "new"} + + patch := MustDiff(c1, c2) + if patch == nil { + t.Fatal("Expected patch") + } + + target := Custom{Val: "old"} + patch.Apply(&target) + + if target.Val != "CUSTOM:new" { + t.Errorf("Expected CUSTOM:new, got %s", target.Val) + } +} + +type KeyedTask struct { + ID string `deep:"key"` + Status string + Value int +} + +func TestKeyedSlice_Basic(t *testing.T) { + a := []KeyedTask{ + {ID: "t1", Status: "todo", Value: 1}, + {ID: "t2", Status: "todo", Value: 2}, + } + b := []KeyedTask{ + {ID: "t2", Status: "done", Value: 2}, + {ID: "t1", Status: "todo", Value: 1}, + } + + patch := MustDiff(a, b) + if patch == nil { + t.Fatal("Expected patch") + } + + patch.Apply(&a) + + if len(a) != 2 { + t.Fatalf("Expected 2 tasks, got %d", len(a)) + } + + if a[0].ID != "t2" || a[0].Status != "done" { + t.Errorf("Expected t2 done at index 0, got %+v", a[0]) + } + if a[1].ID != "t1" || a[1].Status != "todo" { + t.Errorf("Expected t1 todo at index 1, got %+v", a[1]) + } +} + +func TestKeyedSlice_Ptr(t *testing.T) { + a := []*KeyedTask{ + {ID: "t1", Status: "todo"}, + {ID: "t2", Status: "todo"}, + } + b := []*KeyedTask{ + {ID: "t2", Status: "done"}, + {ID: "t1", Status: "todo"}, + } + + patch := MustDiff(a, b) + if patch == nil { + t.Fatal("Expected patch") + } + + patch.Apply(&a) + + if a[0].ID != "t2" || a[0].Status != "done" { + t.Errorf("Expected t2 done at index 0, got %+v", a[0]) + } + if a[1].ID != "t1" || a[1].Status != "todo" { + t.Errorf("Expected t1 todo at index 1, got %+v", a[1]) + } +} + +func TestKeyedSlice_Complex(t *testing.T) { + a := []KeyedTask{ + {ID: "t1", Status: "todo"}, + {ID: "t2", Status: "todo"}, + {ID: "t3", Status: "todo"}, + } + b := []KeyedTask{ + {ID: "t3", Status: "todo"}, + {ID: "t1", Status: "done"}, + {ID: "t4", Status: "new"}, + } + + patch := MustDiff(a, b) + if patch == nil { + t.Fatal("Expected patch") + } + + patch.Apply(&a) + + if len(a) != 3 { + t.Fatalf("Expected 3 tasks, got %d", len(a)) + } + + if a[0].ID != "t3" || a[1].ID != "t1" || a[1].Status != "done" || a[2].ID != "t4" { + t.Errorf("Unexpected results after apply: %+v", a) + } +} + +func TestDiff_MoveDetection(t *testing.T) { + type Document struct { + Title string + Content string + } + + type Workspace struct { + Drafts []Document + Archive map[string]Document + } + + doc := Document{ + Title: "Move Test", + Content: "Some content", + } + + ws := Workspace{ + Drafts: []Document{doc}, + Archive: make(map[string]Document), + } + + target := Workspace{ + Drafts: []Document{}, + Archive: map[string]Document{ + "moved": doc, + }, + } + + t.Run("Disabled", func(t *testing.T) { + patch := MustDiff(ws, target, DiffDetectMoves(false)) + moveCount := 0 + patch.Walk(func(path string, op OpKind, old, new any) error { + if op == OpMove { + moveCount++ + } + return nil + }) + if moveCount != 0 { + t.Errorf("Expected 0 moves when disabled, got %d", moveCount) + } + + // Verify apply still works (via copy/delete) + final, _ := Copy(ws) + err := patch.ApplyChecked(&final) + if err != nil { + t.Fatalf("Apply failed: %v", err) + } + if len(final.Drafts) != 0 || len(final.Archive) != 1 || final.Archive["moved"].Title != doc.Title { + t.Errorf("Final state incorrect: %+v", final) + } + }) + + t.Run("Enabled", func(t *testing.T) { + patch := MustDiff(ws, target, DiffDetectMoves(true)) + moveCount := 0 + var movePath string + var moveFrom string + patch.Walk(func(path string, op OpKind, old, new any) error { + if op == OpMove { + moveCount++ + movePath = path + moveFrom = old.(string) + } + return nil + }) + if moveCount != 1 { + t.Errorf("Expected 1 move when enabled, got %d", moveCount) + } + if movePath != "/Archive/moved" || moveFrom != "/Drafts/0" { + t.Errorf("Unexpected move: %s from %s", movePath, moveFrom) + } + + // Verify apply works + final, _ := Copy(ws) + err := patch.ApplyChecked(&final) + if err != nil { + t.Fatalf("Apply failed: %v", err) + } + if len(final.Drafts) != 0 || len(final.Archive) != 1 || final.Archive["moved"].Title != doc.Title { + t.Errorf("Final state incorrect: %+v", final) + } + }) + + t.Run("MapToSlice", func(t *testing.T) { + doc := &Document{ + Title: "Move Test Ptr", + Content: "Some content", + } + type WorkspacePtr struct { + Drafts []*Document + Archive map[string]*Document + } + ws := WorkspacePtr{ + Drafts: []*Document{}, + Archive: map[string]*Document{ + "d1": doc, + }, + } + target := WorkspacePtr{ + Drafts: []*Document{doc}, + Archive: map[string]*Document{}, + } + + patch := MustDiff(ws, target, DiffDetectMoves(true)) + moveCount := 0 + patch.Walk(func(path string, op OpKind, old, new any) error { + if op == OpMove { + moveCount++ + } + return nil + }) + if moveCount != 1 { + t.Errorf("Expected 1 move, got %d", moveCount) + } + + final, _ := Copy(ws) + err := patch.ApplyChecked(&final) + if err != nil { + t.Fatalf("Apply failed: %v", err) + } + if len(final.Drafts) != 1 || len(final.Archive) != 0 || final.Drafts[0].Title != doc.Title { + t.Errorf("Final state incorrect: %+v", final) + } + }) +} diff --git a/equal.go b/internal/engine/equal.go similarity index 67% rename from equal.go rename to internal/engine/equal.go index 8afbefd..42bf4d0 100644 --- a/equal.go +++ b/internal/engine/equal.go @@ -1,16 +1,16 @@ -package deep +package engine import ( - "github.com/brunoga/deep/v4/internal/core" + icore "github.com/brunoga/deep/v5/internal/core" ) // Equal performs a deep equality check between a and b. // It supports cyclic references and unexported fields. // You can customize behavior using EqualOption (e.g., IgnorePath). func Equal[T any](a, b T, opts ...EqualOption) bool { - coreOpts := make([]core.EqualOption, len(opts)) + coreOpts := make([]icore.EqualOption, len(opts)) for i, opt := range opts { coreOpts[i] = opt.asCoreEqualOption() } - return core.Equal(a, b, coreOpts...) + return icore.Equal(a, b, coreOpts...) } diff --git a/internal/engine/operation.go b/internal/engine/operation.go new file mode 100644 index 0000000..70f0ed0 --- /dev/null +++ b/internal/engine/operation.go @@ -0,0 +1,15 @@ +package engine + +import "github.com/brunoga/deep/v5/condition" + +// Operation represents a single change within a Patch. +type Operation struct { + Kind OpKind `json:"k"` + Path string `json:"p"` + Old any `json:"o,omitempty"` + New any `json:"n,omitempty"` + If *condition.Condition `json:"if,omitempty"` + Unless *condition.Condition `json:"un,omitempty"` + // Strict is stamped from Patch.Strict at apply time; not serialized. + Strict bool `json:"-"` +} diff --git a/options.go b/internal/engine/options.go similarity index 67% rename from options.go rename to internal/engine/options.go index 213f985..afe246a 100644 --- a/options.go +++ b/internal/engine/options.go @@ -1,9 +1,9 @@ -package deep +package engine import ( "reflect" - "github.com/brunoga/deep/v4/internal/core" + icore "github.com/brunoga/deep/v5/internal/core" ) // DiffOption allows configuring the behavior of the Diff function. @@ -13,22 +13,22 @@ type DiffOption interface { // CopyOption allows configuring the behavior of the Copy function. type CopyOption interface { - asCoreCopyOption() core.CopyOption + asCoreCopyOption() icore.CopyOption } // EqualOption allows configuring the behavior of the Equal function. type EqualOption interface { - asCoreEqualOption() core.EqualOption + asCoreEqualOption() icore.EqualOption } type unifiedOption string -func (u unifiedOption) asCoreCopyOption() core.CopyOption { - return core.CopyIgnorePath(string(u)) +func (u unifiedOption) asCoreCopyOption() icore.CopyOption { + return icore.CopyIgnorePath(string(u)) } -func (u unifiedOption) asCoreEqualOption() core.EqualOption { - return core.EqualIgnorePath(string(u)) +func (u unifiedOption) asCoreEqualOption() icore.EqualOption { + return icore.EqualIgnorePath(string(u)) } func (u unifiedOption) applyDiffOption() {} @@ -45,26 +45,26 @@ func IgnorePath(path string) interface { } type simpleCopyOption struct { - opt core.CopyOption + opt icore.CopyOption } -func (s simpleCopyOption) asCoreCopyOption() core.CopyOption { return s.opt } +func (s simpleCopyOption) asCoreCopyOption() icore.CopyOption { return s.opt } // SkipUnsupported returns an option that tells Copy to skip unsupported types. func SkipUnsupported() CopyOption { - return simpleCopyOption{core.SkipUnsupported()} + return simpleCopyOption{icore.SkipUnsupported()} } // RegisterCustomCopy registers a custom copy function for a specific type. func RegisterCustomCopy[T any](fn func(T) (T, error)) { var t T typ := reflect.TypeOf(t) - core.RegisterCustomCopy(typ, reflect.ValueOf(fn)) + icore.RegisterCustomCopy(typ, reflect.ValueOf(fn)) } // RegisterCustomEqual registers a custom equality function for a specific type. func RegisterCustomEqual[T any](fn func(T, T) bool) { var t T typ := reflect.TypeOf(t) - core.RegisterCustomEqual(typ, reflect.ValueOf(fn)) + icore.RegisterCustomEqual(typ, reflect.ValueOf(fn)) } diff --git a/options_test.go b/internal/engine/options_test.go similarity index 96% rename from options_test.go rename to internal/engine/options_test.go index 9bbb72c..da3f2a7 100644 --- a/options_test.go +++ b/internal/engine/options_test.go @@ -1,4 +1,4 @@ -package deep +package engine import ( "testing" @@ -11,13 +11,13 @@ func TestOptions(t *testing.T) { var _ EqualOption = IgnorePath("A") var _ DiffOption = IgnorePath("A") var _ DiffOption = DiffDetectMoves(true) - + // Copy option skip := SkipUnsupported() if skip == nil { t.Error("SkipUnsupported returned nil") } - + // IgnorePath ignore := IgnorePath("A") if ignore == nil { diff --git a/internal/engine/patch.go b/internal/engine/patch.go new file mode 100644 index 0000000..4b7b633 --- /dev/null +++ b/internal/engine/patch.go @@ -0,0 +1,201 @@ +package engine + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" +) + +// OpKind represents the type of operation in a patch. +type OpKind int + +const ( + OpAdd OpKind = iota + OpRemove + OpReplace + OpMove + OpCopy + OpLog +) + +func (k OpKind) String() string { + switch k { + case OpAdd: + return "add" + case OpRemove: + return "remove" + case OpReplace: + return "replace" + case OpMove: + return "move" + case OpCopy: + return "copy" + case OpLog: + return "log" + default: + return "unknown" + } +} + +// Patch represents a set of changes that can be applied to a value of type T. +type Patch[T any] interface { + fmt.Stringer + + // Apply applies the patch to the value pointed to by v. + // The value v must not be nil. + Apply(v *T) + + // ApplyChecked applies the patch only if specific conditions are met. + // If Strict mode is enabled, every modification must match the 'oldVal' recorded in the patch. + ApplyChecked(v *T) error + + // ApplyResolved applies the patch using a custom ConflictResolver. + // This is used for convergent synchronization (CRDTs). + ApplyResolved(v *T, r ConflictResolver) error + + // Walk calls fn for every operation in the patch. + // The path is a JSON Pointer dot-notation path (e.g. "/Field/SubField/0"). + // If fn returns an error, walking stops and that error is returned. + Walk(fn func(path string, op OpKind, old, new any) error) error + + // AsStrict returns a new Patch with strict mode enabled. + AsStrict() Patch[T] + + // 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) + + // Summary returns a human-readable summary of the changes in the patch. + Summary() string +} + +// ApplyError represents one or more errors that occurred during patch application. +type ApplyError struct { + errors []error +} + +func (e *ApplyError) Error() string { + if len(e.errors) == 1 { + return e.errors[0].Error() + } + var b strings.Builder + b.WriteString(fmt.Sprintf("%d errors during apply:\n", len(e.errors))) + for _, err := range e.errors { + b.WriteString("- " + err.Error() + "\n") + } + return b.String() +} + +func (e *ApplyError) Unwrap() []error { + return e.errors +} + +func (e *ApplyError) Errors() []error { + return e.errors +} + +type typedPatch[T any] struct { + inner diffPatch + strict bool +} + +type patchUnwrapper interface { + unwrap() diffPatch +} + +func (p *typedPatch[T]) unwrap() diffPatch { + return p.inner +} + +func (p *typedPatch[T]) Apply(v *T) { + if p.inner == nil { + return + } + rv := reflect.ValueOf(v).Elem() + p.inner.apply(reflect.ValueOf(v), rv, "/") +} + +func (p *typedPatch[T]) ApplyChecked(v *T) error { + if p.inner == nil { + return nil + } + + rv := reflect.ValueOf(v).Elem() + err := p.inner.applyChecked(reflect.ValueOf(v), rv, p.strict, "/") + if err != nil { + if ae, ok := err.(*ApplyError); ok { + return ae + } + return &ApplyError{errors: []error{err}} + } + return nil +} + +func (p *typedPatch[T]) ApplyResolved(v *T, r ConflictResolver) error { + if p.inner == nil { + return nil + } + + rv := reflect.ValueOf(v).Elem() + return p.inner.applyResolved(reflect.ValueOf(v), rv, "/", r) +} + +func (p *typedPatch[T]) Walk(fn func(path string, op OpKind, old, new any) error) error { + if p.inner == nil { + return nil + } + + return p.inner.walk("", func(path string, op OpKind, old, new any) error { + fullPath := path + if fullPath == "" { + fullPath = "/" + } else if fullPath[0] != '/' { + fullPath = "/" + fullPath + } + + return fn(fullPath, op, old, new) + }) +} + +func (p *typedPatch[T]) AsStrict() Patch[T] { + return &typedPatch[T]{ + inner: p.inner, + strict: true, + } +} + +func (p *typedPatch[T]) Reverse() Patch[T] { + if p.inner == nil { + return &typedPatch[T]{} + } + return &typedPatch[T]{ + inner: p.inner.reverse(), + strict: p.strict, + } +} + +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]) Summary() string { + if p.inner == nil { + return "No changes." + } + return p.inner.summary("/") +} + +func (p *typedPatch[T]) String() string { + if p.inner == nil { + return "" + } + return p.inner.format(0) +} diff --git a/patch_graph.go b/internal/engine/patch_graph.go similarity index 93% rename from patch_graph.go rename to internal/engine/patch_graph.go index 942816a..7c8d8e7 100644 --- a/patch_graph.go +++ b/internal/engine/patch_graph.go @@ -1,4 +1,4 @@ -package deep +package engine import ( "fmt" @@ -6,7 +6,7 @@ import ( "sort" "strings" - "github.com/brunoga/deep/v4/internal/core" + icore "github.com/brunoga/deep/v5/internal/core" ) // dependencyNode represents a node in the dependency graph. @@ -23,7 +23,7 @@ type dependencyNode struct { func resolveStructDependencies(p *structPatch, basePath string, root reflect.Value) (map[string]diffPatch, []string, error) { nodes := make(map[string]*dependencyNode) effectivePatches := make(map[string]diffPatch) - + // 1. Build nodes for name, patch := range p.fields { fullPath := basePath @@ -33,7 +33,7 @@ func resolveStructDependencies(p *structPatch, basePath string, root reflect.Val fullPath = "/" + fullPath } fieldPath := fullPath + "/" + name - + reads, writes := patch.dependencies(fieldPath) nodes[name] = &dependencyNode{ name: name, @@ -48,7 +48,7 @@ func resolveStructDependencies(p *structPatch, basePath string, root reflect.Val // A -> B if A reads what B writes. adj := make(map[string][]string) inDegree := make(map[string]int) - + // Initialize inDegree for name := range nodes { inDegree[name] = 0 @@ -115,7 +115,7 @@ func resolveStructDependencies(p *structPatch, basePath string, root reflect.Val queue = append(queue, v) } } - sort.Strings(queue) + sort.Strings(queue) } if processedCount == len(nodes) { @@ -133,24 +133,24 @@ func resolveStructDependencies(p *structPatch, basePath string, root reflect.Val for _, name := range cycleNodes { node := nodes[name] - + if len(node.reads) == 0 { continue } if cp, ok := node.patch.(*copyPatch); ok { - val, err := core.DeepPath(cp.from).Resolve(root) + val, err := icore.DeepPath(cp.from).Resolve(root) if err != nil { return nil, nil, fmt.Errorf("failed to resolve cycle dependency for %s (from %s): %w", name, cp.from, err) } - valCopy := core.DeepCopyValue(val) + valCopy := icore.DeepCopyValue(val) effectivePatches[name] = newValuePatch(reflect.Value{}, valCopy) } else if mp, ok := node.patch.(*movePatch); ok { - val, err := core.DeepPath(mp.from).Resolve(root) + val, err := icore.DeepPath(mp.from).Resolve(root) if err != nil { return nil, nil, fmt.Errorf("failed to resolve cycle dependency for %s (from %s): %w", name, mp.from, err) } - valCopy := core.DeepCopyValue(val) + valCopy := icore.DeepCopyValue(val) effectivePatches[name] = newValuePatch(reflect.Value{}, valCopy) } else { return nil, nil, fmt.Errorf("cycle detected involving non-simple patch type for field %s", name) diff --git a/patch_ops.go b/internal/engine/patch_ops.go similarity index 71% rename from patch_ops.go rename to internal/engine/patch_ops.go index f6c7dcc..fce4f46 100644 --- a/patch_ops.go +++ b/internal/engine/patch_ops.go @@ -1,4 +1,4 @@ -package deep +package engine import ( "encoding/json" @@ -7,13 +7,10 @@ import ( "strconv" "strings" - "github.com/brunoga/deep/v4/cond" - "github.com/brunoga/deep/v4/internal/core" - "github.com/brunoga/deep/v4/internal/unsafe" + icore "github.com/brunoga/deep/v5/internal/core" + "github.com/brunoga/deep/v5/internal/unsafe" ) -var ErrConditionSkipped = fmt.Errorf("condition skipped") - // diffPatch is the internal recursive interface for all patch types. type diffPatch interface { apply(root, v reflect.Value, path string) @@ -22,92 +19,13 @@ type diffPatch interface { reverse() diffPatch format(indent int) string walk(path string, fn func(path string, op OpKind, old, new any) error) error - setCondition(cond any) - setIfCondition(cond any) - setUnlessCondition(cond any) - conditions() (cond, ifCond, unlessCond any) toJSONPatch(path string) []map[string]any summary(path string) string dependencies(path string) (reads []string, writes []string) } -type basePatch struct { - cond any - - ifCond any - - unlessCond any -} - -func (p *basePatch) setCondition(cond any) { p.cond = cond } - -func (p *basePatch) setIfCondition(cond any) { p.ifCond = cond } - -func (p *basePatch) setUnlessCondition(cond any) { p.unlessCond = cond } - -func (p *basePatch) conditions() (any, any, any) { return p.cond, p.ifCond, p.unlessCond } - -func checkConditions(p diffPatch, root, v reflect.Value) error { - c, ifC, unlessC := p.conditions() - if err := checkIfUnless(ifC, unlessC, root); err != nil { - return err - } - return evaluateLocalCondition(c, v) -} - -func evaluateLocalCondition(c any, v reflect.Value) error { - if c == nil { - return nil - } - ok, err := evaluateCondition(c, v) - if err != nil { - return err - } - if !ok { - return fmt.Errorf("local condition failed for value %v", v.Interface()) - } - return nil -} - -func evaluateCondition(c any, v reflect.Value) (bool, error) { - if ic, ok := c.(cond.InternalCondition); ok { - return ic.EvaluateAny(v.Interface()) - } - - if gc, ok := c.(interface { - Evaluate(any) (bool, error) - }); ok { - return gc.Evaluate(v.Interface()) - } - - return false, fmt.Errorf("local condition: %T does not implement required interface", c) -} - -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 { - basePatch oldVal reflect.Value newVal reflect.Value } @@ -123,20 +41,14 @@ func (p *valuePatch) apply(root, v reflect.Value, path string) { if !v.CanSet() { unsafe.DisableRO(&v) } - core.SetValue(v, p.newVal) + icore.SetValue(v, p.newVal) } func (p *valuePatch) applyChecked(root, v reflect.Value, strict bool, path string) 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 := core.ConvertValue(p.oldVal, v.Type()) - if !core.Equal(v.Interface(), convertedOldVal.Interface()) { + convertedOldVal := icore.ConvertValue(p.oldVal, v.Type()) + if !icore.Equal(v.Interface(), convertedOldVal.Interface()) { return fmt.Errorf("value mismatch: expected %v, got %v", convertedOldVal, v) } } else { @@ -165,17 +77,17 @@ func (p *valuePatch) dependencies(path string) (reads []string, writes []string) } func (p *valuePatch) reverse() diffPatch { - return &valuePatch{oldVal: p.newVal, newVal: p.oldVal, basePatch: p.basePatch} + return &valuePatch{oldVal: p.newVal, newVal: p.oldVal} } func (p *valuePatch) walk(path string, fn func(path string, op OpKind, old, new any) error) error { op := OpReplace - if !p.newVal.IsValid() { + if isNilValue(p.newVal) { op = OpRemove - } else if !p.oldVal.IsValid() { + } else if isNilValue(p.oldVal) { op = OpAdd } - return fn(path, op, core.ValueToInterface(p.oldVal), core.ValueToInterface(p.newVal)) + return fn(path, op, icore.ValueToInterface(p.oldVal), icore.ValueToInterface(p.newVal)) } func (p *valuePatch) format(indent int) string { @@ -202,101 +114,25 @@ func (p *valuePatch) toJSONPatch(path string) []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": core.ValueToInterface(p.newVal)} + op = map[string]any{"op": "add", "path": fullPath, "value": icore.ValueToInterface(p.newVal)} } else { - op = map[string]any{"op": "replace", "path": fullPath, "value": core.ValueToInterface(p.newVal)} + op = map[string]any{"op": "replace", "path": fullPath, "value": icore.ValueToInterface(p.newVal)} } - addConditionsToOp(op, p) return []map[string]any{op} } func (p *valuePatch) summary(path string) string { if !p.newVal.IsValid() { - return fmt.Sprintf("Removed %s (was %v)", path, core.ValueToInterface(p.oldVal)) + return fmt.Sprintf("Removed %s (was %v)", path, icore.ValueToInterface(p.oldVal)) } if !p.oldVal.IsValid() { - return fmt.Sprintf("Added %s: %v", path, core.ValueToInterface(p.newVal)) - } - return fmt.Sprintf("Updated %s from %v to %v", path, core.ValueToInterface(p.oldVal), core.ValueToInterface(p.newVal)) -} - -// testPatch handles equality checks without modifying the value. -type testPatch struct { - basePatch - expected reflect.Value -} - -func (p *testPatch) apply(root, v reflect.Value, path string) { - // No-op -} - -func (p *testPatch) applyChecked(root, v reflect.Value, strict bool, path string) 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 := core.ConvertValue(p.expected, v.Type()) - if !core.Equal(v.Interface(), convertedExpected.Interface()) { - return fmt.Errorf("test failed: expected %v, got %v", convertedExpected, v) - } - } - - return nil -} - -func (p *testPatch) applyResolved(root, v reflect.Value, path string, resolver ConflictResolver) error { - if resolver != nil { - resolved, ok := resolver.Resolve(path, OpTest, nil, nil, v, p.expected) - if !ok { - return nil - } - p.expected = resolved - } - return p.applyChecked(root, v, true, path) -} - -func (p *testPatch) dependencies(path string) (reads []string, writes []string) { - return []string{path}, nil -} - -func (p *testPatch) reverse() diffPatch { - return p // Reversing a test is still a test -} - -func (p *testPatch) walk(path string, fn func(path string, op OpKind, old, new any) error) error { - return fn(path, OpTest, nil, core.ValueToInterface(p.expected)) -} - -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 = "/" + return fmt.Sprintf("Added %s: %v", path, icore.ValueToInterface(p.newVal)) } - op := map[string]any{"op": "test", "path": fullPath, "value": core.ValueToInterface(p.expected)} - addConditionsToOp(op, p) - return []map[string]any{op} -} - -func (p *testPatch) summary(path string) string { - return fmt.Sprintf("Tested %s == %v", path, core.ValueToInterface(p.expected)) + return fmt.Sprintf("Updated %s from %v to %v", path, icore.ValueToInterface(p.oldVal), icore.ValueToInterface(p.newVal)) } // copyPatch copies a value from another path. type copyPatch struct { - basePatch from string path string // target path for reversal } @@ -310,12 +146,6 @@ func (p *copyPatch) apply(root, v reflect.Value, path string) { } func (p *copyPatch) applyChecked(root, v reflect.Value, strict bool, path string) error { - if err := checkConditions(p, root, v); err != nil { - if err == ErrConditionSkipped { - return nil - } - return err - } to := path if p.path != "" && p.path[0] == '/' { to = p.path @@ -356,7 +186,6 @@ func (p *copyPatch) toJSONPatch(path string) []map[string]any { } p.path = fullPath op := map[string]any{"op": "copy", "from": p.from, "path": fullPath} - addConditionsToOp(op, p) return []map[string]any{op} } @@ -369,28 +198,28 @@ func applyCopyOrMoveInternal(from, to, currentPath string, root, v reflect.Value if rvRoot.Kind() == reflect.Pointer { rvRoot = rvRoot.Elem() } - fromVal, err := core.DeepPath(from).Resolve(rvRoot) + fromVal, err := icore.DeepPath(from).Resolve(rvRoot) if err != nil { return err } - fromVal = core.DeepCopyValue(fromVal) + fromVal = icore.DeepCopyValue(fromVal) if isMove { - if err := core.DeepPath(from).Delete(rvRoot); err != nil { + if err := icore.DeepPath(from).Delete(rvRoot); err != nil { return err } } if v.IsValid() && v.CanSet() && (to == "" || to == currentPath) { - core.SetValue(v, fromVal) + icore.SetValue(v, fromVal) } else if to != "" && to != "/" { - if err := core.DeepPath(to).Set(rvRoot, fromVal); err != nil { + if err := icore.DeepPath(to).Set(rvRoot, fromVal); err != nil { return err } } else if to == "" || to == "/" { if rvRoot.CanSet() { - core.SetValue(rvRoot, fromVal) + icore.SetValue(rvRoot, fromVal) } } return nil @@ -398,7 +227,6 @@ func applyCopyOrMoveInternal(from, to, currentPath string, root, v reflect.Value // movePatch moves a value from another path. type movePatch struct { - basePatch from string path string // target path for reversal } @@ -412,12 +240,6 @@ func (p *movePatch) apply(root, v reflect.Value, path string) { } func (p *movePatch) applyChecked(root, v reflect.Value, strict bool, path string) error { - if err := checkConditions(p, root, v); err != nil { - if err == ErrConditionSkipped { - return nil - } - return err - } to := path if p.path != "" && p.path[0] == '/' { to = p.path @@ -458,7 +280,6 @@ func (p *movePatch) toJSONPatch(path string) []map[string]any { } 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} } @@ -466,67 +287,6 @@ func (p *movePatch) summary(path string) string { return fmt.Sprintf("Moved %s to %s", p.from, path) } -// logPatch logs a message without modifying the value. -type logPatch struct { - basePatch - message string -} - -func (p *logPatch) apply(root, v reflect.Value, path string) { - fmt.Printf("DEEP LOG: %s (value: %v)\n", p.message, v.Interface()) -} - -func (p *logPatch) applyChecked(root, v reflect.Value, strict bool, path string) error { - if err := checkConditions(p, root, v); err != nil { - if err == ErrConditionSkipped { - return nil - } - return err - } - p.apply(root, v, path) - return nil -} - -func (p *logPatch) applyResolved(root, v reflect.Value, path string, resolver ConflictResolver) error { - if resolver != nil { - _, ok := resolver.Resolve(path, OpLog, nil, nil, v, reflect.ValueOf(p.message)) - if !ok { - return nil - } - } - return p.applyChecked(root, v, false, path) -} - -func (p *logPatch) dependencies(path string) (reads []string, writes []string) { - return nil, nil -} - -func (p *logPatch) reverse() diffPatch { - return p -} - -func (p *logPatch) walk(path string, fn func(path string, op OpKind, old, new any) error) error { - return fn(path, OpLog, nil, p.message) -} - -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 (p *logPatch) summary(path string) string { - return fmt.Sprintf("Log: %s", p.message) -} - func newPtrPatch(elemPatch diffPatch) *ptrPatch { return &ptrPatch{ elemPatch: elemPatch, @@ -551,7 +311,6 @@ func newMapPatch(keyType reflect.Type) *mapPatch { // ptrPatch handles changes to the content pointed to by a pointer. type ptrPatch struct { - basePatch elemPatch diffPatch } @@ -566,12 +325,6 @@ func (p *ptrPatch) apply(root, v reflect.Value, path string) { } func (p *ptrPatch) applyChecked(root, v reflect.Value, strict bool, path string) 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") } @@ -591,7 +344,6 @@ func (p *ptrPatch) dependencies(path string) (reads []string, writes []string) { func (p *ptrPatch) reverse() diffPatch { return &ptrPatch{ - basePatch: p.basePatch, elemPatch: p.elemPatch.reverse(), } } @@ -605,11 +357,7 @@ func (p *ptrPatch) format(indent int) string { } func (p *ptrPatch) toJSONPatch(path string) []map[string]any { - ops := p.elemPatch.toJSONPatch(path) - for _, op := range ops { - addConditionsToOp(op, p) - } - return ops + return p.elemPatch.toJSONPatch(path) } func (p *ptrPatch) summary(path string) string { @@ -618,7 +366,6 @@ func (p *ptrPatch) summary(path string) string { // interfacePatch handles changes to the value stored in an interface. type interfacePatch struct { - basePatch elemPatch diffPatch } @@ -634,12 +381,6 @@ func (p *interfacePatch) apply(root, v reflect.Value, path string) { } func (p *interfacePatch) applyChecked(root, v reflect.Value, strict bool, path string) 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") } @@ -673,7 +414,6 @@ func (p *interfacePatch) dependencies(path string) (reads []string, writes []str func (p *interfacePatch) reverse() diffPatch { return &interfacePatch{ - basePatch: p.basePatch, elemPatch: p.elemPatch.reverse(), } } @@ -687,11 +427,7 @@ func (p *interfacePatch) format(indent int) string { } func (p *interfacePatch) toJSONPatch(path string) []map[string]any { - ops := p.elemPatch.toJSONPatch(path) - for _, op := range ops { - addConditionsToOp(op, p) - } - return ops + return p.elemPatch.toJSONPatch(path) } func (p *interfacePatch) summary(path string) string { @@ -700,7 +436,6 @@ func (p *interfacePatch) summary(path string) string { // structPatch handles field-level modifications in a struct. type structPatch struct { - basePatch fields map[string]diffPatch } @@ -712,25 +447,25 @@ func (p *structPatch) apply(root, v reflect.Value, path string) { for _, name := range order { patch := effectivePatches[name] - f := v.FieldByName(name) + info := icore.GetTypeInfo(v.Type()) + var f reflect.Value + for _, fInfo := range info.Fields { + if fInfo.Name == name { + f = v.Field(fInfo.Index) + break + } + } if f.IsValid() { if !f.CanSet() { unsafe.DisableRO(&f) } - subPath := core.JoinPath(path, name) + subPath := icore.JoinPath(path, name) patch.apply(root, f, subPath) } } } func (p *structPatch) applyChecked(root, v reflect.Value, strict bool, path string) error { - if err := checkConditions(p, root, v); err != nil { - if err == ErrConditionSkipped { - return nil - } - return err - } - effectivePatches, order, err := resolveStructDependencies(p, path, root) if err != nil { return err @@ -740,7 +475,14 @@ func (p *structPatch) applyChecked(root, v reflect.Value, strict bool, path stri processField := func(name string) { patch := effectivePatches[name] - f := v.FieldByName(name) + info := icore.GetTypeInfo(v.Type()) + var f reflect.Value + for _, fInfo := range info.Fields { + if fInfo.Name == name { + f = v.Field(fInfo.Index) + break + } + } if !f.IsValid() { errs = append(errs, fmt.Errorf("field %s not found", name)) return @@ -749,7 +491,7 @@ func (p *structPatch) applyChecked(root, v reflect.Value, strict bool, path stri unsafe.DisableRO(&f) } - subPath := core.JoinPath(path, name) + subPath := icore.JoinPath(path, name) if err := patch.applyChecked(root, f, strict, subPath); err != nil { errs = append(errs, fmt.Errorf("field %s: %w", name, err)) @@ -773,7 +515,14 @@ func (p *structPatch) applyResolved(root, v reflect.Value, path string, resolver processField := func(name string) error { patch := effectivePatches[name] - f := v.FieldByName(name) + info := icore.GetTypeInfo(v.Type()) + var f reflect.Value + for _, fInfo := range info.Fields { + if fInfo.Name == name { + f = v.Field(fInfo.Index) + break + } + } if !f.IsValid() { return fmt.Errorf("field %s not found", name) } @@ -781,7 +530,7 @@ func (p *structPatch) applyResolved(root, v reflect.Value, path string, resolver unsafe.DisableRO(&f) } - subPath := core.JoinPath(path, name) + subPath := icore.JoinPath(path, name) if err := patch.applyResolved(root, f, subPath, resolver); err != nil { return fmt.Errorf("field %s: %w", name, err) @@ -799,8 +548,8 @@ func (p *structPatch) applyResolved(root, v reflect.Value, path string, resolver func (p *structPatch) dependencies(path string) (reads []string, writes []string) { for name, patch := range p.fields { - fieldPath := core.JoinPath(path, name) - + fieldPath := icore.JoinPath(path, name) + r, w := patch.dependencies(fieldPath) reads = append(reads, r...) writes = append(writes, w...) @@ -814,8 +563,7 @@ func (p *structPatch) reverse() diffPatch { newFields[k] = v.reverse() } return &structPatch{ - basePatch: p.basePatch, - fields: newFields, + fields: newFields, } } @@ -845,9 +593,6 @@ func (p *structPatch) toJSONPatch(path string) []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 @@ -868,7 +613,6 @@ func (p *structPatch) summary(path string) string { // arrayPatch handles index-level modifications in a fixed-size array. type arrayPatch struct { - basePatch indices map[int]diffPatch } @@ -879,19 +623,13 @@ func (p *arrayPatch) apply(root, v reflect.Value, path string) { if !e.CanSet() { unsafe.DisableRO(&e) } - fullPath := core.JoinPath(path, strconv.Itoa(i)) + fullPath := icore.JoinPath(path, strconv.Itoa(i)) patch.apply(root, e, fullPath) } } } func (p *arrayPatch) applyChecked(root, v reflect.Value, strict bool, path string) error { - if err := checkConditions(p, root, v); err != nil { - if err == ErrConditionSkipped { - return nil - } - return err - } var errs []error for i, patch := range p.indices { if i >= v.Len() { @@ -902,7 +640,7 @@ func (p *arrayPatch) applyChecked(root, v reflect.Value, strict bool, path strin if !e.CanSet() { unsafe.DisableRO(&e) } - fullPath := core.JoinPath(path, strconv.Itoa(i)) + fullPath := icore.JoinPath(path, strconv.Itoa(i)) if err := patch.applyChecked(root, e, strict, fullPath); err != nil { errs = append(errs, fmt.Errorf("index %d: %w", i, err)) } @@ -923,7 +661,7 @@ func (p *arrayPatch) applyResolved(root, v reflect.Value, path string, resolver unsafe.DisableRO(&e) } - subPath := core.JoinPath(path, strconv.Itoa(i)) + subPath := icore.JoinPath(path, strconv.Itoa(i)) if err := patch.applyResolved(root, e, subPath, resolver); err != nil { return fmt.Errorf("index %d: %w", i, err) @@ -934,7 +672,7 @@ func (p *arrayPatch) applyResolved(root, v reflect.Value, path string, resolver func (p *arrayPatch) dependencies(path string) (reads []string, writes []string) { for i, patch := range p.indices { - fullPath := core.JoinPath(path, strconv.Itoa(i)) + fullPath := icore.JoinPath(path, strconv.Itoa(i)) r, w := patch.dependencies(fullPath) reads = append(reads, r...) writes = append(writes, w...) @@ -948,8 +686,7 @@ func (p *arrayPatch) reverse() diffPatch { newIndices[k] = v.reverse() } return &arrayPatch{ - basePatch: p.basePatch, - indices: newIndices, + indices: newIndices, } } @@ -979,9 +716,6 @@ func (p *arrayPatch) toJSONPatch(path string) []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 @@ -1002,7 +736,6 @@ func (p *arrayPatch) summary(path string) string { // mapPatch handles additions, removals, and modifications in a map. type mapPatch struct { - basePatch added map[any]reflect.Value removed map[any]reflect.Value modified map[any]diffPatch @@ -1024,7 +757,7 @@ func (p *mapPatch) apply(root, v reflect.Value, path string) { } for k, patch := range p.modified { keyVal := p.getOriginalKey(k, v.Type().Key(), v) - fullPath := core.JoinPath(path, fmt.Sprintf("%v", k)) + fullPath := icore.JoinPath(path, fmt.Sprintf("%v", k)) if cp, ok := patch.(*copyPatch); ok { _ = applyCopyOrMoveInternal(cp.from, fullPath, fullPath, root, reflect.Value{}, false) continue @@ -1043,15 +776,15 @@ func (p *mapPatch) apply(root, v reflect.Value, path string) { } for k, val := range p.added { keyVal := p.getOriginalKey(k, v.Type().Key(), v) - v.SetMapIndex(keyVal, core.ConvertValue(val, v.Type().Elem())) + v.SetMapIndex(keyVal, icore.ConvertValue(val, v.Type().Elem())) } } func (p *mapPatch) getOriginalKey(k any, targetType reflect.Type, v reflect.Value) reflect.Value { if orig, ok := p.originalKeys[k]; ok { - return core.ConvertValue(reflect.ValueOf(orig), targetType) + return icore.ConvertValue(reflect.ValueOf(orig), targetType) } - + // If it's a Keyer, we can search the target map for a matching canonical key. mv := v for mv.Kind() == reflect.Pointer || mv.Kind() == reflect.Interface { @@ -1075,16 +808,10 @@ func (p *mapPatch) getOriginalKey(k any, targetType reflect.Type, v reflect.Valu } } - return core.ConvertValue(reflect.ValueOf(k), targetType) + return icore.ConvertValue(reflect.ValueOf(k), targetType) } func (p *mapPatch) applyChecked(root, v reflect.Value, strict bool, path string) error { - if err := checkConditions(p, root, v); err != nil { - if err == ErrConditionSkipped { - return nil - } - return err - } if v.IsNil() { if len(p.added) > 0 { newMap := reflect.MakeMap(v.Type()) @@ -1101,13 +828,13 @@ func (p *mapPatch) applyChecked(root, v reflect.Value, strict bool, path string) errs = append(errs, fmt.Errorf("key %v not found for removal", k)) continue } - if strict && !core.Equal(val.Interface(), oldVal.Interface()) { + if strict && !icore.Equal(val.Interface(), oldVal.Interface()) { errs = append(errs, fmt.Errorf("map removal mismatch for key %v: expected %v, got %v", k, oldVal, val)) } } for k, patch := range p.modified { keyVal := p.getOriginalKey(k, v.Type().Key(), v) - fullPath := core.JoinPath(path, fmt.Sprintf("%v", k)) + fullPath := icore.JoinPath(path, fmt.Sprintf("%v", k)) if cp, ok := patch.(*copyPatch); ok { if err := applyCopyOrMoveInternal(cp.from, fullPath, fullPath, root, reflect.Value{}, false); err != nil { errs = append(errs, fmt.Errorf("map copy from %s failed: %w", cp.from, err)) @@ -1141,7 +868,7 @@ func (p *mapPatch) applyChecked(root, v reflect.Value, strict bool, path string) if strict && curr.IsValid() { errs = append(errs, fmt.Errorf("key %v already exists", k)) } - v.SetMapIndex(keyVal, core.ConvertValue(val, v.Type().Elem())) + v.SetMapIndex(keyVal, icore.ConvertValue(val, v.Type().Elem())) } if len(errs) > 0 { return &ApplyError{errors: errs} @@ -1161,7 +888,7 @@ func (p *mapPatch) applyResolved(root, v reflect.Value, path string, resolver Co // Removals for k, val := range p.removed { - subPath := core.JoinPath(path, fmt.Sprintf("%v", k)) + subPath := icore.JoinPath(path, fmt.Sprintf("%v", k)) keyVal := p.getOriginalKey(k, v.Type().Key(), v) current := v.MapIndex(keyVal) @@ -1180,10 +907,10 @@ func (p *mapPatch) applyResolved(root, v reflect.Value, path string, resolver Co keyVal := p.getOriginalKey(k, v.Type().Key(), v) val := v.MapIndex(keyVal) if !val.IsValid() { - continue + continue } - subPath := core.JoinPath(path, fmt.Sprintf("%v", k)) + subPath := icore.JoinPath(path, fmt.Sprintf("%v", k)) newElem := reflect.New(val.Type()).Elem() newElem.Set(val) @@ -1195,7 +922,7 @@ func (p *mapPatch) applyResolved(root, v reflect.Value, path string, resolver Co // Additions for k, val := range p.added { - subPath := core.JoinPath(path, fmt.Sprintf("%v", k)) + subPath := icore.JoinPath(path, fmt.Sprintf("%v", k)) if resolver != nil { resolved, ok := resolver.Resolve(subPath, OpAdd, k, nil, reflect.Value{}, val) @@ -1204,23 +931,23 @@ func (p *mapPatch) applyResolved(root, v reflect.Value, path string, resolver Co } val = resolved } - v.SetMapIndex(p.getOriginalKey(k, v.Type().Key(), v), core.ConvertValue(val, v.Type().Elem())) + v.SetMapIndex(p.getOriginalKey(k, v.Type().Key(), v), icore.ConvertValue(val, v.Type().Elem())) } return nil } func (p *mapPatch) dependencies(path string) (reads []string, writes []string) { for k, patch := range p.modified { - fullPath := core.JoinPath(path, fmt.Sprintf("%v", k)) + fullPath := icore.JoinPath(path, fmt.Sprintf("%v", k)) r, w := patch.dependencies(fullPath) reads = append(reads, r...) writes = append(writes, w...) } for k := range p.added { - writes = append(writes, core.JoinPath(path, fmt.Sprintf("%v", k))) + writes = append(writes, icore.JoinPath(path, fmt.Sprintf("%v", k))) } for k := range p.removed { - writes = append(writes, core.JoinPath(path, fmt.Sprintf("%v", k))) + writes = append(writes, icore.JoinPath(path, fmt.Sprintf("%v", k))) } return } @@ -1231,24 +958,23 @@ func (p *mapPatch) reverse() diffPatch { newModified[k] = v.reverse() } return &mapPatch{ - basePatch: p.basePatch, - added: p.removed, - removed: p.added, - modified: newModified, - keyType: p.keyType, + added: p.removed, + removed: p.added, + modified: newModified, + keyType: p.keyType, } } func (p *mapPatch) walk(path string, fn func(path string, op OpKind, old, new any) error) error { for k, val := range p.added { fullPath := fmt.Sprintf("%s/%v", path, k) - if err := fn(fullPath, OpAdd, nil, core.ValueToInterface(val)); err != nil { + if err := fn(fullPath, OpAdd, nil, icore.ValueToInterface(val)); err != nil { return err } } for k, oldVal := range p.removed { fullPath := fmt.Sprintf("%s/%v", path, k) - if err := fn(fullPath, OpRemove, core.ValueToInterface(oldVal), nil); err != nil { + if err := fn(fullPath, OpRemove, icore.ValueToInterface(oldVal), nil); err != nil { return err } } @@ -1283,21 +1009,16 @@ func (p *mapPatch) toJSONPatch(path string) []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": core.ValueToInterface(val)} - addConditionsToOp(op, p) + op := map[string]any{"op": "add", "path": fullPath, "value": icore.ValueToInterface(val)} ops = append(ops, op) } return ops @@ -1311,11 +1032,11 @@ func (p *mapPatch) summary(path string) string { } for k, val := range p.added { subPath := prefix + fmt.Sprintf("%v", k) - summaries = append(summaries, fmt.Sprintf("Added %s: %v", subPath, core.ValueToInterface(val))) + summaries = append(summaries, fmt.Sprintf("Added %s: %v", subPath, icore.ValueToInterface(val))) } for k, oldVal := range p.removed { subPath := prefix + fmt.Sprintf("%v", k) - summaries = append(summaries, fmt.Sprintf("Removed %s (was %v)", subPath, core.ValueToInterface(oldVal))) + summaries = append(summaries, fmt.Sprintf("Removed %s (was %v)", subPath, icore.ValueToInterface(oldVal))) } for k, patch := range p.modified { subPath := prefix + fmt.Sprintf("%v", k) @@ -1345,7 +1066,6 @@ type ConflictResolver interface { // slicePatch handles complex edits (insertions, deletions, modifications) in a slice. type slicePatch struct { - basePatch ops []sliceOp } @@ -1363,7 +1083,7 @@ func (p *slicePatch) apply(root, v reflect.Value, path string) { } switch op.Kind { case OpAdd: - newSlice = reflect.Append(newSlice, core.ConvertValue(op.Val, v.Type().Elem())) + newSlice = reflect.Append(newSlice, icore.ConvertValue(op.Val, v.Type().Elem())) case OpRemove: curIdx++ case OpCopy, OpMove: @@ -1378,9 +1098,9 @@ func (p *slicePatch) apply(root, v reflect.Value, path string) { case OpReplace: if curIdx < v.Len() { elem := reflect.New(v.Type().Elem()).Elem() - elem.Set(core.DeepCopyValue(v.Index(curIdx))) + elem.Set(icore.DeepCopyValue(v.Index(curIdx))) if op.Patch != nil { - fullPath := core.JoinPath(path, strconv.Itoa(curIdx)) + fullPath := icore.JoinPath(path, strconv.Itoa(curIdx)) op.Patch.apply(root, elem, fullPath) } newSlice = reflect.Append(newSlice, elem) @@ -1395,12 +1115,6 @@ func (p *slicePatch) apply(root, v reflect.Value, path string) { } func (p *slicePatch) applyChecked(root, v reflect.Value, strict bool, path string) error { - if err := checkConditions(p, root, v); err != nil { - if err == ErrConditionSkipped { - return nil - } - return err - } newSlice := reflect.MakeSlice(v.Type(), 0, v.Len()) curIdx := 0 var errs []error @@ -1415,7 +1129,7 @@ func (p *slicePatch) applyChecked(root, v reflect.Value, strict bool, path strin } switch op.Kind { case OpAdd: - newSlice = reflect.Append(newSlice, core.ConvertValue(op.Val, v.Type().Elem())) + newSlice = reflect.Append(newSlice, icore.ConvertValue(op.Val, v.Type().Elem())) case OpRemove: if curIdx >= v.Len() { errs = append(errs, fmt.Errorf("slice deletion index %d out of bounds", curIdx)) @@ -1423,8 +1137,8 @@ func (p *slicePatch) applyChecked(root, v reflect.Value, strict bool, path strin } curr := v.Index(curIdx) if strict && op.Val.IsValid() { - convertedVal := core.ConvertValue(op.Val, v.Type().Elem()) - if !core.Equal(curr.Interface(), convertedVal.Interface()) { + convertedVal := icore.ConvertValue(op.Val, v.Type().Elem()) + if !icore.Equal(curr.Interface(), convertedVal.Interface()) { errs = append(errs, fmt.Errorf("slice deletion mismatch at %d: expected %v, got %v", curIdx, convertedVal, curr)) } } @@ -1448,9 +1162,9 @@ func (p *slicePatch) applyChecked(root, v reflect.Value, strict bool, path strin continue } elem := reflect.New(v.Type().Elem()).Elem() - elem.Set(core.DeepCopyValue(v.Index(curIdx))) + elem.Set(icore.DeepCopyValue(v.Index(curIdx))) if op.Patch != nil { - fullPath := core.JoinPath(path, strconv.Itoa(curIdx)) + fullPath := icore.JoinPath(path, strconv.Itoa(curIdx)) if err := op.Patch.applyChecked(root, elem, strict, fullPath); err != nil { errs = append(errs, fmt.Errorf("slice index %d: %w", curIdx, err)) } @@ -1474,7 +1188,7 @@ func (p *slicePatch) applyResolved(root, v reflect.Value, path string, resolver return p.applyChecked(root, v, false, path) } - keyField, hasKey := core.GetKeyField(v.Type().Elem()) + keyField, hasKey := icore.GetKeyField(v.Type().Elem()) if !hasKey { newSlice := reflect.MakeSlice(v.Type(), 0, v.Len()) @@ -1489,13 +1203,13 @@ func (p *slicePatch) applyResolved(root, v reflect.Value, path string, resolver curIdx = op.Index } - subPath := core.JoinPath(path, strconv.Itoa(curIdx)) + subPath := icore.JoinPath(path, strconv.Itoa(curIdx)) switch op.Kind { case OpAdd: resolved, ok := resolver.Resolve(subPath, OpAdd, nil, nil, reflect.Value{}, op.Val) if ok { - newSlice = reflect.Append(newSlice, core.ConvertValue(resolved, v.Type().Elem())) + newSlice = reflect.Append(newSlice, icore.ConvertValue(resolved, v.Type().Elem())) } case OpRemove: var current reflect.Value @@ -1504,7 +1218,7 @@ func (p *slicePatch) applyResolved(root, v reflect.Value, path string, resolver } _, ok := resolver.Resolve(subPath, OpRemove, nil, nil, current, reflect.Value{}) if ok { - curIdx++ + curIdx++ } else { if curIdx < v.Len() { newSlice = reflect.Append(newSlice, v.Index(curIdx)) @@ -1514,7 +1228,7 @@ func (p *slicePatch) applyResolved(root, v reflect.Value, path string, resolver case OpReplace: if curIdx < v.Len() { elem := reflect.New(v.Type().Elem()).Elem() - elem.Set(core.DeepCopyValue(v.Index(curIdx))) + elem.Set(icore.DeepCopyValue(v.Index(curIdx))) if op.Patch != nil { if err := op.Patch.applyResolved(root, elem, subPath, resolver); err != nil { return err @@ -1541,13 +1255,13 @@ func (p *slicePatch) applyResolved(root, v reflect.Value, path string, resolver for i := 0; i < v.Len(); i++ { val := v.Index(i) - k := core.ExtractKey(val, keyField) + k := icore.ExtractKey(val, keyField) existingMap[k] = &elemInfo{val: val, index: i} orderedKeys = append(orderedKeys, k) } for _, op := range p.ops { - subPath := core.JoinPath(path, fmt.Sprintf("%v", op.Key)) + subPath := icore.JoinPath(path, fmt.Sprintf("%v", op.Key)) switch op.Kind { case OpRemove: @@ -1562,7 +1276,7 @@ func (p *slicePatch) applyResolved(root, v reflect.Value, path string, resolver case OpReplace: if info, ok := existingMap[op.Key]; ok { newVal := reflect.New(v.Type().Elem()).Elem() - newVal.Set(core.DeepCopyValue(info.val)) + newVal.Set(icore.DeepCopyValue(info.val)) if err := op.Patch.applyResolved(root, newVal, subPath, resolver); err == nil { info.val = newVal } @@ -1572,7 +1286,7 @@ func (p *slicePatch) applyResolved(root, v reflect.Value, path string, resolver for _, op := range p.ops { if op.Kind == OpAdd { - subPath := core.JoinPath(path, fmt.Sprintf("%v", op.Key)) + subPath := icore.JoinPath(path, fmt.Sprintf("%v", op.Key)) resolved, ok := resolver.Resolve(subPath, OpAdd, op.Key, op.PrevKey, reflect.Value{}, op.Val) if ok { insertIdx := 0 @@ -1608,7 +1322,7 @@ func (p *slicePatch) applyResolved(root, v reflect.Value, path string, resolver copy(orderedKeys[insertIdx+1:], orderedKeys[insertIdx:]) orderedKeys[insertIdx] = op.Key } - existingMap[op.Key] = &elemInfo{val: core.ConvertValue(resolved, v.Type().Elem())} + existingMap[op.Key] = &elemInfo{val: icore.ConvertValue(resolved, v.Type().Elem())} } } @@ -1632,7 +1346,7 @@ func (p *slicePatch) dependencies(path string) (reads []string, writes []string) writes = append(writes, path) for _, op := range p.ops { if op.Patch != nil { - r, w := op.Patch.dependencies(core.JoinPath(path, "?")) + r, w := op.Patch.dependencies(icore.JoinPath(path, "?")) reads = append(reads, r...) writes = append(writes, w...) } @@ -1677,8 +1391,7 @@ func (p *slicePatch) reverse() diffPatch { } } return &slicePatch{ - basePatch: p.basePatch, - ops: revOps, + ops: revOps, } } @@ -1690,11 +1403,11 @@ func (p *slicePatch) walk(path string, fn func(path string, op OpKind, old, new } switch op.Kind { case OpAdd: - if err := fn(fullPath, OpAdd, nil, core.ValueToInterface(op.Val)); err != nil { + if err := fn(fullPath, OpAdd, nil, icore.ValueToInterface(op.Val)); err != nil { return err } case OpRemove: - if err := fn(fullPath, OpRemove, core.ValueToInterface(op.Val), nil); err != nil { + if err := fn(fullPath, OpRemove, icore.ValueToInterface(op.Val), nil); err != nil { return err } case OpReplace: @@ -1748,20 +1461,15 @@ func (p *slicePatch) toJSONPatch(path string) []map[string]any { fullPath := fmt.Sprintf("%s/%d", path, op.Index+shift) switch op.Kind { case OpAdd: - jsonOp := map[string]any{"op": "add", "path": fullPath, "value": core.ValueToInterface(op.Val)} - addConditionsToOp(jsonOp, p) + jsonOp := map[string]any{"op": "add", "path": fullPath, "value": icore.ValueToInterface(op.Val)} ops = append(ops, jsonOp) shift++ case OpRemove: jsonOp := map[string]any{"op": "remove", "path": fullPath} - addConditionsToOp(jsonOp, p) ops = append(ops, jsonOp) shift-- case OpReplace: subOps := op.Patch.toJSONPatch(fullPath) - for _, sop := range subOps { - addConditionsToOp(sop, p) - } ops = append(ops, subOps...) } } @@ -1781,9 +1489,9 @@ func (p *slicePatch) summary(path string) string { } switch op.Kind { case OpAdd: - summaries = append(summaries, fmt.Sprintf("Added to %s: %v", subPath, core.ValueToInterface(op.Val))) + summaries = append(summaries, fmt.Sprintf("Added to %s: %v", subPath, icore.ValueToInterface(op.Val))) case OpRemove: - summaries = append(summaries, fmt.Sprintf("Removed from %s: %v", subPath, core.ValueToInterface(op.Val))) + summaries = append(summaries, fmt.Sprintf("Removed from %s: %v", subPath, icore.ValueToInterface(op.Val))) case OpReplace: if op.Patch != nil { summaries = append(summaries, op.Patch.summary(subPath)) @@ -1793,119 +1501,7 @@ func (p *slicePatch) summary(path string) string { return strings.Join(summaries, "\n") } -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.Pointer || v.Kind() == reflect.Interface { - v = v.Elem() - } - - typeName := v.Type().Name() - if strings.HasPrefix(typeName, "rawCompareCondition") { - path := string(v.FieldByName("Path").Interface().(core.DeepPath)) - val := v.FieldByName("Val").Interface() - op := v.FieldByName("Op").String() - ignoreCase := v.FieldByName("IgnoreCase").Bool() - - switch op { - case "==": - jsonOp := "test" - if ignoreCase { - jsonOp = "test-" - } - return map[string]any{"op": jsonOp, "path": path, "value": val} - case "!=": - jsonOp := "test" - if ignoreCase { - jsonOp = "test-" - } - return map[string]any{"op": "not", "apply": []any{map[string]any{"op": jsonOp, "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, "rawDefinedCondition") { - path := string(v.FieldByName("Path").Interface().(core.DeepPath)) - return map[string]any{"op": "defined", "path": path} - } - - if strings.HasPrefix(typeName, "rawUndefinedCondition") { - path := string(v.FieldByName("Path").Interface().(core.DeepPath)) - return map[string]any{"op": "undefined", "path": path} - } - - if strings.HasPrefix(typeName, "rawTypeCondition") { - path := string(v.FieldByName("Path").Interface().(core.DeepPath)) - typeName := v.FieldByName("TypeName").String() - return map[string]any{"op": "type", "path": path, "value": typeName} - } - - if strings.HasPrefix(typeName, "rawStringCondition") { - path := string(v.FieldByName("Path").Interface().(core.DeepPath)) - val := v.FieldByName("Val").String() - op := v.FieldByName("Op").String() - ignoreCase := v.FieldByName("IgnoreCase").Bool() - - if ignoreCase && op != "matches" { - op += "-" - } - if op == "matches" && ignoreCase { - return map[string]any{"op": op, "path": path, "value": val, "ignoreCase": true} - } - return map[string]any{"op": op, "path": path, "value": val} - } - - if strings.HasPrefix(typeName, "rawInCondition") { - path := string(v.FieldByName("Path").Interface().(core.DeepPath)) - vals := v.FieldByName("Values").Interface() - ignoreCase := v.FieldByName("IgnoreCase").Bool() - - op := "in" - if ignoreCase { - op = "in-" - } - return map[string]any{"op": op, "path": path, "value": vals} - } - - if strings.HasPrefix(typeName, "typedCondition") || strings.HasPrefix(typeName, "typedRawCondition") { - inner := v.FieldByName("inner") - if !inner.IsValid() { - inner = v.FieldByName("raw") - } - return conditionToPredicate(inner.Interface()) - } - - return nil -} - type readOnlyPatch struct { - basePatch inner diffPatch } @@ -1924,7 +1520,7 @@ func (p *readOnlyPatch) applyResolved(root, v reflect.Value, path string, resolv } func (p *readOnlyPatch) reverse() diffPatch { - return &readOnlyPatch{basePatch: p.basePatch, inner: p.inner.reverse()} + return &readOnlyPatch{inner: p.inner.reverse()} } func (p *readOnlyPatch) format(indent int) string { @@ -1948,7 +1544,6 @@ func (p *readOnlyPatch) dependencies(path string) (reads []string, writes []stri } type customDiffPatch struct { - basePatch patch any } @@ -1990,7 +1585,7 @@ func (p *customDiffPatch) reverse() diffPatch { m := reflect.ValueOf(p.patch).MethodByName("Reverse") if m.IsValid() { res := m.Call(nil) - return &customDiffPatch{basePatch: p.basePatch, patch: res[0].Interface()} + return &customDiffPatch{patch: res[0].Interface()} } return p // Cannot reverse? } @@ -2013,12 +1608,6 @@ func (p *customDiffPatch) walk(path string, fn func(path string, op OpKind, old, m := reflect.ValueOf(p.patch).MethodByName("Walk") if m.IsValid() { // This is tricky. Fn needs to be adapted. - // Let's assume custom patch Walk signature matches Patch.Walk but untyped? - // func(path string, op OpKind, old, new any) error - // We can try to pass fn. - // But reflection call expects reflect.Value. - // We can't easily pass a closure via reflection if types don't match exactly. - // Skip for now. } return nil } diff --git a/patch_ops_test.go b/internal/engine/patch_ops_test.go similarity index 95% rename from patch_ops_test.go rename to internal/engine/patch_ops_test.go index 98b4cdc..b1a6dd7 100644 --- a/patch_ops_test.go +++ b/internal/engine/patch_ops_test.go @@ -1,4 +1,4 @@ -package deep +package engine import ( "reflect" @@ -69,13 +69,6 @@ func TestPatch_ReverseFormat_Exhaustive(t *testing.T) { 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"} @@ -90,13 +83,6 @@ func TestPatch_ReverseFormat_Exhaustive(t *testing.T) { 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_MiscCoverage(t *testing.T) { @@ -172,7 +158,7 @@ func TestDependencyResolution_Swap(t *testing.T) { } s := S{A: 1, B: 2} - + // Create a swap patch manually // A = Copy(B) // B = Copy(A) @@ -189,7 +175,7 @@ func TestDependencyResolution_Swap(t *testing.T) { // Apply to s // Expected: A=2, B=1 // Original: A=1, B=2 - + // Logic: // Cycle A <-> B. // A reads B. B reads A. @@ -200,7 +186,7 @@ func TestDependencyResolution_Swap(t *testing.T) { // We need to wrap it in a typedPatch to call Apply easily, or call apply directly. // calling apply directly requires root value. - + val := reflect.ValueOf(&s) patch.apply(val, val.Elem(), "/") @@ -282,7 +268,7 @@ func TestDependencyResolution_MoveSwap(t *testing.T) { // Move B -> A, Move A -> B // Cycle. // Expected: A=2, B=1. - + patch := &structPatch{ fields: map[string]diffPatch{ "A": &movePatch{from: "/B", path: "/A"}, @@ -313,10 +299,10 @@ func TestDependencyResolution_Overlap(t *testing.T) { // Copy B.X -> A.X // Remove B - + // A field patch: replace A (valuePatch? No, let's use structPatch for A) // A structPatch -> fields["X"] -> copyPatch(from="/B/X") - + // B field patch: remove (valuePatch with nil new) // Dependency: diff --git a/internal/engine/patch_test.go b/internal/engine/patch_test.go new file mode 100644 index 0000000..b2aafcf --- /dev/null +++ b/internal/engine/patch_test.go @@ -0,0 +1,344 @@ +package engine + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + //"github.com/brunoga/deep/v5/core" +) + +func TestPatch_String_Basic(t *testing.T) { + a, b := "foo", "bar" + patch := MustDiff(a, b) + if !strings.Contains(patch.String(), "foo -> bar") { + t.Errorf("String() missing transition: %s", patch.String()) + } +} + +func TestPatch_String_Complex(t *testing.T) { + type Child struct { + Name string + } + type Data struct { + Tags []string + Meta map[string]any + Kids []Child + Status *string + } + active := "active" + inactive := "inactive" + a := Data{ + Tags: []string{"tag1", "tag2"}, + Meta: map[string]any{ + "key1": "val1", + "key2": 123, + }, + Kids: []Child{ + {Name: "Kid1"}, + }, + Status: &active, + } + b := Data{ + Tags: []string{"tag1", "tag2", "tag3"}, + Meta: map[string]any{ + "key1": "val1-mod", + "key3": true, + }, + Kids: []Child{ + {Name: "Kid1"}, + {Name: "Kid2"}, + }, + Status: &inactive, + } + patch := MustDiff(a, b) + if patch == nil { + t.Fatal("Expected non-nil patch") + } + + summary := patch.String() + if !strings.Contains(summary, "+ [1]: {Kid2}") { + t.Errorf("String() missing added kid: %s", summary) + } +} + +func TestPatch_ApplyResolved(t *testing.T) { + type Config struct { + Value int + } + c1 := Config{Value: 10} + c2 := Config{Value: 20} + + patch := MustDiff(c1, c2) + + target := Config{Value: 10} + + // Resolver that rejects everything + err := patch.ApplyResolved(&target, ConflictResolverFunc(func(path string, op OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) { + return reflect.Value{}, false + })) + if err != nil { + t.Fatalf("ApplyResolved failed: %v", err) + } + + if target.Value != 10 { + t.Errorf("Value should not have changed, got %d", target.Value) + } + + // Resolver that accepts everything + err = patch.ApplyResolved(&target, ConflictResolverFunc(func(path string, op OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) { + return proposed, true + })) + if err != nil { + t.Fatalf("ApplyResolved failed: %v", err) + } + + if target.Value != 20 { + t.Errorf("Value should have changed to 20, got %d", target.Value) + } +} + +type ConflictResolverFunc func(path string, op OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) + +func (f ConflictResolverFunc) Resolve(path string, op OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) { + return f(path, op, key, prevKey, current, proposed) +} + +func TestPatch_MoreApplyChecked(t *testing.T) { + // ptrPatch + t.Run("ptrPatch", func(t *testing.T) { + val1 := 1 + p1 := &val1 + val2 := 2 + p2 := &val2 + patch := MustDiff(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 := MustDiff(i1, i2) + if err := patch.ApplyChecked(&i1); err != nil { + t.Errorf("interfacePatch ApplyChecked failed: %v", err) + } + }) +} + +func TestPatch_Walk_Basic(t *testing.T) { + a := 10 + b := 20 + patch := MustDiff(a, b) + + var ops []string + err := patch.Walk(func(path string, op OpKind, old, new any) error { + ops = append(ops, fmt.Sprintf("%s:%s:%v:%v", path, op, old, new)) + return nil + }) + + if err != nil { + t.Fatalf("Walk failed: %v", err) + } + + expected := []string{"/:replace:10:20"} + if fmt.Sprintf("%v", ops) != fmt.Sprintf("%v", expected) { + t.Errorf("Expected ops %v, got %v", expected, ops) + } +} + +func TestPatch_Walk_Struct(t *testing.T) { + type S struct { + A int + B string + } + a := S{A: 1, B: "one"} + b := S{A: 2, B: "two"} + patch := MustDiff(a, b) + + ops := make(map[string]string) + err := patch.Walk(func(path string, op OpKind, old, new any) error { + ops[path] = fmt.Sprintf("%s:%v:%v", op, old, new) + return nil + }) + + if err != nil { + t.Fatalf("Walk failed: %v", err) + } + + if len(ops) != 2 { + t.Errorf("Expected 2 ops, got %d", len(ops)) + } + + if ops["/A"] != "replace:1:2" { + t.Errorf("Unexpected op for A: %s", ops["/A"]) + } + if ops["/B"] != "replace:one:two" { + t.Errorf("Unexpected op for B: %s", ops["/B"]) + } +} + +func TestPatch_Walk_Slice(t *testing.T) { + a := []int{1, 2, 3} + b := []int{1, 4, 3, 5} + patch := MustDiff(a, b) + + var ops []string + err := patch.Walk(func(path string, op OpKind, old, new any) error { + ops = append(ops, fmt.Sprintf("%s:%s:%v:%v", path, op, old, new)) + return nil + }) + + if err != nil { + t.Fatalf("Walk failed: %v", err) + } + + found4 := false + found5 := false + for _, op := range ops { + if strings.Contains(op, ":2:4") || (strings.Contains(op, ":remove:2:") || strings.Contains(op, ":add::4")) { + if strings.Contains(op, "4") { + found4 = true + } + } + if strings.Contains(op, ":add::5") { + found5 = true + } + } + + if !found4 || !found5 { + t.Errorf("Missing expected ops in %v", ops) + } +} + +func TestPatch_Walk_Map(t *testing.T) { + a := map[string]int{"one": 1, "two": 2} + b := map[string]int{"one": 1, "two": 20, "three": 3} + patch := MustDiff(a, b) + + ops := make(map[string]string) + err := patch.Walk(func(path string, op OpKind, old, new any) error { + ops[path] = fmt.Sprintf("%s:%v:%v", op, old, new) + return nil + }) + + if err != nil { + t.Fatalf("Walk failed: %v", err) + } + + if ops["/two"] != "replace:2:20" { + t.Errorf("Unexpected op for two: %s", ops["/two"]) + } + if ops["/three"] != "add::3" { + t.Errorf("Unexpected op for three: %s", ops["/three"]) + } +} + +func TestPatch_Walk_KeyedSlice(t *testing.T) { + type KeyedTask struct { + ID string `deep:"key"` + Status string + } + a := []KeyedTask{ + {ID: "t1", Status: "todo"}, + {ID: "t2", Status: "todo"}, + } + b := []KeyedTask{ + {ID: "t2", Status: "done"}, + {ID: "t1", Status: "todo"}, + } + + patch := MustDiff(a, b) + + ops := make(map[string]string) + err := patch.Walk(func(path string, op OpKind, old, new any) error { + ops[path] = fmt.Sprintf("%s:%v:%v", op, old, new) + return nil + }) + + if err != nil { + t.Fatalf("Walk failed: %v", err) + } + + if len(ops) == 0 { + t.Errorf("Expected some ops, got none") + } +} + +func TestPatch_Walk_ErrorStop(t *testing.T) { + a := map[string]int{"one": 1, "two": 2} + b := map[string]int{"one": 10, "two": 20} + patch := MustDiff(a, b) + + count := 0 + err := patch.Walk(func(path string, op OpKind, old, new any) error { + count++ + return fmt.Errorf("stop") + }) + + if err == nil || err.Error() != "stop" { + t.Errorf("Expected 'stop' error, got %v", err) + } + if count != 1 { + t.Errorf("Expected walk to stop after 1 call, got %d", count) + } +} + +type customTestStruct struct { + V int +} + +func TestCustomDiffPatch_ToJSONPatch(t *testing.T) { + patch := &typedPatch[customTestStruct]{ + inner: &structPatch{ + fields: map[string]diffPatch{ + "V": &valuePatch{ + oldVal: reflect.ValueOf(1), + newVal: reflect.ValueOf(2), + }, + }, + }, + } + + // Manually wrap it in customDiffPatch + custom := &customDiffPatch{ + patch: patch, + } + + jsonBytes := custom.toJSONPatch("") // Use empty prefix for root + + var ops []map[string]any + data, _ := json.Marshal(jsonBytes) + json.Unmarshal(data, &ops) + + if len(ops) != 1 { + t.Fatalf("expected 1 op, got %d", len(ops)) + } + + if ops[0]["path"] != "/V" { + t.Errorf("expected path /V, got %s", ops[0]["path"]) + } +} + +func TestPatch_Summary(t *testing.T) { + type Config struct { + Name string + Value int + Options []string + } + + c1 := Config{Name: "v1", Value: 10, Options: []string{"a", "b"}} + c2 := Config{Name: "v2", Value: 20, Options: []string{"a", "c"}} + + patch := MustDiff(c1, c2) + if patch == nil { + t.Fatal("Expected patch") + } + + summary := patch.Summary() + if summary == "" || summary == "No changes." { + t.Errorf("Unexpected summary: %q", summary) + } +} diff --git a/register_test.go b/internal/engine/register_test.go similarity index 83% rename from register_test.go rename to internal/engine/register_test.go index 6382845..c112a77 100644 --- a/register_test.go +++ b/internal/engine/register_test.go @@ -1,6 +1,7 @@ -package deep +package engine import ( + "reflect" "testing" ) @@ -53,10 +54,18 @@ func TestRegisterCustomDiff_Example(t *testing.T) { if a.ID == b.ID { return nil, nil } - // Return atomic patch replacing entire struct - bld := NewPatchBuilder[CustomRegistered]() - bld.Set(a, b) - return bld.Build() + /* + // Return atomic patch replacing entire struct + bld := NewPatchBuilder[CustomRegistered]() + bld.Set(a, b) + return bld.Build() + */ + return &typedPatch[CustomRegistered]{ + inner: &valuePatch{ + oldVal: reflect.ValueOf(a), + newVal: reflect.ValueOf(b), + }, + }, nil }) a := CustomRegistered{ID: 1, Data: "A"} @@ -66,7 +75,7 @@ func TestRegisterCustomDiff_Example(t *testing.T) { if patch == nil { t.Fatal("Expected patch") } - + // Verify it's atomic (1 op) ops := 0 patch.Walk(func(path string, op OpKind, old, new any) error { diff --git a/tags_integration_test.go b/internal/engine/tags_integration_test.go similarity index 99% rename from tags_integration_test.go rename to internal/engine/tags_integration_test.go index a1085d0..9b5ad85 100644 --- a/tags_integration_test.go +++ b/internal/engine/tags_integration_test.go @@ -1,4 +1,4 @@ -package deep +package engine import ( "testing" diff --git a/internal/testmodels/user.go b/internal/testmodels/user.go new file mode 100644 index 0000000..8d40eb8 --- /dev/null +++ b/internal/testmodels/user.go @@ -0,0 +1,27 @@ +package testmodels + +//go:generate go run github.com/brunoga/deep/v5/cmd/deep-gen -type=User,Detail -output user_deep.go . + +import ( + "github.com/brunoga/deep/v5/crdt" +) + +type User struct { + ID int `json:"id"` + Name string `json:"full_name"` + Info Detail `json:"info"` + Roles []string `json:"roles"` + Score map[string]int `json:"score"` + Bio crdt.Text `json:"bio"` + age int // Unexported field +} + +type Detail struct { + Age int + Address string `json:"addr"` +} + +// AgePtr returns a pointer to the unexported age field for use in path selectors. +func (u *User) AgePtr() *int { + return &u.age +} diff --git a/internal/testmodels/user_deep.go b/internal/testmodels/user_deep.go new file mode 100644 index 0000000..70fa749 --- /dev/null +++ b/internal/testmodels/user_deep.go @@ -0,0 +1,827 @@ +// Code generated by deep-gen. DO NOT EDIT. +package testmodels + +import ( + "fmt" + deep "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + crdt "github.com/brunoga/deep/v5/crdt" + _deepengine "github.com/brunoga/deep/v5/internal/engine" + "log/slog" + "regexp" + "strings" +) + +// Patch applies p to t using the generated fast path. +func (t *User) Patch(p deep.Patch[User], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *User) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(User)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(User); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/id", "/ID": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.ID) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.ID == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.ID) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.ID) + } + } + if v, ok := op.New.(int); ok { + t.ID = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.ID = int(f) + return true, nil + } + case "/full_name", "/Name": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Name) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Name != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Name) + } + } + if v, ok := op.New.(string); ok { + t.Name = v + return true, nil + } + case "/info", "/Info": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Info) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.(Detail); !ok || !deep.Equal(t.Info, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Info) + } + } + if v, ok := op.New.(Detail); ok { + t.Info = v + return true, nil + } + case "/roles", "/Roles": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Roles) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.([]string); !ok || !deep.Equal(t.Roles, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Roles) + } + } + if v, ok := op.New.([]string); ok { + t.Roles = v + return true, nil + } + case "/score", "/Score": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Score) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.(map[string]int); !ok || !deep.Equal(t.Score, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Score) + } + } + if v, ok := op.New.(map[string]int); ok { + t.Score = v + return true, nil + } + case "/bio", "/Bio": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Bio) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if old, ok := op.Old.(crdt.Text); !ok || !deep.Equal(t.Bio, old) { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Bio) + } + } + op.Path = "/" + return true, t.Bio.Patch(deep.Patch[crdt.Text]{Operations: []deep.Operation{op}}, logger) + case "/age": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.age) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.age == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.age) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.age) + } + } + if v, ok := op.New.(int); ok { + t.age = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.age = int(f) + return true, nil + } + default: + if strings.HasPrefix(op.Path, "/info/") { + op.Path = op.Path[len("/info/")-1:] + return (&t.Info).applyOperation(op, logger) + } + if strings.HasPrefix(op.Path, "/score/") { + parts := strings.Split(op.Path[len("/score/"):], "/") + key := parts[0] + if op.Kind == deep.OpRemove { + delete(t.Score, key) + return true, nil + } + if t.Score == nil { + t.Score = make(map[string]int) + } + if v, ok := op.New.(int); ok { + t.Score[key] = v + return true, nil + } + } + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *User) Diff(other *User) deep.Patch[User] { + p := deep.Patch[User]{} + if t.ID != other.ID { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/id", Old: t.ID, New: other.ID}) + } + if t.Name != other.Name { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/full_name", Old: t.Name, New: other.Name}) + } + subInfo := (&t.Info).Diff(&other.Info) + for _, op := range subInfo.Operations { + if op.Path == "" || op.Path == "/" { + op.Path = "/info" + } else { + op.Path = "/info" + op.Path + } + p.Operations = append(p.Operations, op) + } + if len(t.Roles) != len(other.Roles) { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/roles", Old: t.Roles, New: other.Roles}) + } else { + for i := range t.Roles { + if t.Roles[i] != other.Roles[i] { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: fmt.Sprintf("/roles/%d", i), Old: t.Roles[i], New: other.Roles[i]}) + } + } + } + if other.Score != nil { + for k, v := range other.Score { + if t.Score == nil { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: fmt.Sprintf("/score/%v", k), New: v}) + continue + } + if oldV, ok := t.Score[k]; !ok || v != oldV { + kind := deep.OpReplace + if !ok { + kind = deep.OpAdd + } + p.Operations = append(p.Operations, deep.Operation{Kind: kind, Path: fmt.Sprintf("/score/%v", k), Old: oldV, New: v}) + } + } + } + if t.Score != nil { + for k, v := range t.Score { + if other.Score == nil || !contains(other.Score, k) { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpRemove, Path: fmt.Sprintf("/score/%v", k), Old: v}) + } + } + } + subBio := (&t.Bio).Diff(other.Bio) + for _, op := range subBio.Operations { + if op.Path == "" || op.Path == "/" { + op.Path = "/bio" + } else { + op.Path = "/bio" + op.Path + } + p.Operations = append(p.Operations, op) + } + if t.age != other.age { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/age", Old: t.age, New: other.age}) + } + + return p +} + +func (t *User) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/id", "/ID": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.ID, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.ID)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field ID") + } + _fv := float64(t.ID) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.ID == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.ID == iv { + return true, nil + } + case float64: + if float64(t.ID) == iv { + return true, nil + } + } + } + } + return false, nil + } + case "/full_name", "/Name": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Name, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Name)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Name") + } + switch c.Op { + case "==": + return t.Name == _sv, nil + case "!=": + return t.Name != _sv, nil + case ">": + return t.Name > _sv, nil + case "<": + return t.Name < _sv, nil + case ">=": + return t.Name >= _sv, nil + case "<=": + return t.Name <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Name == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Name == sv { + return true, nil + } + } + } + return false, nil + } + case "/age": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.age, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.age)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field age") + } + _fv := float64(t.age) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.age == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.age == iv { + return true, nil + } + case float64: + if float64(t.age) == iv { + return true, nil + } + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *User) Equal(other *User) bool { + if t.ID != other.ID { + return false + } + if t.Name != other.Name { + return false + } + if !(&t.Info).Equal((&other.Info)) { + return false + } + if len(t.Roles) != len(other.Roles) { + return false + } + for i := range t.Roles { + if t.Roles[i] != other.Roles[i] { + return false + } + } + if len(t.Score) != len(other.Score) { + return false + } + for k, v := range t.Score { + vOther, ok := other.Score[k] + if !ok { + return false + } + if v != vOther { + return false + } + } + if len(t.Bio) != len(other.Bio) { + return false + } + for i := range t.Bio { + if t.Bio[i] != other.Bio[i] { + return false + } + } + if t.age != other.age { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *User) Clone() *User { + res := &User{ + ID: t.ID, + Name: t.Name, + Roles: append([]string(nil), t.Roles...), + Bio: append(crdt.Text(nil), t.Bio...), + age: t.age, + } + res.Info = *(&t.Info).Clone() + if t.Score != nil { + res.Score = make(map[string]int) + for k, v := range t.Score { + res.Score[k] = v + } + } + return res +} + +// Patch applies p to t using the generated fast path. +func (t *Detail) Patch(p deep.Patch[Detail], logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + if p.Guard != nil { + ok, err := t.evaluateCondition(*p.Guard) + if err != nil { + return fmt.Errorf("global condition evaluation failed: %w", err) + } + if !ok { + return fmt.Errorf("global condition not met") + } + } + var errs []error + for _, op := range p.Operations { + op.Strict = p.Strict + handled, err := t.applyOperation(op, logger) + if err != nil { + errs = append(errs, err) + } else if !handled { + if err := _deepengine.ApplyOpReflection(t, op, logger); err != nil { + errs = append(errs, err) + } + } + } + if len(errs) > 0 { + return &deep.ApplyError{Errors: errs} + } + return nil +} + +func (t *Detail) applyOperation(op deep.Operation, logger *slog.Logger) (bool, error) { + if op.If != nil { + ok, err := t.evaluateCondition(*op.If) + if err != nil || !ok { + return true, nil + } + } + if op.Unless != nil { + ok, err := t.evaluateCondition(*op.Unless) + if err != nil || ok { + return true, nil + } + } + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path) + return true, nil + } + + switch op.Path { + case "/": + if op.Strict && (op.Kind == deep.OpReplace || op.Kind == deep.OpRemove) { + if !deep.Equal(*t, op.Old.(Detail)) { + return true, fmt.Errorf("strict check failed at root: expected %v, got %v", op.Old, *t) + } + } + if op.Kind == deep.OpReplace { + if v, ok := op.New.(Detail); ok { + *t = v + return true, nil + } + } + return true, fmt.Errorf("unsupported root operation: %s", op.Kind) + case "/Age": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Age) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + _oldOK := false + if _oldV, ok := op.Old.(int); ok { + _oldOK = t.Age == _oldV + } + if !_oldOK { + if _oldF, ok := op.Old.(float64); ok { + _oldOK = float64(t.Age) == _oldF + } + } + if !_oldOK { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Age) + } + } + if v, ok := op.New.(int); ok { + t.Age = v + return true, nil + } + if f, ok := op.New.(float64); ok { + t.Age = int(f) + return true, nil + } + case "/addr", "/Address": + if op.Kind == deep.OpLog { + logger.Info("deep log", "message", op.New, "path", op.Path, "field", t.Address) + return true, nil + } + if op.Kind == deep.OpReplace && op.Strict { + if _oldV, ok := op.Old.(string); !ok || t.Address != _oldV { + return true, fmt.Errorf("strict check failed at %s: expected %v, got %v", op.Path, op.Old, t.Address) + } + } + if v, ok := op.New.(string); ok { + t.Address = v + return true, nil + } + default: + } + return false, nil +} + +// Diff compares t with other and returns a Patch. +func (t *Detail) Diff(other *Detail) deep.Patch[Detail] { + p := deep.Patch[Detail]{} + if t.Age != other.Age { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/Age", Old: t.Age, New: other.Age}) + } + if t.Address != other.Address { + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpReplace, Path: "/addr", Old: t.Address, New: other.Address}) + } + + return p +} + +func (t *Detail) evaluateCondition(c condition.Condition) (bool, error) { + switch c.Op { + case "and": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err != nil || !ok { + return false, err + } + } + return true, nil + case "or": + for _, sub := range c.Sub { + ok, err := t.evaluateCondition(*sub) + if err == nil && ok { + return true, nil + } + } + return false, nil + case "not": + if len(c.Sub) > 0 { + ok, err := t.evaluateCondition(*c.Sub[0]) + if err != nil { + return false, err + } + return !ok, nil + } + return true, nil + } + + switch c.Path { + case "/Age": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Age, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Age)) + } + var _cv float64 + switch v := c.Value.(type) { + case int: + _cv = float64(v) + case float64: + _cv = v + default: + return false, fmt.Errorf("condition value type mismatch for field Age") + } + _fv := float64(t.Age) + switch c.Op { + case "==": + return _fv == _cv, nil + case "!=": + return _fv != _cv, nil + case ">": + return _fv > _cv, nil + case "<": + return _fv < _cv, nil + case ">=": + return _fv >= _cv, nil + case "<=": + return _fv <= _cv, nil + case "in": + switch vals := c.Value.(type) { + case []int: + for _, v := range vals { + if t.Age == v { + return true, nil + } + } + case []any: + for _, v := range vals { + switch iv := v.(type) { + case int: + if t.Age == iv { + return true, nil + } + case float64: + if float64(t.Age) == iv { + return true, nil + } + } + } + } + return false, nil + } + case "/addr", "/Address": + if c.Op == "exists" { + return true, nil + } + if c.Op == "type" { + return condition.CheckType(t.Address, c.Value.(string)), nil + } + if c.Op == "matches" { + return regexp.MatchString(c.Value.(string), fmt.Sprintf("%v", t.Address)) + } + _sv, _ok := c.Value.(string) + if !_ok { + return false, fmt.Errorf("condition value type mismatch for field Address") + } + switch c.Op { + case "==": + return t.Address == _sv, nil + case "!=": + return t.Address != _sv, nil + case ">": + return t.Address > _sv, nil + case "<": + return t.Address < _sv, nil + case ">=": + return t.Address >= _sv, nil + case "<=": + return t.Address <= _sv, nil + case "in": + switch vals := c.Value.(type) { + case []string: + for _, v := range vals { + if t.Address == v { + return true, nil + } + } + case []any: + for _, v := range vals { + if sv, ok := v.(string); ok && t.Address == sv { + return true, nil + } + } + } + return false, nil + } + } + return false, fmt.Errorf("unsupported condition path or op: %s", c.Path) +} + +// Equal returns true if t and other are deeply equal. +func (t *Detail) Equal(other *Detail) bool { + if t.Age != other.Age { + return false + } + if t.Address != other.Address { + return false + } + return true +} + +// Clone returns a deep copy of t. +func (t *Detail) Clone() *Detail { + res := &Detail{ + Age: t.Age, + Address: t.Address, + } + return res +} + +func contains[M ~map[K]V, K comparable, V any](m M, k K) bool { + _, ok := m[k] + return ok +} diff --git a/internal/unsafe/disable_ro_test.go b/internal/unsafe/disable_ro_test.go index 8c2c31b..7145741 100644 --- a/internal/unsafe/disable_ro_test.go +++ b/internal/unsafe/disable_ro_test.go @@ -4,8 +4,8 @@ import ( "reflect" "testing" - "github.com/brunoga/deep/v4/internal/unsafe" - "github.com/brunoga/deep/v4/internal/unsafe/testdata/foo" + "github.com/brunoga/deep/v5/internal/unsafe" + "github.com/brunoga/deep/v5/internal/unsafe/testdata/foo" ) func TestDisableRO_CrossPackage(t *testing.T) { diff --git a/log.go b/log.go new file mode 100644 index 0000000..50040fb --- /dev/null +++ b/log.go @@ -0,0 +1 @@ +package deep diff --git a/merge.go b/merge.go deleted file mode 100644 index bcb28f6..0000000 --- a/merge.go +++ /dev/null @@ -1,155 +0,0 @@ -package deep - -import ( - "fmt" - "reflect" - "strings" - - "github.com/brunoga/deep/v4/internal/core" -) - -// Conflict represents a merge conflict where two patches modify the same path -// with different values or cause structural inconsistencies (tree conflicts). -type Conflict struct { - Path string - OpA OpInfo - OpB OpInfo - Base any -} - -func (c Conflict) String() string { - return fmt.Sprintf("conflict at %s: %v vs %v", c.Path, c.OpA.Val, c.OpB.Val) -} - -// Merge combines multiple patches into a single patch. -// It detects conflicts and overlaps, including tree conflicts. -func Merge[T any](patches ...Patch[T]) (Patch[T], []Conflict, error) { - if len(patches) == 0 { - return nil, nil, nil - } - - // 1. Flatten - opsByPath := make(map[string]OpInfo) - var conflicts []Conflict - var orderedPaths []string - - // Track which paths are removed or moved from, to detect tree conflicts. - removedPaths := make(map[string]int) - - for i, p := range patches { - err := p.Walk(func(path string, kind OpKind, oldVal, newVal any) error { - op := OpInfo{ - Kind: kind, - Path: path, - Val: newVal, - } - if kind == OpMove || kind == OpCopy { - op.From = oldVal.(string) - } - - // Tree Conflict Detection 1: New op is under an already removed path - for removed := range removedPaths { - if path == removed { - continue - } - if strings.HasPrefix(path, removed+"/") { - conflicts = append(conflicts, Conflict{ - Path: path, - OpA: OpInfo{Kind: OpRemove, Path: removed}, - OpB: op, - }) - } - } - - // Direct Conflict Detection - if existing, ok := opsByPath[path]; ok { - conflict := false - if existing.Kind != op.Kind { - conflict = true - } else { - if kind == OpMove || kind == OpCopy { - if existing.From != op.From { - conflict = true - } - } else { - if !core.Equal(existing.Val, op.Val) { - conflict = true - } - } - } - - if conflict { - conflicts = append(conflicts, Conflict{ - Path: path, - OpA: existing, - OpB: op, - }) - } - // Last wins: overwrite existing op - opsByPath[path] = op - } else { - opsByPath[path] = op - orderedPaths = append(orderedPaths, path) - } - - // Track removals - isRemoval := false - if kind == OpRemove { - isRemoval = true - } else if kind == OpMove { - removedPaths[op.From] = i - } else if kind == OpReplace { - if newVal == nil { - isRemoval = true - } else { - v := reflect.ValueOf(newVal) - if !v.IsValid() || ((v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface || v.Kind() == reflect.Map || v.Kind() == reflect.Slice) && v.IsNil()) { - isRemoval = true - } - } - } - - if isRemoval { - removedPaths[path] = i - - // Tree Conflict Detection 2: This removal invalidates existing ops under it - for existingPath, existingOp := range opsByPath { - if existingPath == path { - continue - } - if strings.HasPrefix(existingPath, path+"/") { - conflicts = append(conflicts, Conflict{ - Path: existingPath, - OpA: op, // The removal - OpB: existingOp, // The existing modification - }) - } - } - } - - return nil - }) - if err != nil { - return nil, nil, fmt.Errorf("error walking patch %d: %w", i, err) - } - } - - // 3. Rebuild - builder := NewPatchBuilder[T]() - - // Filter out orderedPaths that were overwritten (duplicates in list? no, orderedPaths is append-only) - // orderedPaths might contain duplicates if we added `path` multiple times? - // Logic: `if existing ... else append`. So no duplicates in orderedPaths. - // But `opsByPath` stores the *last* op. - - for _, path := range orderedPaths { - if op, ok := opsByPath[path]; ok { - if err := applyToBuilder(builder, op); err != nil { - return nil, conflicts, err - } - } - } - - p, err := builder.Build() - return p, conflicts, err -} diff --git a/merge_test.go b/merge_test.go deleted file mode 100644 index a143c1e..0000000 --- a/merge_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package deep - -import ( - "testing" -) - -func TestMerge_Basic(t *testing.T) { - type Data struct { - A int - B int - C int - } - base := Data{A: 1, B: 1, C: 1} - - // Patch 1: A -> 2 - p1 := MustDiff(base, Data{A: 2, B: 1, C: 1}) - - // Patch 2: B -> 3 - p2 := MustDiff(base, Data{A: 1, B: 3, C: 1}) - - // Merge - merged, conflicts, err := Merge(p1, p2) - if err != nil { - t.Fatalf("Merge failed: %v", err) - } - if len(conflicts) > 0 { - t.Errorf("Expected no conflicts, got %d", len(conflicts)) - } - - final := base - err = merged.ApplyChecked(&final) - if err != nil { - t.Fatalf("Apply failed: %v", err) - } - - expected := Data{A: 2, B: 3, C: 1} - if final != expected { - t.Errorf("Merge incorrect: got %+v, want %+v", final, expected) - } -} - -func TestMerge_Conflict(t *testing.T) { - type Data struct { - A int - } - base := Data{A: 1} - - // Patch 1: A -> 2 - p1 := MustDiff(base, Data{A: 2}) - - // Patch 2: A -> 3 - p2 := MustDiff(base, Data{A: 3}) - - merged, conflicts, err := Merge(p1, p2) - if err != nil { - t.Fatalf("Merge failed: %v", err) - } - - if len(conflicts) != 1 { - t.Fatalf("Expected 1 conflict, got %d", len(conflicts)) - } - - // By default, last one wins in current implementation for the patch part - final := base - merged.ApplyChecked(&final) - if final.A != 3 { - t.Errorf("Expected last patch to win value, got %d", final.A) - } -} - -func TestMerge_TreeConflict(t *testing.T) { - type Inner struct { - A int - } - type Data struct { - I *Inner - } - - base := Data{I: &Inner{A: 1}} - - // Patch 1: I.A -> 2 - p1 := MustDiff(base, Data{I: &Inner{A: 2}}) - - // Patch 2: I -> nil (delete I) - p2 := MustDiff(base, Data{I: nil}) - - // Merge - _, conflicts, err := Merge(p1, p2) - if err != nil { - t.Fatalf("Merge failed: %v", err) - } - - // Should detect conflict on /I/A (modified) vs /I (deleted) - if len(conflicts) == 0 { - t.Error("Expected tree conflict") - } -} diff --git a/patch.go b/patch.go index d0b2e7d..fd430bb 100644 --- a/patch.go +++ b/patch.go @@ -1,359 +1,418 @@ package deep import ( - "encoding/gob" "encoding/json" "fmt" - "reflect" "strings" - "github.com/brunoga/deep/v4/cond" + "github.com/brunoga/deep/v5/condition" + "github.com/brunoga/deep/v5/internal/engine" ) -// OpKind represents the type of operation in a patch. -type OpKind int - -const ( - OpAdd OpKind = iota - OpRemove - OpReplace - OpMove - OpCopy - OpTest - OpLog -) +// ApplyError represents one or more errors that occurred during patch application. +type ApplyError struct { + Errors []error +} -func (k OpKind) String() string { - switch k { - case OpAdd: - return "add" - case OpRemove: - return "remove" - case OpReplace: - return "replace" - case OpMove: - return "move" - case OpCopy: - return "copy" - case OpTest: - return "test" - case OpLog: - return "log" - default: - return "unknown" +func (e *ApplyError) Error() string { + if len(e.Errors) == 1 { + return e.Errors[0].Error() + } + var b strings.Builder + b.WriteString(fmt.Sprintf("%d errors during apply:\n", len(e.Errors))) + for _, err := range e.Errors { + b.WriteString("- " + err.Error() + "\n") } + return b.String() } -// Patch represents a set of changes that can be applied to a value of type T. -type Patch[T any] interface { - fmt.Stringer - - // Apply applies the patch to the value pointed to by v. - // The value v must not be nil. - Apply(v *T) +// Unwrap implements the errors.Join interface, allowing errors.Is and errors.As +// to inspect individual errors within the ApplyError. +func (e *ApplyError) Unwrap() []error { + return e.Errors +} - // ApplyChecked applies the patch only if specific conditions are met. - // 1. If the patch has a global Condition, it must evaluate to true. - // 2. If Strict mode is enabled, every modification must match the 'oldVal' recorded in the patch. - // 3. Any local per-field conditions must evaluate to true. - ApplyChecked(v *T) error +// OpKind represents the type of operation in a patch. +type OpKind = engine.OpKind - // ApplyResolved applies the patch using a custom ConflictResolver. - // This is used for convergent synchronization (CRDTs). - ApplyResolved(v *T, r ConflictResolver) error +const ( + OpAdd = engine.OpAdd + OpRemove = engine.OpRemove + OpReplace = engine.OpReplace + OpMove = engine.OpMove + OpCopy = engine.OpCopy + OpLog = engine.OpLog +) - // Walk calls fn for every operation in the patch. - // The path is a JSON Pointer dot-notation path (e.g. "/Field/SubField/0"). - // If fn returns an error, walking stops and that error is returned. - Walk(fn func(path string, op OpKind, old, new any) error) error +// Patch is a pure data structure representing a set of changes to type T. +// It is designed to be easily serializable and manipulatable. +type Patch[T any] struct { + // _ is a zero-size phantom field that binds T into the struct's type identity. + // It prevents Patch[Foo] from being assignable to Patch[Bar] at compile time + // without contributing any size or alignment to the struct. + _ [0]T - // WithCondition returns a new Patch with the given global condition attached. - WithCondition(c cond.Condition[T]) Patch[T] + // Guard is a global Condition that must be satisfied before any operation + // in this patch is applied. Set via WithGuard or Builder.Guard. + Guard *condition.Condition `json:"cond,omitempty"` - // WithStrict returns a new Patch with the strict consistency check enabled or disabled. - WithStrict(strict bool) Patch[T] + // Operations is a flat list of changes. + Operations []Operation `json:"ops"` - // Reverse returns a new Patch that undoes the changes in this patch. - Reverse() Patch[T] + // Strict mode enables Old value verification. + Strict bool `json:"strict,omitempty"` +} - // ToJSONPatch returns an RFC 6902 compliant JSON Patch representation of this patch. - ToJSONPatch() ([]byte, error) +// Operation is an alias for the internal engine operation type. +// +// Note: after JSON round-trip, numeric Old/New values become float64. +type Operation = engine.Operation - // Summary returns a human-readable summary of the changes in the patch. - Summary() string +// IsEmpty reports whether the patch contains no operations. +func (p Patch[T]) IsEmpty() bool { + return len(p.Operations) == 0 +} - // MarshalSerializable returns a serializable representation of the patch. - MarshalSerializable() (any, error) +// AsStrict returns a new patch with strict mode enabled. +// When strict mode is on, every Replace and Remove operation verifies the +// current value matches Op.Old before applying; mismatches return an error. +func (p Patch[T]) AsStrict() Patch[T] { + p.Strict = true + return p } -// NewPatch returns a new, empty patch for type T. -func NewPatch[T any]() Patch[T] { - return &typedPatch[T]{} +// WithGuard returns a new patch with the global guard condition set. +func (p Patch[T]) WithGuard(c *condition.Condition) Patch[T] { + p.Guard = c + return p } -// UnmarshalPatchSerializable reconstructs a patch from its serializable representation. -func UnmarshalPatchSerializable[T any](data any) (Patch[T], error) { - if data == nil { - return &typedPatch[T]{}, nil +// String returns a human-readable summary of the patch operations. +func (p Patch[T]) String() string { + if len(p.Operations) == 0 { + return "No changes." } - - m, ok := data.(map[string]any) - if !ok { - // Try direct unmarshal if it's not the wrapped map - inner, err := PatchFromSerializable(data) - if err != nil { - return nil, err + var b strings.Builder + for i, op := range p.Operations { + if i > 0 { + b.WriteByte('\n') + } + switch op.Kind { + case OpAdd: + b.WriteString(fmt.Sprintf("Add %s: %v", op.Path, op.New)) + case OpRemove: + b.WriteString(fmt.Sprintf("Remove %s (was %v)", op.Path, op.Old)) + case OpReplace: + b.WriteString(fmt.Sprintf("Replace %s: %v -> %v", op.Path, op.Old, op.New)) + case OpMove: + b.WriteString(fmt.Sprintf("Move %v to %s", op.Old, op.Path)) + case OpCopy: + b.WriteString(fmt.Sprintf("Copy %v to %s", op.Old, op.Path)) + case OpLog: + b.WriteString(fmt.Sprintf("Log %s: %v", op.Path, op.New)) } - return &typedPatch[T]{inner: inner.(diffPatch)}, nil } + return b.String() +} - innerData, ok := m["inner"] - if !ok { - // It might be a direct surrogate map - inner, err := PatchFromSerializable(m) - if err != nil { - return nil, err +// Reverse returns a new patch that undoes the changes in this patch. +func (p Patch[T]) Reverse() Patch[T] { + res := Patch[T]{ + Strict: p.Strict, + } + for i := len(p.Operations) - 1; i >= 0; i-- { + op := p.Operations[i] + rev := Operation{ + Path: op.Path, + } + switch op.Kind { + case OpAdd: + rev.Kind = OpRemove + rev.Old = op.New + case OpRemove: + rev.Kind = OpAdd + rev.New = op.Old + case OpReplace: + rev.Kind = OpReplace + rev.Old = op.New + rev.New = op.Old + case OpMove: + rev.Kind = OpMove + // op.Old for Move was the fromPath string. + // To reverse, we move back from current Path to op.Old Path. + rev.Path = fmt.Sprintf("%v", op.Old) + rev.Old = op.Path + case OpCopy: + // Undoing a copy means removing the copied value at the target path + rev.Kind = OpRemove + rev.Old = op.New } - return &typedPatch[T]{inner: inner.(diffPatch)}, nil + res.Operations = append(res.Operations, rev) } + return res +} - inner, err := PatchFromSerializable(innerData) - if err != nil { - return nil, err - } +// ToJSONPatch returns a JSON Patch representation compatible with RFC 6902 +// and the github.com/brunoga/jsonpatch extensions. +func (p Patch[T]) ToJSONPatch() ([]byte, error) { + var res []map[string]any + + // If there is a global condition, we prepend a no-op test operation + // that carries the condition. github.com/brunoga/jsonpatch supports this. + if p.Guard != nil { + res = append(res, map[string]any{ + "op": "test", + "path": "/", + "if": p.Guard.ToPredicate(), + }) + } + + for _, op := range p.Operations { + m := map[string]any{ + "op": op.Kind.String(), + "path": op.Path, + } - p := &typedPatch[T]{ - inner: inner.(diffPatch), - } - if condData, ok := m["cond"]; ok && condData != nil { - c, err := cond.ConditionFromSerializable[T](condData) - if err != nil { - return nil, err + switch op.Kind { + case OpAdd, OpReplace: + m["value"] = op.New + case OpMove, OpCopy: + m["from"] = op.Old + case OpLog: + m["value"] = op.New // log message } - p.cond = c - } - if strict, ok := m["strict"].(bool); ok { - p.strict = strict + + if op.If != nil { + m["if"] = op.If.ToPredicate() + } + if op.Unless != nil { + m["unless"] = op.Unless.ToPredicate() + } + + res = append(res, m) } - return p, nil + + return json.Marshal(res) } -// Register registers the Patch implementation for type T with the gob package. -// This is required if you want to use Gob serialization with Patch[T]. -func Register[T any]() { - gob.Register(&typedPatch[T]{}) +// ParseJSONPatch parses a JSON Patch document (RFC 6902 plus deep extensions) +// back into a Patch[T]. This is the inverse of Patch.ToJSONPatch(). +func ParseJSONPatch[T any](data []byte) (Patch[T], error) { + var ops []map[string]any + if err := json.Unmarshal(data, &ops); err != nil { + return Patch[T]{}, fmt.Errorf("ParseJSONPatch: %w", err) + } + res := Patch[T]{} + for _, m := range ops { + opStr, _ := m["op"].(string) + path, _ := m["path"].(string) + + // Global condition is encoded as a test op on "/" with an "if" predicate. + if opStr == "test" && path == "/" { + if ifPred, ok := m["if"].(map[string]any); ok { + res.Guard = condition.FromPredicate(ifPred) + } + continue + } + + op := Operation{Path: path} + + // Per-op conditions + if ifPred, ok := m["if"].(map[string]any); ok { + op.If = condition.FromPredicate(ifPred) + } + if unlessPred, ok := m["unless"].(map[string]any); ok { + op.Unless = condition.FromPredicate(unlessPred) + } + + switch opStr { + case "add": + op.Kind = OpAdd + op.New = m["value"] + case "remove": + op.Kind = OpRemove + case "replace": + op.Kind = OpReplace + op.New = m["value"] + case "move": + op.Kind = OpMove + op.Old = m["from"] + case "copy": + op.Kind = OpCopy + op.Old = m["from"] + case "log": + op.Kind = OpLog + op.New = m["value"] + default: + continue // unknown op, skip + } + + res.Operations = append(res.Operations, op) + } + return res, nil } -// ApplyError represents one or more errors that occurred during patch application. -type ApplyError struct { - errors []error +// Edit returns a Builder for constructing a Patch[T]. The target argument is +// used only for type inference and is not stored; the builder produces a +// standalone Patch, not a live view of the target. +func Edit[T any](_ *T) *Builder[T] { + return &Builder[T]{} } -func (e *ApplyError) Error() string { - if len(e.errors) == 1 { - return e.errors[0].Error() - } - var b strings.Builder - b.WriteString(fmt.Sprintf("%d errors during apply:\n", len(e.errors))) - for _, err := range e.errors { - b.WriteString("- " + err.Error() + "\n") - } - return b.String() +// Op is a pending patch operation. Obtain one from [Set], [Add], [Remove], +// [Move], or [Copy]; attach per-operation conditions with [Op.If] or +// [Op.Unless] before passing to [Builder.With]. +type Op struct { + op Operation } -func (e *ApplyError) Unwrap() []error { - return e.errors +// If attaches a condition that must hold for this operation to be applied. +func (o Op) If(c *condition.Condition) Op { + o.op.If = c + return o } -func (e *ApplyError) Errors() []error { - return e.errors +// Unless attaches a condition that must NOT hold for this operation to be applied. +func (o Op) Unless(c *condition.Condition) Op { + o.op.Unless = c + return o } -type typedPatch[T any] struct { - inner diffPatch - cond cond.Condition[T] - strict bool +// Set returns a type-safe replace operation. +func Set[T, V any](p Path[T, V], val V) Op { + return Op{op: Operation{Kind: OpReplace, Path: p.String(), New: val}} } -type patchUnwrapper interface { - unwrap() diffPatch +// Add returns a type-safe add (insert) operation. +func Add[T, V any](p Path[T, V], val V) Op { + return Op{op: Operation{Kind: OpAdd, Path: p.String(), New: val}} } -func (p *typedPatch[T]) unwrap() diffPatch { - return p.inner +// Remove returns a type-safe remove operation. +func Remove[T, V any](p Path[T, V]) Op { + return Op{op: Operation{Kind: OpRemove, Path: p.String()}} } -func (p *typedPatch[T]) Apply(v *T) { - if p.inner == nil { - return - } - rv := reflect.ValueOf(v).Elem() - p.inner.apply(reflect.ValueOf(v), rv, "/") +// Move returns a type-safe move operation that relocates the value at from to to. +// Both paths must share the same value type V. +func Move[T, V any](from, to Path[T, V]) Op { + return Op{op: Operation{Kind: OpMove, Path: to.String(), Old: from.String()}} } -func (p *typedPatch[T]) ApplyChecked(v *T) error { - if p.cond != nil { - ok, err := p.cond.Evaluate(v) - if err != nil { - return &ApplyError{errors: []error{fmt.Errorf("condition evaluation failed: %w", err)}} - } - if !ok { - return &ApplyError{errors: []error{fmt.Errorf("condition failed")}} - } - } +// Copy returns a type-safe copy operation that duplicates the value at from to to. +// Both paths must share the same value type V. +func Copy[T, V any](from, to Path[T, V]) Op { + return Op{op: Operation{Kind: OpCopy, Path: to.String(), Old: from.String()}} +} - if p.inner == nil { - return nil - } +// Builder constructs a [Patch] via a fluent chain. +type Builder[T any] struct { + global *condition.Condition + ops []Operation +} - rv := reflect.ValueOf(v).Elem() - err := p.inner.applyChecked(reflect.ValueOf(v), rv, p.strict, "/") - if err != nil { - if ae, ok := err.(*ApplyError); ok { - return ae - } - return &ApplyError{errors: []error{err}} +// Guard sets the global guard condition on the patch. If Guard has already been +// called, the new condition is ANDed with the existing one rather than +// replacing it — calling Guard twice is equivalent to Guard(And(c1, c2)). +func (b *Builder[T]) Guard(c *condition.Condition) *Builder[T] { + if b.global == nil { + b.global = c + } else { + b.global = And(b.global, c) } - return nil + return b } -func (p *typedPatch[T]) ApplyResolved(v *T, r ConflictResolver) error { - if p.inner == nil { - return nil +// With appends one or more operations to the patch being built. +// Obtain operations from the typed constructors [Set], [Add], [Remove], +// [Move], and [Copy]; per-operation conditions can be attached with +// [Op.If] and [Op.Unless] before passing here. +func (b *Builder[T]) With(ops ...Op) *Builder[T] { + for _, o := range ops { + b.ops = append(b.ops, o.op) } + return b +} - rv := reflect.ValueOf(v).Elem() - return p.inner.applyResolved(reflect.ValueOf(v), rv, "/", r) +// Log appends a log operation. +func (b *Builder[T]) Log(msg string) *Builder[T] { + b.ops = append(b.ops, Operation{ + Kind: OpLog, + Path: "/", + New: msg, + }) + return b } -func (p *typedPatch[T]) Walk(fn func(path string, op OpKind, old, new any) error) error { - if p.inner == nil { - return nil +// Build assembles and returns the completed Patch. +func (b *Builder[T]) Build() Patch[T] { + return Patch[T]{ + Guard: b.global, + Operations: b.ops, } +} - return p.inner.walk("", func(path string, op OpKind, old, new any) error { - fullPath := path - if fullPath == "" { - fullPath = "/" - } else if fullPath[0] != '/' { - fullPath = "/" + fullPath - } +// Eq creates an equality condition. +func Eq[T, V any](p Path[T, V], val V) *condition.Condition { + return &condition.Condition{Path: p.String(), Op: condition.Eq, Value: val} +} - return fn(fullPath, op, old, new) - }) +// Ne creates a non-equality condition. +func Ne[T, V any](p Path[T, V], val V) *condition.Condition { + return &condition.Condition{Path: p.String(), Op: condition.Ne, Value: val} } -func (p *typedPatch[T]) WithCondition(c cond.Condition[T]) Patch[T] { - return &typedPatch[T]{ - inner: p.inner, - cond: c, - strict: p.strict, - } +// Gt creates a greater-than condition. +func Gt[T, V any](p Path[T, V], val V) *condition.Condition { + return &condition.Condition{Path: p.String(), Op: condition.Gt, Value: val} } -func (p *typedPatch[T]) WithStrict(strict bool) Patch[T] { - return &typedPatch[T]{ - inner: p.inner, - cond: p.cond, - strict: strict, - } +// Ge creates a greater-than-or-equal condition. +func Ge[T, V any](p Path[T, V], val V) *condition.Condition { + return &condition.Condition{Path: p.String(), Op: condition.Ge, Value: val} } -func (p *typedPatch[T]) Reverse() Patch[T] { - if p.inner == nil { - return &typedPatch[T]{} - } - return &typedPatch[T]{ - inner: p.inner.reverse(), - strict: p.strict, - } +// Lt creates a less-than condition. +func Lt[T, V any](p Path[T, V], val V) *condition.Condition { + return &condition.Condition{Path: p.String(), Op: condition.Lt, Value: val} } -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("")) +// Le creates a less-than-or-equal condition. +func Le[T, V any](p Path[T, V], val V) *condition.Condition { + return &condition.Condition{Path: p.String(), Op: condition.Le, Value: val} } -func (p *typedPatch[T]) Summary() string { - if p.inner == nil { - return "No changes." - } - return p.inner.summary("/") +// Exists creates a condition that checks if a path exists. +func Exists[T, V any](p Path[T, V]) *condition.Condition { + return &condition.Condition{Path: p.String(), Op: condition.Exists} } -func (p *typedPatch[T]) MarshalSerializable() (any, error) { - inner, err := PatchToSerializable(p.inner) - if err != nil { - return nil, err - } - c, err := cond.ConditionToSerializable(p.cond) - if err != nil { - return nil, err - } - return map[string]any{ - "inner": inner, - "cond": c, - "strict": p.strict, - }, nil +// In creates a condition that checks if a value is in a list. +func In[T, V any](p Path[T, V], vals []V) *condition.Condition { + return &condition.Condition{Path: p.String(), Op: condition.In, Value: vals} } -func (p *typedPatch[T]) String() string { - if p.inner == nil { - return "" - } - return p.inner.format(0) +// Matches creates a regex condition. +func Matches[T, V any](p Path[T, V], regex string) *condition.Condition { + return &condition.Condition{Path: p.String(), Op: condition.Matches, Value: regex} } -func (p *typedPatch[T]) MarshalJSON() ([]byte, error) { - s, err := p.MarshalSerializable() - if err != nil { - return nil, err - } - return json.Marshal(s) +// Type creates a type-check condition. +func Type[T, V any](p Path[T, V], typeName string) *condition.Condition { + return &condition.Condition{Path: p.String(), Op: condition.Type, Value: typeName} } -func (p *typedPatch[T]) UnmarshalJSON(data []byte) error { - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return err - } - res, err := UnmarshalPatchSerializable[T](m) - if err != nil { - return err - } - if tp, ok := res.(*typedPatch[T]); ok { - p.inner = tp.inner - p.cond = tp.cond - p.strict = tp.strict - } - return nil +// And combines multiple conditions with logical AND. +func And(conds ...*condition.Condition) *condition.Condition { + return &condition.Condition{Op: condition.And, Sub: conds} } -func (p *typedPatch[T]) GobEncode() ([]byte, error) { - s, err := p.MarshalSerializable() - if err != nil { - return nil, err - } - return json.Marshal(s) +// Or combines multiple conditions with logical OR. +func Or(conds ...*condition.Condition) *condition.Condition { + return &condition.Condition{Op: condition.Or, Sub: conds} } -func (p *typedPatch[T]) GobDecode(data []byte) error { - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return err - } - res, err := UnmarshalPatchSerializable[T](m) - if err != nil { - return err - } - if tp, ok := res.(*typedPatch[T]); ok { - p.inner = tp.inner - p.cond = tp.cond - p.strict = tp.strict - } - return nil +// Not inverts a condition. +func Not(c *condition.Condition) *condition.Condition { + return &condition.Condition{Op: condition.Not, Sub: []*condition.Condition{c}} } diff --git a/patch_serialization.go b/patch_serialization.go deleted file mode 100644 index 0b89b5b..0000000 --- a/patch_serialization.go +++ /dev/null @@ -1,510 +0,0 @@ -package deep - -import ( - "encoding/gob" - "encoding/json" - "fmt" - "reflect" - "sync" - - "github.com/brunoga/deep/v4/cond" - "github.com/brunoga/deep/v4/internal/core" -) - -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, p diffPatch) (*patchSurrogate, error) { - c, ifC, unlessC := p.conditions() - cData, err := cond.ConditionToSerializable(c) - if err != nil { - return nil, err - } - if cData != nil { - data["c"] = cData - } - ifCData, err := cond.ConditionToSerializable(ifC) - if err != nil { - return nil, err - } - if ifCData != nil { - data["if"] = ifCData - } - unlessCData, err := cond.ConditionToSerializable(unlessC) - if err != nil { - return nil, err - } - if unlessCData != nil { - data["un"] = unlessCData - } - return &patchSurrogate{Kind: kind, Data: data}, nil -} - -var ( - customPatchTypes = make(map[string]reflect.Type) - muCustom sync.RWMutex -) - -// RegisterCustomPatch registers a custom patch implementation for serialization. -// The provided patch instance must implement interface { PatchKind() string }. -func RegisterCustomPatch(p any) { - pk, ok := p.(interface{ PatchKind() string }) - if !ok { - panic(fmt.Sprintf("RegisterCustomPatch: type %T does not implement PatchKind()", p)) - } - kind := pk.PatchKind() - muCustom.Lock() - defer muCustom.Unlock() - customPatchTypes[kind] = reflect.TypeOf(p) -} - -// PatchToSerializable returns a serializable representation of the patch. -// This is intended for use by pluggable encoders (e.g. gRPC, custom binary formats). -func PatchToSerializable(p any) (any, error) { - if p == nil { - return nil, nil - } - - var dp diffPatch - if typed, ok := p.(patchUnwrapper); ok { - dp = typed.unwrap() - } else if direct, ok := p.(diffPatch); ok { - dp = direct - } else { - return nil, fmt.Errorf("invalid patch type: %T", p) - } - - return marshalDiffPatch(dp) -} - -func marshalDiffPatch(p diffPatch) (any, error) { - if p == nil { - return nil, nil - } - switch v := p.(type) { - case *valuePatch: - return makeSurrogate("value", map[string]any{ - "o": core.ValueToInterface(v.oldVal), - "n": core.ValueToInterface(v.newVal), - }, v) - case *ptrPatch: - elem, err := marshalDiffPatch(v.elemPatch) - if err != nil { - return nil, err - } - return makeSurrogate("ptr", map[string]any{ - "p": elem, - }, v) - case *interfacePatch: - elem, err := marshalDiffPatch(v.elemPatch) - if err != nil { - return nil, err - } - return makeSurrogate("interface", map[string]any{ - "p": elem, - }, v) - case *structPatch: - fields := make(map[string]any) - for name, patch := range v.fields { - p, err := marshalDiffPatch(patch) - if err != nil { - return nil, err - } - fields[name] = p - } - return makeSurrogate("struct", map[string]any{ - "f": fields, - }, v) - case *arrayPatch: - indices := make(map[string]any) - for idx, patch := range v.indices { - p, err := marshalDiffPatch(patch) - if err != nil { - return nil, err - } - indices[fmt.Sprintf("%d", idx)] = p - } - return makeSurrogate("array", map[string]any{ - "i": indices, - }, v) - case *mapPatch: - added := make([]map[string]any, 0, len(v.added)) - for k, val := range v.added { - added = append(added, map[string]any{"k": k, "v": core.ValueToInterface(val)}) - } - removed := make([]map[string]any, 0, len(v.removed)) - for k, val := range v.removed { - removed = append(removed, map[string]any{"k": k, "v": core.ValueToInterface(val)}) - } - modified := make([]map[string]any, 0, len(v.modified)) - for k, patch := range v.modified { - p, err := marshalDiffPatch(patch) - if err != nil { - return nil, err - } - modified = append(modified, map[string]any{"k": k, "p": p}) - } - orig := make([]map[string]any, 0, len(v.originalKeys)) - for k, v := range v.originalKeys { - orig = append(orig, map[string]any{"k": k, "v": v}) - } - return makeSurrogate("map", map[string]any{ - "a": added, - "r": removed, - "m": modified, - "o": orig, - }, v) - case *slicePatch: - ops := make([]map[string]any, 0, len(v.ops)) - for _, op := range v.ops { - p, err := marshalDiffPatch(op.Patch) - if err != nil { - return nil, err - } - ops = append(ops, map[string]any{ - "k": int(op.Kind), - "i": op.Index, - "v": core.ValueToInterface(op.Val), - "p": p, - "y": op.Key, - "r": op.PrevKey, - }) - } - return makeSurrogate("slice", map[string]any{ - "o": ops, - }, v) - case *testPatch: - return makeSurrogate("test", map[string]any{ - "e": core.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) - case *customDiffPatch: - if pk, ok := v.patch.(interface{ PatchKind() string }); ok { - return makeSurrogate("custom", map[string]any{ - "k": pk.PatchKind(), - "v": v.patch, - }, v) - } - return nil, fmt.Errorf("unknown patch type: %T (does not implement PatchKind())", v.patch) - } - return nil, fmt.Errorf("unknown patch type: %T", p) -} - -func unmarshalDiffPatch(data []byte) (diffPatch, error) { - var s patchSurrogate - if err := json.Unmarshal(data, &s); err != nil { - return nil, err - } - return convertFromSurrogate(&s) -} - -func unmarshalCondFromMap(d map[string]any, key string) (any, error) { - if cData, ok := d[key]; ok && cData != nil { - // We use ConditionFromSerializable for 'any' since diffPatch stores 'any' (InternalCondition) - c, err := cond.ConditionFromSerializable[any](cData) - if err != nil { - return nil, err - } - return c, nil - } - return nil, nil -} - -func unmarshalBasePatch(d map[string]any) (basePatch, error) { - c, err := unmarshalCondFromMap(d, "c") - if err != nil { - return basePatch{}, err - } - ifCond, err := unmarshalCondFromMap(d, "if") - if err != nil { - return basePatch{}, err - } - unlessCond, err := unmarshalCondFromMap(d, "un") - if err != nil { - return basePatch{}, err - } - return basePatch{ - cond: c, - ifCond: ifCond, - unlessCond: unlessCond, - }, nil -} - -// PatchFromSerializable reconstructs a patch from its serializable representation. -func PatchFromSerializable(s any) (any, error) { - if s == nil { - return nil, nil - } - return convertFromSurrogate(s) -} - -func convertFromSurrogate(s any) (diffPatch, error) { - if s == nil { - return nil, nil - } - - var kind string - var data any - - switch v := s.(type) { - case *patchSurrogate: - kind = v.Kind - data = v.Data - case map[string]any: - kind = v["k"].(string) - data = v["d"] - default: - return nil, fmt.Errorf("invalid surrogate type: %T", s) - } - - d := data.(map[string]any) - base, err := unmarshalBasePatch(d) - if err != nil { - return nil, err - } - - switch kind { - case "value": - return &valuePatch{ - oldVal: core.InterfaceToValue(d["o"]), - newVal: core.InterfaceToValue(d["n"]), - basePatch: base, - }, nil - case "ptr": - elem, err := convertFromSurrogate(d["p"]) - if err != nil { - return nil, err - } - return &ptrPatch{ - elemPatch: elem, - basePatch: base, - }, nil - case "interface": - elem, err := convertFromSurrogate(d["p"]) - if err != nil { - return nil, err - } - return &interfacePatch{ - elemPatch: elem, - basePatch: base, - }, nil - case "struct": - fieldsData := d["f"].(map[string]any) - fields := make(map[string]diffPatch) - for name, pData := range fieldsData { - p, err := convertFromSurrogate(pData) - if err != nil { - return nil, err - } - fields[name] = p - } - return &structPatch{ - fields: fields, - basePatch: base, - }, nil - case "array": - indicesData := d["i"].(map[string]any) - indices := make(map[int]diffPatch) - for idxStr, pData := range indicesData { - var idx int - fmt.Sscanf(idxStr, "%d", &idx) - p, err := convertFromSurrogate(pData) - if err != nil { - return nil, err - } - indices[idx] = p - } - return &arrayPatch{ - indices: indices, - basePatch: base, - }, nil - case "map": - added := make(map[any]reflect.Value) - if a := d["a"]; a != nil { - if slice, ok := a.([]any); ok { - for _, entry := range slice { - e := entry.(map[string]any) - added[e["k"]] = core.InterfaceToValue(e["v"]) - } - } else if slice, ok := a.([]map[string]any); ok { - for _, e := range slice { - added[e["k"]] = core.InterfaceToValue(e["v"]) - } - } - } - removed := make(map[any]reflect.Value) - if r := d["r"]; r != nil { - if slice, ok := r.([]any); ok { - for _, entry := range slice { - e := entry.(map[string]any) - removed[e["k"]] = core.InterfaceToValue(e["v"]) - } - } else if slice, ok := r.([]map[string]any); ok { - for _, e := range slice { - removed[e["k"]] = core.InterfaceToValue(e["v"]) - } - } - } - modified := make(map[any]diffPatch) - if m := d["m"]; m != nil { - if slice, ok := m.([]any); ok { - for _, entry := range slice { - e := entry.(map[string]any) - p, err := convertFromSurrogate(e["p"]) - if err != nil { - return nil, err - } - modified[e["k"]] = p - } - } else if slice, ok := m.([]map[string]any); ok { - for _, e := range slice { - p, err := convertFromSurrogate(e["p"]) - if err != nil { - return nil, err - } - modified[e["k"]] = p - } - } - } - originalKeys := make(map[any]any) - if o := d["o"]; o != nil { - if slice, ok := o.([]any); ok { - for _, entry := range slice { - e := entry.(map[string]any) - originalKeys[e["k"]] = e["v"] - } - } else if slice, ok := o.([]map[string]any); ok { - for _, e := range slice { - originalKeys[e["k"]] = e["v"] - } - } - } - return &mapPatch{ - added: added, - removed: removed, - modified: modified, - originalKeys: originalKeys, - basePatch: base, - }, nil - case "slice": - var opsDataRaw []any - if raw, ok := d["o"].([]any); ok { - opsDataRaw = raw - } else if raw, ok := d["o"].([]map[string]any); ok { - for _, m := range raw { - opsDataRaw = append(opsDataRaw, m) - } - } - - ops := make([]sliceOp, 0, len(opsDataRaw)) - for _, oRaw := range opsDataRaw { - var o map[string]any - switch v := oRaw.(type) { - case map[string]any: - o = v - case *patchSurrogate: - o = v.Data.(map[string]any) - } - p, err := convertFromSurrogate(o["p"]) - if err != nil { - return nil, err - } - - var kind float64 - switch k := o["k"].(type) { - case float64: - kind = k - case int: - kind = float64(k) - } - - var index float64 - switch i := o["i"].(type) { - case float64: - index = i - case int: - index = float64(i) - } - - ops = append(ops, sliceOp{ - Kind: OpKind(int(kind)), - Index: int(index), - Val: core.InterfaceToValue(o["v"]), - Patch: p, - Key: o["y"], - PrevKey: o["r"], - }) - } - return &slicePatch{ - ops: ops, - basePatch: base, - }, nil - case "test": - return &testPatch{ - expected: core.InterfaceToValue(d["e"]), - basePatch: base, - }, nil - case "copy": - return ©Patch{ - from: d["f"].(string), - basePatch: base, - }, nil - case "move": - return &movePatch{ - from: d["f"].(string), - basePatch: base, - }, nil - case "log": - return &logPatch{ - message: d["m"].(string), - basePatch: base, - }, nil - case "custom": - kind := d["k"].(string) - muCustom.RLock() - typ, ok := customPatchTypes[kind] - muCustom.RUnlock() - if !ok { - return nil, fmt.Errorf("unknown custom patch kind: %s", kind) - } - - // Create a new instance of the patch type. - // We expect typ to be a pointer type (e.g. *textPatch). - patchPtr := reflect.New(typ.Elem()).Interface() - - // Unmarshal the data into the new instance. - vData, err := json.Marshal(d["v"]) - if err != nil { - return nil, err - } - if err := json.Unmarshal(vData, patchPtr); err != nil { - return nil, err - } - - return &customDiffPatch{ - patch: patchPtr, - basePatch: base, - }, nil - } - return nil, fmt.Errorf("unknown patch kind: %s", kind) -} - -func init() { - gob.Register(&patchSurrogate{}) - gob.Register(map[string]any{}) - gob.Register([]any{}) - gob.Register([]map[string]any{}) -} diff --git a/patch_serialization_test.go b/patch_serialization_test.go deleted file mode 100644 index b441bc3..0000000 --- a/patch_serialization_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package deep - -import ( - "bytes" - "encoding/gob" - "encoding/json" - "reflect" - "testing" - - "github.com/brunoga/deep/v4/cond" -) - -func TestPatchJSONSerialization(t *testing.T) { - type SubStruct struct { - A int - B string - } - type TestStruct struct { - I int - S string - B bool - M map[string]int - L []int - O *SubStruct - } - - s1 := TestStruct{ - I: 1, - S: "foo", - B: true, - M: map[string]int{"a": 1}, - L: []int{1, 2, 3}, - O: &SubStruct{A: 10, B: "bar"}, - } - s2 := TestStruct{ - I: 2, - S: "bar", - B: false, - M: map[string]int{"a": 2, "b": 3}, - L: []int{1, 4, 3, 5}, - O: &SubStruct{A: 20, B: "baz"}, - } - - p := MustDiff(s1, s2) - if p == nil { - t.Fatal("Diff should not be nil") - } - - data, err := json.Marshal(p) - if err != nil { - t.Fatalf("JSON Marshal failed: %v", err) - } - - p2 := NewPatch[TestStruct]() - if err := json.Unmarshal(data, p2); err != nil { - t.Fatalf("JSON Unmarshal failed: %v", err) - } - - s3 := s1 - p2.Apply(&s3) - - if !reflect.DeepEqual(s2, s3) { - t.Errorf(`Apply after JSON serialization failed. -Expected: %+v -Got: %+v`, s2, s3) - } -} - -func TestPatchGobSerialization(t *testing.T) { - type SubStruct struct { - A int - B string - } - type TestStruct struct { - I int - S string - B bool - M map[string]int - L []int - O *SubStruct - } - - // Gob needs registration for types used in any/interface{} - gob.Register(SubStruct{}) - Register[TestStruct]() - - s1 := TestStruct{ - I: 1, - S: "foo", - B: true, - M: map[string]int{"a": 1}, - L: []int{1, 2, 3}, - O: &SubStruct{A: 10, B: "bar"}, - } - s2 := TestStruct{ - I: 2, - S: "bar", - B: false, - M: map[string]int{"a": 2, "b": 3}, - L: []int{1, 4, 3, 5}, - O: &SubStruct{A: 20, B: "baz"}, - } - - p := MustDiff(s1, s2) - if p == nil { - t.Fatal("Diff should not be nil") - } - - var buf bytes.Buffer - enc := gob.NewEncoder(&buf) - if err := enc.Encode(&p); err != nil { - t.Fatalf("Gob Encode failed: %v", err) - } - - p2 := NewPatch[TestStruct]() - dec := gob.NewDecoder(&buf) - if err := dec.Decode(&p2); err != nil { - t.Fatalf("Gob Decode failed: %v", err) - } - - s3 := s1 - p2.Apply(&s3) - - if !reflect.DeepEqual(s2, s3) { - t.Errorf(`Apply after Gob serialization failed. -Expected: %+v -Got: %+v`, s2, s3) - } -} - -func TestPatchWithConditionSerialization(t *testing.T) { - type TestStruct struct { - I int - } - - s1 := TestStruct{I: 1} - s2 := TestStruct{I: 2} - - p := MustDiff(s1, s2).WithCondition(cond.Eq[TestStruct]("I", 1)) - - data, err := json.Marshal(p) - if err != nil { - t.Fatalf("JSON Marshal failed: %v", err) - } - - p2 := NewPatch[TestStruct]() - if err := json.Unmarshal(data, p2); err != nil { - t.Fatalf("JSON Unmarshal failed: %v", err) - } - - s3 := s1 - if err := p2.ApplyChecked(&s3); err != nil { - t.Fatalf("ApplyChecked failed: %v", err) - } - - if s3.I != 2 { - t.Errorf("Expected I=2, got %d", s3.I) - } - - s4 := TestStruct{I: 10} - if err := p2.ApplyChecked(&s4); err == nil { - t.Error("ApplyChecked should have failed due to condition") - } -} - -func TestPatch_SerializationExhaustive(t *testing.T) { - type Data struct { - C []int - } - Register[Data]() - - builder := NewPatchBuilder[Data]() - builder.Field("C").Index(0).Set(1, 10) - - patch, _ := builder.Build() - - // Gob - var buf bytes.Buffer - enc := gob.NewEncoder(&buf) - enc.Encode(patch) - - dec := gob.NewDecoder(bytes.NewReader(buf.Bytes())) - var patch2 typedPatch[Data] - dec.Decode(&patch2) - - // JSON - data, _ := json.Marshal(patch) - var patch3 typedPatch[Data] - json.Unmarshal(data, &patch3) -} - -func TestPatch_Serialization_Conditions(t *testing.T) { - type Data struct{ A int } - builder := NewPatchBuilder[Data]() - c := cond.Eq[Data]("A", 1) - builder.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_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 TestPatchSerialization_SemanticSlice(t *testing.T) { - type Item struct { - ID int `deep:"key"` - Name string - } - type Data struct { - Items []Item `deep:"key"` - } - - s1 := Data{Items: []Item{{ID: 1, Name: "A"}}} - s2 := Data{Items: []Item{{ID: 1, Name: "A"}, {ID: 2, Name: "B"}}} - - patch := MustDiff(s1, s2) - - // Verify internal state before serialization - unwrapped := patch.(patchUnwrapper).unwrap() - sp := unwrapped.(*structPatch).fields["Items"].(*slicePatch) - foundAdd := false - for _, op := range sp.ops { - if op.Kind == OpAdd { - if op.Key != float64(2) && op.Key != 2 { // json uses float64 - t.Errorf("Expected Key=2, got %v", op.Key) - } - if op.PrevKey != float64(1) && op.PrevKey != 1 { - t.Errorf("Expected PrevKey=1, got %v", op.PrevKey) - } - foundAdd = true - } - } - if !foundAdd { - t.Fatal("Add operation not found in patch") - } - - // JSON Roundtrip - data, err := json.Marshal(patch) - if err != nil { - t.Fatalf("Marshal failed: %v", err) - } - - p2 := NewPatch[Data]() - if err := json.Unmarshal(data, p2); err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - - // Verify internal state after serialization - unwrapped2 := p2.(patchUnwrapper).unwrap() - sp2 := unwrapped2.(*structPatch).fields["Items"].(*slicePatch) - for _, op := range sp2.ops { - if op.Kind == OpAdd { - // json.Unmarshal uses float64 for all numbers - if op.Key != float64(2) { - t.Errorf("After Unmarshal: Expected Key=2 (float64), got %v (%T)", op.Key, op.Key) - } - if op.PrevKey != float64(1) { - t.Errorf("After Unmarshal: Expected PrevKey=1 (float64), got %v (%T)", op.PrevKey, op.PrevKey) - } - } - } -} - -func TestPatchSerializable(t *testing.T) { - type TestStruct struct { - A int - B string - } - s1 := TestStruct{A: 1, B: "foo"} - s2 := TestStruct{A: 2, B: "bar"} - patch := MustDiff(s1, s2) - - // Marshal to serializable - data, err := patch.MarshalSerializable() - if err != nil { - t.Fatalf("MarshalSerializable failed: %v", err) - } - - // Unmarshal from serializable - p2, err := UnmarshalPatchSerializable[TestStruct](data) - if err != nil { - t.Fatalf("UnmarshalPatchSerializable failed: %v", err) - } - - s3 := s1 - p2.Apply(&s3) - if !reflect.DeepEqual(s2, s3) { - t.Errorf("Apply after serializable roundtrip failed. Got %+v, want %+v", s3, s2) - } -} diff --git a/patch_test.go b/patch_test.go index a99ede4..4dca040 100644 --- a/patch_test.go +++ b/patch_test.go @@ -1,409 +1,307 @@ -package deep +package deep_test import ( + "bytes" + "encoding/gob" "encoding/json" - "fmt" - "reflect" "strings" "testing" - "github.com/brunoga/deep/v4/cond" + "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/condition" + "github.com/brunoga/deep/v5/crdt" + "github.com/brunoga/deep/v5/crdt/hlc" + "github.com/brunoga/deep/v5/internal/testmodels" ) -func TestPatch_String_Basic(t *testing.T) { - a, b := "foo", "bar" - patch := MustDiff(a, b) - if !strings.Contains(patch.String(), "foo -> bar") { - t.Errorf("String() missing transition: %s", patch.String()) - } -} - -func TestPatch_String_Complex(t *testing.T) { - type Child struct { - Name string - } - type Data struct { - Tags []string - Meta map[string]any - Kids []Child - Status *string - } - active := "active" - inactive := "inactive" - a := Data{ - Tags: []string{"tag1", "tag2"}, - Meta: map[string]any{ - "key1": "val1", - "key2": 123, - }, - Kids: []Child{ - {Name: "Kid1"}, - }, - Status: &active, - } - b := Data{ - Tags: []string{"tag1", "tag2", "tag3"}, - Meta: map[string]any{ - "key1": "val1-mod", - "key3": true, - }, - Kids: []Child{ - {Name: "Kid1"}, - {Name: "Kid2"}, - }, - Status: &inactive, - } - patch := MustDiff(a, b) - if patch == nil { - t.Fatal("Expected non-nil patch") - } - - summary := patch.String() - if !strings.Contains(summary, "+ [1]: {Kid2}") { - t.Errorf("String() missing added kid: %s", summary) - } -} - -func TestPatch_ApplyResolved(t *testing.T) { - type Config struct { - Value int - } - c1 := Config{Value: 10} - c2 := Config{Value: 20} - - patch := MustDiff(c1, c2) - - target := Config{Value: 10} +func TestGobSerialization(t *testing.T) { + gob.Register(deep.Patch[testmodels.User]{}) + gob.Register(deep.Operation{}) + gob.Register(testmodels.User{}) + gob.Register([]testmodels.User{}) + gob.Register(map[string]testmodels.User{}) - // Resolver that rejects everything - err := patch.ApplyResolved(&target, ConflictResolverFunc(func(path string, op OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) { - return reflect.Value{}, false - })) + u1 := testmodels.User{ID: 1, Name: "Alice"} + u2 := testmodels.User{ID: 2, Name: "Bob"} + patch, err := deep.Diff(u1, u2) if err != nil { - t.Fatalf("ApplyResolved failed: %v", err) + t.Fatalf("Diff failed: %v", err) } - if target.Value != 10 { - t.Errorf("Value should not have changed, got %d", target.Value) + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + if err := enc.Encode(patch); err != nil { + t.Fatalf("Gob Encode failed: %v", err) } - // Resolver that accepts everything - err = patch.ApplyResolved(&target, ConflictResolverFunc(func(path string, op OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) { - return proposed, true - })) - if err != nil { - t.Fatalf("ApplyResolved failed: %v", err) + var patch2 deep.Patch[testmodels.User] + dec := gob.NewDecoder(&buf) + if err := dec.Decode(&patch2); err != nil { + t.Fatalf("Gob Decode failed: %v", err) } - if target.Value != 20 { - t.Errorf("Value should have changed to 20, got %d", target.Value) + u3 := u1 + deep.Apply(&u3, patch2) + if !deep.Equal(u2, u3) { + t.Errorf("Gob roundtrip failed: got %+v, want %+v", u3, u2) } } -type ConflictResolverFunc func(path string, op OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) - -func (f ConflictResolverFunc) Resolve(path string, op OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) { - return f(path, op, key, prevKey, current, proposed) -} +func TestReverse(t *testing.T) { + u1 := testmodels.User{ID: 1, Name: "Alice"} + u2 := testmodels.User{ID: 2, Name: "Bob"} -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 + // 1. Create patch u1 -> u2 + patch, err := deep.Diff(u1, u2) + if err != nil { + t.Fatalf("Diff failed: %v", err) } - builder := NewPatchBuilder[DataC]() - - c := cond.Eq[DataC]("A", 1) - builder.If(c).Unless(c).Test(DataC{A: 1}) + // 2. Reverse patch + reverse := patch.Reverse() - builder.Field("P").If(c).Unless(c) - builder.Field("I").If(c).Unless(c) - builder.Field("M").If(c).Unless(c) - builder.Field("S").If(c).Unless(c) - builder.Field("Arr").If(c).Unless(c) - - patch, _ := builder.Build() - if patch == nil { - t.Fatal("Build failed") + // 3. Apply reverse to u2 + u3 := u2 + if err := deep.Apply(&u3, reverse); err != nil { + t.Fatalf("Reverse apply failed: %v", err) } -} -func TestPatch_MoreApplyChecked(t *testing.T) { - // ptrPatch - t.Run("ptrPatch", func(t *testing.T) { - val1 := 1 - p1 := &val1 - val2 := 2 - p2 := &val2 - patch := MustDiff(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 := MustDiff(i1, i2) - if err := patch.ApplyChecked(&i1); err != nil { - t.Errorf("interfacePatch ApplyChecked failed: %v", err) - } - }) -} - -func TestPatch_ToJSONPatch_Exhaustive(t *testing.T) { - type Inner struct{ V int } - type Data struct { - P *Inner - I any - A []Inner - M map[string]Inner + // 4. Verify we are back to u1 + if !deep.Equal(u1, u3) { + t.Errorf("Reverse failed: got %+v, want %+v", u3, u1) } - - builder := NewPatchBuilder[Data]() - - builder.Field("P").Elem().Field("V").Set(1, 2) - builder.Field("I").Elem().Set(1, 2) - builder.Field("A").Index(0).Field("V").Set(1, 2) - builder.Field("M").MapKey("k").Field("V").Set(1, 2) - - patch, _ := builder.Build() - patch.ToJSONPatch() } -func TestPatch_LogExhaustive(t *testing.T) { - lp := &logPatch{message: "test"} +func TestPatchToJSONPatch(t *testing.T) { - lp.apply(reflect.Value{}, reflect.ValueOf(1), "/path") - - if err := lp.applyChecked(reflect.ValueOf(1), reflect.ValueOf(1), false, "/path"); err != nil { - t.Errorf("logPatch applyChecked failed: %v", err) + p := deep.Patch[testmodels.User]{} + p.Operations = []deep.Operation{ + {Kind: deep.OpReplace, Path: "/full_name", Old: "Alice", New: "Bob"}, } + p = p.WithGuard(deep.Eq(deep.Field(func(u *testmodels.User) *int { return &u.ID }), 1)) - if lp.reverse() != lp { - t.Error("logPatch reverse should return itself") + data, err := p.ToJSONPatch() + if err != nil { + t.Fatalf("ToJSONPatch failed: %v", err) } - if lp.format(0) == "" { - t.Error("logPatch format returned empty string") + var raw []map[string]any + json.Unmarshal(data, &raw) + + if len(raw) != 2 { + t.Fatalf("expected 2 ops (global condition + replace), got %d", len(raw)) } - ops := lp.toJSONPatch("/path") - if len(ops) != 1 || ops[0]["op"] != "log" { - t.Errorf("Unexpected toJSONPatch output: %+v", ops) + if raw[0]["op"] != "test" { + t.Errorf("expected first op to be test (global condition), got %v", raw[0]["op"]) } } -func TestPatch_Walk_Basic(t *testing.T) { - a := 10 - b := 20 - patch := MustDiff(a, b) - - var ops []string - err := patch.Walk(func(path string, op OpKind, old, new any) error { - ops = append(ops, fmt.Sprintf("%s:%s:%v:%v", path, op, old, new)) - return nil - }) - - if err != nil { - t.Fatalf("Walk failed: %v", err) +func TestPatchUtilities(t *testing.T) { + p := deep.Patch[testmodels.User]{} + p.Operations = []deep.Operation{ + {Kind: deep.OpAdd, Path: "/a", New: 1}, + {Kind: deep.OpRemove, Path: "/b", Old: 2}, + {Kind: deep.OpReplace, Path: "/c", Old: 3, New: 4}, + {Kind: deep.OpMove, Path: "/d", Old: "/e"}, + {Kind: deep.OpCopy, Path: "/f", Old: "/g"}, + {Kind: deep.OpLog, Path: "/h", New: "msg"}, + } + + // String() + s := p.String() + expected := []string{"Add /a", "Remove /b", "Replace /c", "Move /e to /d", "Copy /g to /f", "Log /h"} + for _, exp := range expected { + if !strings.Contains(s, exp) { + t.Errorf("String() missing %s: %s", exp, s) + } } - expected := []string{"/:replace:10:20"} - if fmt.Sprintf("%v", ops) != fmt.Sprintf("%v", expected) { - t.Errorf("Expected ops %v, got %v", expected, ops) + // AsStrict + p2 := p.AsStrict() + if !p2.Strict { + t.Error("AsStrict failed to set global Strict") + } + // Operation.Strict is stamped from Patch.Strict at apply time, not at build time. + // Verify ops in the built patch do not carry the flag (it's runtime-only). + for _, op := range p2.Operations { + if op.Strict { + t.Error("AsStrict should not pre-stamp Strict onto operations before Apply") + } } } -func TestPatch_Walk_Struct(t *testing.T) { - type S struct { - A int - B string +func TestConditionToPredicate(t *testing.T) { + tests := []struct { + c *condition.Condition + want string + }{ + {c: &condition.Condition{Op: "!=", Path: "/a", Value: 1}, want: `"op":"not"`}, + {c: &condition.Condition{Op: ">", Path: "/a", Value: 1}, want: `"op":"more"`}, + {c: &condition.Condition{Op: "<", Path: "/a", Value: 1}, want: `"op":"less"`}, + {c: &condition.Condition{Op: "exists", Path: "/a"}, want: `"op":"defined"`}, + {c: &condition.Condition{Op: "matches", Path: "/a", Value: ".*"}, want: `"op":"matches"`}, + {c: &condition.Condition{Op: "type", Path: "/a", Value: "string"}, want: `"op":"type"`}, + {c: deep.Or(deep.Eq(deep.Field(func(u *testmodels.User) *int { return &u.ID }), 1)), want: `"op":"or"`}, + } + + for _, tt := range tests { + got, err := deep.Patch[testmodels.User]{}.WithGuard(tt.c).ToJSONPatch() + if err != nil { + t.Fatalf("ToJSONPatch failed: %v", err) + } + if !strings.Contains(string(got), tt.want) { + t.Errorf("toPredicate(%s) = %s, want %s", tt.c.Op, string(got), tt.want) + } } - a := S{A: 1, B: "one"} - b := S{A: 2, B: "two"} - patch := MustDiff(a, b) - - ops := make(map[string]string) - err := patch.Walk(func(path string, op OpKind, old, new any) error { - ops[path] = fmt.Sprintf("%s:%v:%v", op, old, new) - return nil - }) +} - if err != nil { - t.Fatalf("Walk failed: %v", err) +func TestPatchReverseExhaustive(t *testing.T) { + p := deep.Patch[testmodels.User]{} + p.Operations = []deep.Operation{ + {Kind: deep.OpAdd, Path: "/a", New: 1}, + {Kind: deep.OpRemove, Path: "/b", Old: 2}, + {Kind: deep.OpReplace, Path: "/c", Old: 3, New: 4}, + {Kind: deep.OpMove, Path: "/d", Old: "/e"}, + {Kind: deep.OpCopy, Path: "/f", Old: "/g"}, + {Kind: deep.OpLog, Path: "/h", New: "msg"}, } - if len(ops) != 2 { - t.Errorf("Expected 2 ops, got %d", len(ops)) + rev := p.Reverse() + if len(rev.Operations) != 6 { + t.Errorf("expected 6 reversed ops, got %d", len(rev.Operations)) } +} - if ops["/A"] != "replace:1:2" { - t.Errorf("Unexpected op for A: %s", ops["/A"]) - } - if ops["/B"] != "replace:one:two" { - t.Errorf("Unexpected op for B: %s", ops["/B"]) +func TestPatchMergeCustom(t *testing.T) { + p1 := deep.Patch[testmodels.User]{} + p1.Operations = []deep.Operation{{Path: "/a", New: 1}} + p2 := deep.Patch[testmodels.User]{} + p2.Operations = []deep.Operation{{Path: "/a", New: 2}} + + res := deep.Merge(p1, p2, &localResolver{}) + if res.Operations[0].New != 2 { + t.Error("Merge custom resolution failed") } } -func TestPatch_Walk_Slice(t *testing.T) { - a := []int{1, 2, 3} - b := []int{1, 4, 3, 5} - patch := MustDiff(a, b) - - var ops []string - err := patch.Walk(func(path string, op OpKind, old, new any) error { - ops = append(ops, fmt.Sprintf("%s:%s:%v:%v", path, op, old, new)) - return nil - }) +type localResolver struct{} - if err != nil { - t.Fatalf("Walk failed: %v", err) - } +func (r *localResolver) Resolve(path string, local, remote any) any { return remote } - found4 := false - found5 := false - for _, op := range ops { - if strings.Contains(op, ":2:4") || (strings.Contains(op, ":remove:2:") || strings.Contains(op, ":add::4")) { - if strings.Contains(op, "4") { - found4 = true - } - } - if strings.Contains(op, ":add::5") { - found5 = true - } +func TestPatchIsEmpty(t *testing.T) { + p := deep.Patch[testmodels.User]{} + if !p.IsEmpty() { + t.Error("new patch should be empty") } - - if !found4 || !found5 { - t.Errorf("Missing expected ops in %v", ops) + p.Operations = append(p.Operations, deep.Operation{Kind: deep.OpAdd, Path: "/name", New: "x"}) + if p.IsEmpty() { + t.Error("patch with operations should not be empty") } } -func TestPatch_Walk_Map(t *testing.T) { - a := map[string]int{"one": 1, "two": 2} - b := map[string]int{"one": 1, "two": 20, "three": 3} - patch := MustDiff(a, b) - - ops := make(map[string]string) - err := patch.Walk(func(path string, op OpKind, old, new any) error { - ops[path] = fmt.Sprintf("%s:%v:%v", op, old, new) - return nil - }) - +func TestParseJSONPatchRoundTrip(t *testing.T) { + type Doc struct { + Name string `json:"name"` + Alias string `json:"alias"` + Age int `json:"age"` + } + + // Build a patch with all supported op types and conditions. + namePath := deep.Field[Doc, string](func(d *Doc) *string { return &d.Name }) + aliasPath := deep.Field[Doc, string](func(d *Doc) *string { return &d.Alias }) + agePath := deep.Field[Doc, int](func(d *Doc) *int { return &d.Age }) + + original := deep.Edit(&Doc{}). + With( + deep.Set(namePath, "Alice"), + deep.Add(agePath, 30), + deep.Remove(namePath), + deep.Move(namePath, aliasPath), + deep.Copy(namePath, aliasPath).If(deep.Eq(namePath, "Alice")), + ). + Log("done"). + Guard(deep.Gt(agePath, 18)). + Build() + + data, err := original.ToJSONPatch() if err != nil { - t.Fatalf("Walk failed: %v", err) + t.Fatalf("ToJSONPatch: %v", err) } - if ops["/two"] != "replace:2:20" { - t.Errorf("Unexpected op for two: %s", ops["/two"]) - } - if ops["/three"] != "add::3" { - t.Errorf("Unexpected op for three: %s", ops["/three"]) + rt, err := deep.ParseJSONPatch[Doc](data) + if err != nil { + t.Fatalf("ParseJSONPatch: %v", err) } -} -func TestPatch_Walk_KeyedSlice(t *testing.T) { - type KeyedTask struct { - ID string `deep:"key"` - Status string + if len(rt.Operations) != len(original.Operations) { + t.Errorf("op count: got %d, want %d", len(rt.Operations), len(original.Operations)) } - a := []KeyedTask{ - {ID: "t1", Status: "todo"}, - {ID: "t2", Status: "todo"}, + if rt.Guard == nil { + t.Error("global condition not round-tripped") } - b := []KeyedTask{ - {ID: "t2", Status: "done"}, - {ID: "t1", Status: "todo"}, + if rt.Guard != nil && rt.Guard.Op != ">" { + t.Errorf("global condition op: got %q, want \">\"", rt.Guard.Op) } +} - patch := MustDiff(a, b) - - ops := make(map[string]string) - err := patch.Walk(func(path string, op OpKind, old, new any) error { - ops[path] = fmt.Sprintf("%s:%v:%v", op, old, new) - return nil - }) +func TestGeLeConditions(t *testing.T) { + type S struct{ X int } + xPath := deep.Field[S, int](func(s *S) *int { return &s.X }) - if err != nil { - t.Fatalf("Walk failed: %v", err) + s := S{X: 5} + if err := deep.Apply(&s, deep.Edit(&s).With(deep.Set(xPath, 10).Unless(deep.Ge(xPath, 5))).Build()); err != nil { + t.Fatal(err) } - - if len(ops) == 0 { - t.Errorf("Expected some ops, got none") + // Ge(X, 5) is true when X==5, so Unless fires and op is skipped → X stays 5. + if s.X != 5 { + t.Errorf("Ge condition: got %d, want 5", s.X) } -} - -func TestPatch_Walk_ErrorStop(t *testing.T) { - a := map[string]int{"one": 1, "two": 2} - b := map[string]int{"one": 10, "two": 20} - patch := MustDiff(a, b) - count := 0 - err := patch.Walk(func(path string, op OpKind, old, new any) error { - count++ - return fmt.Errorf("stop") - }) - - if err == nil || err.Error() != "stop" { - t.Errorf("Expected 'stop' error, got %v", err) + if err := deep.Apply(&s, deep.Edit(&s).With(deep.Set(xPath, 10).Unless(deep.Le(xPath, 4))).Build()); err != nil { + t.Fatal(err) } - if count != 1 { - t.Errorf("Expected walk to stop after 1 call, got %d", count) + // Le(X, 4) is false when X==5, so Unless does not fire → X becomes 10. + if s.X != 10 { + t.Errorf("Le condition: got %d, want 10", s.X) } } -type customTestStruct struct { - V int -} - -func TestCustomDiffPatch_ToJSONPatch(t *testing.T) { - builder := NewPatchBuilder[customTestStruct]() - builder.Field("V").Set(1, 2) - patch, _ := builder.Build() - - // Manually wrap it in customDiffPatch - custom := &customDiffPatch{ - patch: patch, +func TestBuilderMoveCopy(t *testing.T) { + type S struct { + A string `json:"a"` + B string `json:"b"` } + aPath := deep.Field[S, string](func(s *S) *string { return &s.A }) + bPath := deep.Field[S, string](func(s *S) *string { return &s.B }) - jsonBytes := custom.toJSONPatch("") // Use empty prefix for root - - var ops []map[string]any - data, _ := json.Marshal(jsonBytes) - json.Unmarshal(data, &ops) - - if len(ops) != 1 { - t.Fatalf("expected 1 op, got %d", len(ops)) + p := deep.Edit(&S{}).With(deep.Move(aPath, bPath)).Build() + if len(p.Operations) != 1 || p.Operations[0].Kind != deep.OpMove { + t.Error("Move not added correctly") } - - if ops[0]["path"] != "/V" { - t.Errorf("expected path /V, got %s", ops[0]["path"]) + if p.Operations[0].Old != aPath.String() || p.Operations[0].Path != bPath.String() { + t.Errorf("Move paths wrong: from=%v to=%v", p.Operations[0].Old, p.Operations[0].Path) } -} -func TestPatch_Summary(t *testing.T) { - type Config struct { - Name string - Value int - Options []string + p2 := deep.Edit(&S{}).With(deep.Copy(aPath, bPath)).Build() + if len(p2.Operations) != 1 || p2.Operations[0].Kind != deep.OpCopy { + t.Error("Copy not added correctly") } +} - c1 := Config{Name: "v1", Value: 10, Options: []string{"a", "b"}} - c2 := Config{Name: "v2", Value: 20, Options: []string{"a", "c"}} +func TestLWWSet(t *testing.T) { + clock := hlc.NewClock("test") + ts1 := clock.Now() + ts2 := clock.Now() - patch := MustDiff(c1, c2) - if patch == nil { - t.Fatal("Expected patch") + var reg crdt.LWW[string] + if reg.Set("first", ts1); reg.Value != "first" { + t.Error("LWW.Set should accept first value") } - - summary := patch.Summary() - if summary == "" || summary == "No changes." { - t.Errorf("Unexpected summary: %q", summary) + if reg.Set("second", ts2); reg.Value != "second" { + t.Error("LWW.Set should accept newer timestamp") + } + if accepted := reg.Set("old", ts1); accepted || reg.Value != "second" { + t.Error("LWW.Set should reject older timestamp") } } diff --git a/patch_utils.go b/patch_utils.go deleted file mode 100644 index 98b798b..0000000 --- a/patch_utils.go +++ /dev/null @@ -1,75 +0,0 @@ -package deep - -import ( - "fmt" - - "github.com/brunoga/deep/v4/internal/core" -) - -// applyToBuilder recursively applies an operation to a PatchBuilder. -// This is used by patch.Reverse and patch.Merge to construct new patches. -func applyToBuilder[T any](b *PatchBuilder[T], op OpInfo) error { - // Apply conditions if present. - if op.Conditions != nil { - // Placeholder for condition re-attachment - } - - switch op.Kind { - case OpReplace: - b.Navigate(op.Path).Put(op.Val) - - case OpAdd: - parentPath, lastPart, err := core.DeepPath(op.Path).ResolveParentPath() - if err != nil { - return fmt.Errorf("invalid path for Add %s: %w", op.Path, err) - } - - node := b.Navigate(string(parentPath)) - if lastPart.IsIndex { - node.Add(lastPart.Index, op.Val) - } else { - node.Add(lastPart.Key, op.Val) - } - - case OpRemove: - parentPath, lastPart, err := core.DeepPath(op.Path).ResolveParentPath() - if err != nil { - return fmt.Errorf("invalid path for Remove %s: %w", op.Path, err) - } - - node := b.Navigate(string(parentPath)) - if lastPart.IsIndex { - node.Delete(lastPart.Index, op.Val) - } else { - node.Delete(lastPart.Key, op.Val) - } - - case OpMove: - b.Navigate(op.Path).Move(op.From) - - case OpCopy: - b.Navigate(op.Path).Copy(op.From) - - case OpTest: - b.Navigate(op.Path).Test(op.Val) - - case OpLog: - if msg, ok := op.Val.(string); ok { - b.Navigate(op.Path).Log(msg) - } - } - - if b.state.err != nil { - return b.state.err - } - return nil -} - -// OpInfo represents a flattened operation from a patch. -type OpInfo struct { - Kind OpKind - Path string - From string // For Move/Copy - Val any - Conditions any // Placeholder -} diff --git a/patch_utils_test.go b/patch_utils_test.go deleted file mode 100644 index a1edfd9..0000000 --- a/patch_utils_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package deep - -import ( - "reflect" - "testing" - - "github.com/brunoga/deep/v4/internal/core" -) - -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 := core.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) - } - } -} diff --git a/resolvers/crdt/lww.go b/resolvers/crdt/lww.go deleted file mode 100644 index 0bed4e4..0000000 --- a/resolvers/crdt/lww.go +++ /dev/null @@ -1,73 +0,0 @@ -package crdt - -import ( - "reflect" - - "github.com/brunoga/deep/v4" - "github.com/brunoga/deep/v4/crdt/hlc" -) - -// LWWResolver implements deep.ConflictResolver using Last-Write-Wins logic -// for a single operation or delta with a fixed timestamp. -type LWWResolver struct { - Clocks map[string]hlc.HLC - Tombstones map[string]hlc.HLC - OpTime hlc.HLC -} - -func (r *LWWResolver) Resolve(path string, op deep.OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) { - lClock := r.Clocks[path] - lTomb, hasLT := r.Tombstones[path] - lTime := lClock - if hasLT && lTomb.After(lTime) { - lTime = lTomb - } - - if !r.OpTime.After(lTime) { - return reflect.Value{}, false - } - - // Accepted. Update clocks for this path. - if op == deep.OpRemove { - r.Tombstones[path] = r.OpTime - } else { - r.Clocks[path] = r.OpTime - } - - return proposed, true -} - -// StateResolver implements deep.ConflictResolver for merging two full CRDT states. -// It compares clocks for each path dynamically. -type StateResolver struct { - LocalClocks map[string]hlc.HLC - LocalTombstones map[string]hlc.HLC - RemoteClocks map[string]hlc.HLC - RemoteTombstones map[string]hlc.HLC -} - -func (r *StateResolver) Resolve(path string, op deep.OpKind, key, prevKey any, current, proposed reflect.Value) (reflect.Value, bool) { - // Local Time - lClock := r.LocalClocks[path] - lTomb, hasLT := r.LocalTombstones[path] - lTime := lClock - if hasLT && lTomb.After(lTime) { - lTime = lTomb - } - - // Remote Time - rClock, hasR := r.RemoteClocks[path] - rTomb, hasRT := r.RemoteTombstones[path] - if !hasR && !hasRT { - return reflect.Value{}, false - } - rTime := rClock - if hasRT && rTomb.After(rTime) { - rTime = rTomb - } - - if rTime.After(lTime) { - return proposed, true - } - return reflect.Value{}, false -} diff --git a/resolvers/crdt/lww_test.go b/resolvers/crdt/lww_test.go deleted file mode 100644 index 2735911..0000000 --- a/resolvers/crdt/lww_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package crdt - -import ( - "reflect" - "testing" - - "github.com/brunoga/deep/v4" - "github.com/brunoga/deep/v4/crdt/hlc" -) - -func TestLWWResolver(t *testing.T) { - clocks := map[string]hlc.HLC{ - "f1": {WallTime: 100, Logical: 0, NodeID: "A"}, - } - tombstones := make(map[string]hlc.HLC) - - resolver := &LWWResolver{ - Clocks: clocks, - Tombstones: tombstones, - OpTime: hlc.HLC{WallTime: 101, Logical: 0, NodeID: "B"}, - } - - proposed := reflect.ValueOf("new") - - // Newer op should be accepted - resolved, ok := resolver.Resolve("f1", deep.OpReplace, nil, nil, reflect.Value{}, proposed) - if !ok { - t.Error("Should accept newer operation") - } - if resolved.Interface() != "new" { - t.Error("Resolved value mismatch") - } - if clocks["f1"].WallTime != 101 { - t.Error("Clock should have been updated") - } - - // Older op should be rejected - resolver.OpTime = hlc.HLC{WallTime: 99, Logical: 0, NodeID: "C"} - _, ok = resolver.Resolve("f1", deep.OpReplace, nil, nil, reflect.Value{}, proposed) - if ok { - t.Error("Should reject older operation") - } -} - -func TestStateResolver(t *testing.T) { - localClocks := map[string]hlc.HLC{"f1": {WallTime: 100, Logical: 0, NodeID: "A"}} - remoteClocks := map[string]hlc.HLC{"f1": {WallTime: 101, Logical: 0, NodeID: "B"}} - - resolver := &StateResolver{ - LocalClocks: localClocks, - RemoteClocks: remoteClocks, - } - - proposed := reflect.ValueOf("remote") - - resolved, ok := resolver.Resolve("f1", deep.OpReplace, nil, nil, reflect.Value{}, proposed) - if !ok { - t.Error("Remote should win (newer)") - } - if resolved.Interface() != "remote" { - t.Error("Resolved value mismatch") - } - - resolver.RemoteClocks["f1"] = hlc.HLC{WallTime: 99, Logical: 0, NodeID: "B"} - _, ok = resolver.Resolve("f1", deep.OpReplace, nil, nil, reflect.Value{}, proposed) - if ok { - t.Error("Local should win (remote is older)") - } -} diff --git a/selector.go b/selector.go new file mode 100644 index 0000000..b59fbee --- /dev/null +++ b/selector.go @@ -0,0 +1,167 @@ +package deep + +import ( + "fmt" + "reflect" + "strings" + "sync" +) + +// selector is a function that retrieves a field from a struct of type T. +type selector[T, V any] func(*T) *V + +// Path represents a type-safe path to a field of type V within type T. +type Path[T, V any] struct { + sel selector[T, V] + path string +} + +// String returns the string representation of the path. +// Paths built from a selector resolve lazily; the result is cached per selector +// function so repeated calls are O(1) after the first. +func (p Path[T, V]) String() string { + if p.path != "" { + return p.path + } + if p.sel != nil { + return resolvePathInternal(p.sel) + } + return "" +} + +// Field creates a new type-safe path from a selector function. +func Field[T, V any](s func(*T) *V) Path[T, V] { + return Path[T, V]{sel: selector[T, V](s)} +} + +// At returns a type-safe path to the element at index i within a slice field. +func At[T any, S ~[]E, E any](p Path[T, S], i int) Path[T, E] { + return Path[T, E]{path: fmt.Sprintf("%s/%d", p.String(), i)} +} + +// MapKey returns a type-safe path to the value at key k within a map field. +func MapKey[T any, M ~map[K]V, K comparable, V any](p Path[T, M], k K) Path[T, V] { + return Path[T, V]{path: fmt.Sprintf("%s/%v", p.String(), k)} +} + +// pathCache stores resolved paths keyed by selector function pointer. +var pathCache sync.Map // map[uintptr]string + +func resolvePathInternal[T, V any](s selector[T, V]) string { + key := reflect.ValueOf(s).Pointer() + if cached, ok := pathCache.Load(key); ok { + return cached.(string) + } + + var zero T + typ := reflect.TypeOf(zero) + + if typ.Kind() != reflect.Struct { + pathCache.Store(key, "") + return "" + } + + base := reflect.New(typ).Elem() + initializePointers(base, make(map[reflect.Type]bool)) + + targetPtr := s(base.Addr().Interface().(*T)) + if targetPtr == nil { + panic(fmt.Sprintf("deep.Field: selector returned nil — use &u.Field, not u.Field (type %T)", (*T)(nil))) + } + + targetAddr := reflect.ValueOf(targetPtr).Pointer() + targetTyp := reflect.TypeOf(targetPtr).Elem() + + path := findPathByAddr(base, targetAddr, targetTyp, "", make(map[uintptr]bool)) + if path == "" { + // Fallback: maybe it's the root itself? + if targetAddr == base.Addr().Pointer() { + path = "/" + } + } + + pathCache.Store(key, path) + return path +} + +// initializePointers allocates nil pointer fields so selectors can safely +// dereference them. inProgress prevents infinite recursion for self-referential +// types (e.g. linked lists). +func initializePointers(v reflect.Value, inProgress map[reflect.Type]bool) { + if v.Kind() != reflect.Struct { + return + } + typ := v.Type() + if inProgress[typ] { + return + } + inProgress[typ] = true + defer delete(inProgress, typ) + + for i := 0; i < v.NumField(); i++ { + f := v.Field(i) + if f.Kind() == reflect.Ptr { + if f.IsNil() && f.CanSet() { + f.Set(reflect.New(f.Type().Elem())) + } + initializePointers(f.Elem(), inProgress) + } else if f.Kind() == reflect.Struct { + initializePointers(f, inProgress) + } + } +} + +// findPathByAddr walks v looking for the field at targetAddr with type +// targetTyp. visited tracks struct addresses already on the walk stack to +// prevent infinite recursion through circular pointer structures. +func findPathByAddr(v reflect.Value, targetAddr uintptr, targetTyp reflect.Type, prefix string, visited map[uintptr]bool) string { + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return "" + } + addr := v.Pointer() + if visited[addr] { + return "" + } + visited[addr] = true + defer delete(visited, addr) + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return "" + } + + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + f := v.Field(i) + + name := field.Name + if tag := field.Tag.Get("json"); tag != "" { + name = strings.Split(tag, ",")[0] + if name == "-" { + continue + } + } + + fieldPath := prefix + "/" + name + + // Recurse first to find the most specific match (handles overlapping + // addresses, e.g. a struct and its first field share the same address). + if f.Kind() == reflect.Struct { + if p := findPathByAddr(f, targetAddr, targetTyp, fieldPath, visited); p != "" { + return p + } + } else if f.Kind() == reflect.Ptr && !f.IsNil() && f.Elem().Kind() == reflect.Struct { + if p := findPathByAddr(f, targetAddr, targetTyp, fieldPath, visited); p != "" { + return p + } + } + + // Check this field after recursing so deeper matches win. + if f.Addr().Pointer() == targetAddr && f.Type() == targetTyp { + return fieldPath + } + } + return "" +} diff --git a/selector_test.go b/selector_test.go new file mode 100644 index 0000000..beb9291 --- /dev/null +++ b/selector_test.go @@ -0,0 +1,89 @@ +package deep_test + +import ( + "testing" + + "github.com/brunoga/deep/v5" + "github.com/brunoga/deep/v5/internal/testmodels" +) + +func TestSelector(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + "simple field", + deep.Field(func(u *testmodels.User) *int { return &u.ID }).String(), + "/id", + }, + { + "nested field", + deep.Field(func(u *testmodels.User) *int { return &u.Info.Age }).String(), + "/info/Age", + }, + { + "slice index", + deep.At(deep.Field(func(u *testmodels.User) *[]string { return &u.Roles }), 1).String(), + "/roles/1", + }, + { + "map key", + deep.MapKey(deep.Field(func(u *testmodels.User) *map[string]int { return &u.Score }), "alice").String(), + "/score/alice", + }, + { + "unexported field", + deep.Field((*testmodels.User).AgePtr).String(), + "/age", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.path != tt.want { + t.Errorf("Path() = %v, want %v", tt.path, tt.want) + } + }) + } +} + +func TestSelectorNestedPointer(t *testing.T) { + type NestedPointer struct { + Inner *struct { + Value string + } `json:"inner"` + } + + path := deep.Field(func(n *NestedPointer) *string { return &n.Inner.Value }) + got := path.String() + want := "/inner/Value" + if got != want { + t.Errorf("path.String() = %q, want %q", got, want) + } +} + +// TestSelectorCircularType verifies that self-referential struct types do not +// cause infinite recursion during path resolution. +func TestSelectorCircularType(t *testing.T) { + type Node struct { + Value int `json:"value"` + Next *Node `json:"next"` + } + + path := deep.Field(func(n *Node) *int { return &n.Value }) + got := path.String() + want := "/value" + if got != want { + t.Errorf("path.String() = %q, want %q", got, want) + } + + // Selecting through the pointer field should also work (one level deep). + path2 := deep.Field(func(n *Node) *int { return &n.Next.Value }) + got2 := path2.String() + want2 := "/next/value" + if got2 != want2 { + t.Errorf("path.String() = %q, want %q", got2, want2) + } +}