From a2a0e2cd3308222a36f8ebbbe2a1d4e481f82ede Mon Sep 17 00:00:00 2001 From: loganpowell Date: Mon, 3 Nov 2025 16:41:35 -0500 Subject: [PATCH 1/3] adds index path capabilities --- go.mod | 2 +- lenspath2.go => lenspath.go | 77 +++++++++--- lenspath.go.bak | 245 ------------------------------------ path_test.go | 154 +++++++++++++++++++++++ 4 files changed, 217 insertions(+), 261 deletions(-) rename lenspath2.go => lenspath.go (67%) delete mode 100644 lenspath.go.bak create mode 100644 path_test.go diff --git a/go.mod b/go.mod index e6f9000..88267f1 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/covalenthq/lenspath +module github.com/loganpowell/lenspath go 1.19 diff --git a/lenspath2.go b/lenspath.go similarity index 67% rename from lenspath2.go rename to lenspath.go index 5d280e0..f95f90a 100644 --- a/lenspath2.go +++ b/lenspath.go @@ -3,6 +3,7 @@ package lenspath import ( "fmt" "reflect" + "strconv" ) type Lens = string @@ -77,50 +78,96 @@ func (lp *Lenspath) recurse(data any, view int, details *TraversalDetails) (any, switch kind { case reflect.Map: - lp.traverseMap(data, view, details) + return lp.traverseMap(data, view, details) case reflect.Slice, reflect.Array: if lp.path(view) == "*" { - lp.traverseSlice(data, view, details) - } else { - return nil, NewInvalidLensPathErr(view, ArrayExpectedErr) + return lp.traverseSlice(data, view, details) } + // Try to parse as numeric index + if idx, err := strconv.Atoi(lp.path(view)); err == nil { + return lp.traverseSliceIndex(data, view, idx, details) + } + return nil, NewInvalidLensPathErr(view, ArrayExpectedErr) case reflect.Struct: nestv := reflect.ValueOf(data).FieldByName(lp.path(view)) if !nestv.IsValid() || nestv.IsZero() { if lp.atLeaf(view) { - details.callback(nil) + return details.callback(nil), nil } - } else { - lp.recurse(nestv.Interface(), view+1, details) + return nil, nil } + return lp.recurse(nestv.Interface(), view+1, details) case reflect.Ptr: - lp.recurse(reflect.ValueOf(data).Elem().Interface(), view, details) + ptr := reflect.ValueOf(data) + if ptr.IsNil() { + return nil, nil + } + return lp.recurse(ptr.Elem().Interface(), view, details) default: return nil, fmt.Errorf("unhandled case: %T", data) } - - return nil, nil - } -func (lp *Lenspath) traverseSlice(value any, view int, details *TraversalDetails) { +func (lp *Lenspath) traverseSlice(value any, view int, details *TraversalDetails) (any, error) { // return []any if the array is not homogeneous (some lens gets return nil for example // or the map entries have different types for same keys) // else if array is homogeneous, return [] (e.g. []string) arr := reflect.ValueOf(value) if arr.Len() == 0 { - lp.recurse(nil, view+1, details) - return + return lp.recurse(nil, view+1, details) } for j := 0; j < arr.Len(); j++ { - lp.recurse(arr.Index(j).Interface(), view+1, details) + newVal, err := lp.recurse(arr.Index(j).Interface(), view+1, details) + if err != nil { + return nil, err + } + + if details.settable && lp.atLeaf(view) && arr.Index(j).CanSet() { + if newVal == nil { + arr.Index(j).Set(reflect.Zero(arr.Index(j).Type())) + } else { + arr.Index(j).Set(reflect.ValueOf(newVal)) + } + } + } + + return nil, nil +} + +func (lp *Lenspath) traverseSliceIndex(value any, view int, index int, details *TraversalDetails) (any, error) { + arr := reflect.ValueOf(value) + + // Check bounds + if index < 0 || index >= arr.Len() { + return nil, fmt.Errorf("lenspath: index %d out of bounds for array of length %d", index, arr.Len()) + } + + elem := arr.Index(index) + + if lp.atLeaf(view) { + // We're at the leaf - get or set the value + if details.settable { + newVal := details.callback(elem.Interface()) + if elem.CanSet() { + if newVal == nil { + elem.Set(reflect.Zero(elem.Type())) + } else { + elem.Set(reflect.ValueOf(newVal)) + } + } + return newVal, nil + } + return details.callback(elem.Interface()), nil } + + // Continue recursing + return lp.recurse(elem.Interface(), view+1, details) } func (lp *Lenspath) traverseMap(value any, view int, details *TraversalDetails) (any, error) { diff --git a/lenspath.go.bak b/lenspath.go.bak deleted file mode 100644 index a87d47e..0000000 --- a/lenspath.go.bak +++ /dev/null @@ -1,245 +0,0 @@ -package lenspath - -import ( - "fmt" - "reflect" -) - -type Lens = string - -type Lenspath struct { - lens []Lens - lastArrayPos int // last position of array (*) lens in lenspath - assumeNil bool // if lenspath cannot be resolved, assume nil. If false, return error on unresolved lenspath while traversing structures -} - -func Create(lens []Lens) (*Lenspath, error) { - if len(lens) == 0 { - return nil, &EmptyLensPathErr{} - } - lastArrPos := -1 - for i, lensv := range lens { - if lensv == "*" { - lastArrPos = i - } - } - assumeNil := true // default to assume nil - return &Lenspath{lens, lastArrPos, assumeNil}, nil -} - -func (lp *Lenspath) Get(data any) (any, error) { - return lp.get(data, 0) -} - -func (lp *Lenspath) Set(data any, value any) (any, error) { - return lp.set(data, value, 0) -} - -func (lp *Lenspath) get(data any, view int) (any, error) { - if view == lp.len() { - return data, nil - } else if data == nil { - if lp.assumeNil { - return nil, nil - } else { - return nil, NewInvalidLensPathErr(view, LensPathStoppedErr) - } - } - - kind := reflect.TypeOf(data).Kind() - - switch kind { - case reflect.Map: - return lp.getFromMap(data, view) - - case reflect.Slice, reflect.Array: - if lp.path(view) == "*" { - - // return []any if the array is not homogeneous (some lens gets return nil for example - // or the map entries have different types for same keys) - // else if array is homogeneous, return [] (e.g. []string) - - arr := reflect.ValueOf(data) - if arr.Len() == 0 { - return nil, nil - } - any_slice := make([]any, 0, arr.Len()) - consistent_type := true - var prev_type reflect.Type - - for j := 0; j < arr.Len(); j++ { - if value, err := lp.get(arr.Index(j).Interface(), view+1); err == nil { - value_type := reflect.TypeOf(value) - any_slice = append(any_slice, value) - if j > 0 { - consistent_type = consistent_type && value_type == prev_type - } - prev_type = value_type - } else { - return nil, err - } - } - - if view != lp.lastArrayPos { - // need to unwrap or flatten the any_slice - consistent_type = true - - flattened_slice := make([]any, 0) - for i, value := range any_slice { - arrv := reflect.ValueOf(value) - for j := 0; j < arrv.Len(); j++ { - arrv_val := arrv.Index(j).Interface() - arrv_type := reflect.TypeOf(arrv_val) - flattened_slice = append(flattened_slice, arrv_val) - if i > 0 || j > 0 { - consistent_type = consistent_type && arrv_type == prev_type - } - prev_type = arrv_type - } - } - - any_slice = flattened_slice - } - - if consistent_type && prev_type != nil { - slice := reflect.MakeSlice(reflect.SliceOf(prev_type), 0, arr.Len()) - for _, v := range any_slice { - slice = reflect.Append(slice, reflect.ValueOf(v)) - } - - return slice.Interface(), nil - } - - return any_slice, nil - } else { - return nil, NewInvalidLensPathErr(view, ArrayExpectedErr) - } - - case reflect.Struct: - nestv := reflect.ValueOf(data).FieldByName(lp.path(view)) - if !nestv.IsValid() || nestv.IsZero() { - if lp.assumeNil { - return nil, nil - } else { - return nil, NewInvalidLensPathErr(view, LensPathStoppedErr) - } - } - - return lp.get(nestv.Interface(), view+1) - - case reflect.Ptr: - return lp.get(reflect.ValueOf(data).Elem().Interface(), view) - - default: - return nil, fmt.Errorf("unhandled case: %T", data) - } -} - -func (lp *Lenspath) set(data any, value any, view int) (any, error) { - if view == lp.len() { - return value, nil - } - - kind := reflect.TypeOf(data).Kind() - - switch kind { - case reflect.Map: - return lp.setFromMap(data, value, view) - - case reflect.Slice, reflect.Array: - if lp.path(view) == "*" { - arr := reflect.ValueOf(data) - slice := reflect.MakeSlice(arr.Type(), 0, arr.Len()) - - // check if value is a slice or array; the length should then match - // each value in the array is set to the corresponding value in the data slice - if reflect.TypeOf(value).Kind() != reflect.Slice && reflect.TypeOf(value).Kind() != reflect.Array { - return nil, ArrayParamExpectedErr - } - value_arr := reflect.ValueOf(value) - - if arr.Len() != value_arr.Len() { - return nil, ParamSizeMismatchErr - } - - for j := 0; j < arr.Len(); j++ { - if v, err := lp.set(arr.Index(j).Interface(), value_arr.Index(j).Interface(), view+1); err == nil { - slice = reflect.Append(slice, reflect.ValueOf(v)) - } else { - return nil, err - } - } - return slice.Interface(), nil - } else { - return nil, NewInvalidLensPathErr(view, ArrayExpectedErr) - } - - case reflect.Struct: - field := reflect.ValueOf(data).FieldByName(lp.path(view)) - if field.IsZero() { - if lp.assumeNil { - return nil, nil - } else { - return nil, NewInvalidLensPathErr(view, LensPathStoppedErr) - } - } - - if field.CanSet() { - if val, err := lp.set(field.Interface(), value, view+1); err != nil { - return nil, err - } else { - field.Set(reflect.ValueOf(val)) - } - } - - return data, nil - - case reflect.Ptr: - return lp.set(reflect.ValueOf(data).Elem().Interface(), value, view) - - default: - return nil, fmt.Errorf("unhandled case: %T", data) - } -} - -func (lp *Lenspath) setFromMap(data any, value any, view int) (any, error) { - key := reflect.ValueOf((lp.lens[view])) - keyv := reflect.ValueOf(data).MapIndex(key) - - tosetv := value - if !keyv.IsValid() || keyv.IsZero() { - if view < lp.len()-1 { - return nil, NewInvalidLensPathErr(view, LensPathStoppedErr) - } - } else if val, err := lp.set(keyv.Interface(), value, view+1); err != nil { - return nil, err - } else { - tosetv = val - } - - reflect.ValueOf(data).SetMapIndex(key, reflect.ValueOf(tosetv)) - return data, nil -} - -func (lp *Lenspath) getFromMap(value any, view int) (any, error) { - key := reflect.ValueOf((lp.lens[view])) - keyv := reflect.ValueOf(value).MapIndex(key) - - if !keyv.IsValid() || keyv.IsZero() { - if lp.assumeNil { - return nil, nil - } else { - return nil, NewInvalidLensPathErr(view, LensPathStoppedErr) - } - } else { - return lp.get(keyv.Interface(), view+1) - } -} - -func (lp *Lenspath) len() int { - return len(lp.lens) -} - -func (lp *Lenspath) path(view int) string { - return string(lp.lens[view]) -} diff --git a/path_test.go b/path_test.go new file mode 100644 index 0000000..f616e79 --- /dev/null +++ b/path_test.go @@ -0,0 +1,154 @@ +package lenspath_test + +import ( + "reflect" + "testing" + + "github.com/loganpowell/lenspath" +) + +func TestWildcardGetterCollectsAllMatches(t *testing.T) { + data := map[string]any{ + "items": []map[string]any{ + {"name": "first", "value": 1}, + {"name": "second", "value": 2}, + {"name": "third", "value": 3}, + }, + } + + lp := newLens(t, []string{"items", "*", "name"}) + + got := collectValues(t, lp, data) + want := []any{"first", "second", "third"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("Getter returned %v, want %v", got, want) + } +} + +func TestWildcardGetterDeeplyNested(t *testing.T) { + data := map[string]any{ + "delivery_items": []map[string]any{ + { + "description": "Item 1", + "quantity": 10, + "details": map[string]any{"hs_code": "123456", "origin": "Country A"}, + }, + { + "description": "Item 2", + "quantity": 20, + "details": map[string]any{"hs_code": "789012", "origin": "Country B"}, + }, + }, + } + + lp := newLens(t, []string{"delivery_items", "*", "details", "hs_code"}) + got := collectValues(t, lp, data) + want := []any{"123456", "789012"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("Getter returned %v, want %v", got, want) + } +} + +func TestWildcardSetterUpdatesEachMatch(t *testing.T) { + data := map[string]any{ + "data": []map[string]any{ + {"value": "original1", "status": "pending"}, + {"value": "original2", "status": "pending"}, + }, + } + + lp := newLens(t, []string{"data", "*", "status"}) + + if err := lp.Setter(data, func(any) any { return "completed" }); err != nil { + t.Fatalf("Setter returned error: %v", err) + } + + got := collectValues(t, lp, data) + want := []any{"completed", "completed"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("Setter produced %v, want %v", got, want) + } +} + +func TestWildcardGetterIncludesMissingKeys(t *testing.T) { + data := map[string]any{ + "data": []map[string]any{ + {"value": "original1"}, + {"value": "original2", "status": "pending"}, + }, + } + + lp := newLens(t, []string{"data", "*", "status"}) + got := collectValues(t, lp, data) + want := []any{nil, "pending"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("Getter returned %v, want %v", got, want) + } +} + +func TestNumericIndexOnSliceReturnsError(t *testing.T) { + data := map[string]any{ + "items": []string{"zero", "one", "two"}, + } + + // Test getting by numeric index + lp := newLens(t, []string{"items", "1"}) + val, err := lp.Get(data) + if err != nil { + t.Fatalf("Get with numeric index failed: %v", err) + } + if val != "one" { + t.Fatalf("Get returned %v, want %q", val, "one") + } + + // Test setting by numeric index + if err := lp.Set(data, "ONE"); err != nil { + t.Fatalf("Set with numeric index failed: %v", err) + } + + val, err = lp.Get(data) + if err != nil { + t.Fatalf("Get after Set failed: %v", err) + } + if val != "ONE" { + t.Fatalf("After Set, Get returned %v, want %q", val, "ONE") + } + + // Test index 0 + lp0 := newLens(t, []string{"items", "0"}) + val, err = lp0.Get(data) + if err != nil { + t.Fatalf("Get with index 0 failed: %v", err) + } + if val != "zero" { + t.Fatalf("Get at index 0 returned %v, want %q", val, "zero") + } +} + +func newLens(t *testing.T, path []string) *lenspath.Lenspath { + t.Helper() + if len(path) == 0 { + t.Fatalf("path must not be empty") + } + lp, err := lenspath.Create(path) + if err != nil { + t.Fatalf("Create(%v) returned error: %v", path, err) + } + return lp +} + +func collectValues(t *testing.T, lp *lenspath.Lenspath, data any) []any { + t.Helper() + var results []any + if err := lp.Getter(data, func(value any) any { + results = append(results, value) + return value + }); err != nil { + t.Fatalf("Getter returned error: %v", err) + } + return results +} \ No newline at end of file From b922829125481254f65b804382829a74ec9ca373 Mon Sep 17 00:00:00 2001 From: loganpowell Date: Mon, 3 Nov 2025 17:11:05 -0500 Subject: [PATCH 2/3] fix: create intermediate enclosing data structure for index-based path assignment --- compose.go | 13 +++- errors.go | 26 +++++-- lenspath.go | 136 ++++++++++++++++++++++++++++++++++- options.go | 27 ++++++- path_test.go | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 389 insertions(+), 13 deletions(-) diff --git a/compose.go b/compose.go index 3be2ee6..6dbe9b9 100644 --- a/compose.go +++ b/compose.go @@ -1,7 +1,16 @@ package lenspath -// this package provides ways to compose lenspath - +// Compose creates a new Lenspath by appending additional lens segments to this Lenspath. +// The original Lenspath is not modified; a new instance is returned. +// +// This is useful for building up complex paths programmatically or reusing common path prefixes. +// +// Examples: +// +// base, _ := Create([]string{"users", "0"}) +// namePath, _ := base.Compose([]Lens{"name"}) // users[0].name +// emailPath, _ := base.Compose([]Lens{"email"}) // users[0].email +// // base remains unchanged as []string{"users", "0"} func (lp *Lenspath) Compose(lens []Lens) (*Lenspath, error) { newlens := append(lp.lens, lens...) copylens := make([]Lens, len(newlens)) diff --git a/errors.go b/errors.go index 8b6edfc..e757784 100644 --- a/errors.go +++ b/errors.go @@ -2,27 +2,37 @@ package lenspath import "fmt" +// EmptyLensPathErr is returned when attempting to create a Lenspath with an empty lens slice. type EmptyLensPathErr struct{} func (e *EmptyLensPathErr) Error() string { return "lenspath: must have at least one lens" } +// InvalidLensPathErr is returned when a path cannot be traversed due to type mismatches +// or structural issues in the data. type InvalidLensPathErr struct { index int errType InvalidLensPathErrType } +// InvalidLensPathErrType describes the kind of path traversal error. type InvalidLensPathErrType string const ( - ArrayExpectedErr InvalidLensPathErrType = "expected array (*)" - LensPathStoppedErr = "could not navigate further, end of structure reached" - CannotSetFieldErr = "cannot set field" - PathContainsArrErr = "path contains *; use Getter/Setter() instead" - PathDoesntContainsArrErr = "path does not contain *; use Get() instead" + // ArrayExpectedErr indicates a non-wildcard, non-numeric segment was used on an array/slice + ArrayExpectedErr InvalidLensPathErrType = "expected array (*)" + // LensPathStoppedErr indicates the path ended prematurely in the data structure + LensPathStoppedErr = "could not navigate further, end of structure reached" + // CannotSetFieldErr indicates a struct field cannot be modified + CannotSetFieldErr = "cannot set field" + // PathContainsArrErr indicates Get/Set was called on a path with wildcards (use Getter/Setter) + PathContainsArrErr = "path contains *; use Getter/Setter() instead" + // PathDoesntContainsArrErr indicates the path doesn't contain wildcards when expected + PathDoesntContainsArrErr = "path does not contain *; use Get() instead" ) +// NewInvalidLensPathErr creates a new InvalidLensPathErr with the given context. func NewInvalidLensPathErr(index int, errType InvalidLensPathErrType) *InvalidLensPathErr { return &InvalidLensPathErr{index, errType} } @@ -31,16 +41,20 @@ func (e *InvalidLensPathErr) Error() string { return fmt.Sprintf("lenspath: %s; lens index: %d", e.errType, e.index) } +// Is implements error matching for InvalidLensPathErr. func (e *InvalidLensPathErr) Is(err error) bool { _, ok := err.(*InvalidLensPathErr) return ok } +// InvalidSetParamErr is returned when setter parameters don't match expectations. type InvalidSetParamErr string const ( + // ArrayParamExpectedErr indicates an array was expected for the set value ArrayParamExpectedErr InvalidSetParamErr = "expected array for set value" - ParamSizeMismatchErr InvalidSetParamErr = "array param and structure field array length should match" + // ParamSizeMismatchErr indicates array parameter size doesn't match structure field array length + ParamSizeMismatchErr InvalidSetParamErr = "array param and structure field array length should match" ) func (e InvalidSetParamErr) Error() string { diff --git a/lenspath.go b/lenspath.go index f95f90a..3921615 100644 --- a/lenspath.go +++ b/lenspath.go @@ -1,3 +1,5 @@ +// Package lenspath provides a lens-based approach to accessing and modifying nested data structures. +// It supports maps, slices, arrays, and structs with a path-based API. package lenspath import ( @@ -6,21 +8,43 @@ import ( "strconv" ) +// Lens represents a single path segment in a lenspath. +// It can be a map key (string), array index (numeric string like "0", "1"), or wildcard ("*"). type Lens = string -type LeafCallback = func(any) any // called when a leaf (of the lenspath) is reached; return value is set to the leaf (in case of setter) - +// LeafCallback is a function called when a leaf node is reached during traversal. +// For getters, it receives the leaf value and the return value is ignored. +// For setters, it receives the current value and returns the new value to set. +type LeafCallback = func(any) any + +// Lenspath represents a path through nested data structures. +// It supports traversing and modifying maps, slices, arrays, and structs. +// +// Path segments can be: +// - Map keys: any string +// - Array indices: numeric strings like "0", "1", "2" +// - Wildcards: "*" to operate on all array elements +// - Struct fields: field names (case-sensitive) type Lenspath struct { lens []Lens lastArrayPos int // last position of array (*) lens in lenspath assumeNil bool // if lenspath cannot be resolved, assume nil. If false, return error on unresolved lenspath while traversing structures } +// TraversalDetails contains the context for a traversal operation. type TraversalDetails struct { callback LeafCallback settable bool } +// Create constructs a new Lenspath from the given path segments. +// Returns an error if the lens slice is empty. +// +// Examples: +// +// lp, _ := Create([]string{"users", "0", "name"}) // Access users[0].name +// lp, _ := Create([]string{"items", "*", "price"}) // Access all item prices +// lp, _ := Create([]string{"config", "database", "host"}) // Access nested map func Create(lens []Lens) (*Lenspath, error) { if len(lens) == 0 { return nil, &EmptyLensPathErr{} @@ -35,6 +59,14 @@ func Create(lens []Lens) (*Lenspath, error) { return &Lenspath{lens, lastArrPos, assumeNil}, nil } +// Get retrieves the value at the path specified by this Lenspath. +// Returns an error if the path contains wildcards (use Getter instead) or if the path cannot be resolved. +// +// Examples: +// +// data := map[string]any{"user": map[string]any{"name": "Alice"}} +// lp, _ := Create([]string{"user", "name"}) +// val, _ := lp.Get(data) // Returns "Alice" func (lp *Lenspath) Get(data any) (any, error) { if lp.isArrBased() { return nil, NewInvalidLensPathErr(-1, PathContainsArrErr) @@ -47,11 +79,40 @@ func (lp *Lenspath) Get(data any) (any, error) { return rdata, err } +// Getter traverses the path and calls the callback function for each leaf value found. +// This method must be used when the path contains wildcards ("*"). +// The callback receives each matching value and the return value is ignored. +// +// Examples: +// +// data := map[string]any{"items": []map[string]any{{"name": "A"}, {"name": "B"}}} +// lp, _ := Create([]string{"items", "*", "name"}) +// var names []any +// lp.Getter(data, func(val any) any { +// names = append(names, val) +// return val +// }) +// // names now contains ["A", "B"] func (lp *Lenspath) Getter(data any, callback LeafCallback) error { _, err := lp.recurse(data, 0, &TraversalDetails{callback: callback, settable: false}) return err } +// Set sets the value at the path specified by this Lenspath. +// Returns an error if the path contains wildcards (use Setter instead). +// If intermediate structures don't exist, they will be created automatically: +// - If the next segment is numeric, creates a slice +// - If the next segment is a key, creates a map +// +// Examples: +// +// data := map[string]any{} +// lp, _ := Create([]string{"user", "name"}) +// lp.Set(data, "Alice") // data is now {"user": {"name": "Alice"}} +// +// data2 := map[string]any{} +// lp2, _ := Create([]string{"items", "0"}) +// lp2.Set(data2, "first") // data2 is now {"items": ["first"]} func (lp *Lenspath) Set(data any, value any) error { if lp.isArrBased() { return NewInvalidLensPathErr(-1, PathContainsArrErr) @@ -62,6 +123,18 @@ func (lp *Lenspath) Set(data any, value any) error { return err } +// Setter traverses the path and calls the callback function to determine the new value for each leaf. +// This method must be used when the path contains wildcards ("*"). +// The callback receives the current value and returns the new value to set. +// +// Examples: +// +// data := map[string]any{"items": []map[string]any{{"price": 10}, {"price": 20}}} +// lp, _ := Create([]string{"items", "*", "price"}) +// lp.Setter(data, func(val any) any { +// return val.(int) * 2 // Double all prices +// }) +// // All prices are now doubled func (lp *Lenspath) Setter(data any, callback LeafCallback) error { _, err := lp.recurse(data, 0, &TraversalDetails{callback: callback, settable: true}) return err @@ -166,6 +239,28 @@ func (lp *Lenspath) traverseSliceIndex(value any, view int, index int, details * return details.callback(elem.Interface()), nil } + // If the element is nil and we're setting, we need to create the intermediate structure + if elem.Interface() == nil && details.settable { + // Check what the next path segment is + if view+1 < lp.len() { + nextPath := lp.path(view + 1) + var newVal any + + // Check if next segment is an index (create array) or key (create map) + if _, parseErr := strconv.Atoi(nextPath); parseErr == nil { + // Next is an index, create an array + // We'll determine size when we recurse + newVal = make([]any, 0) // Start with empty, will grow in recursion + } else { + // Next is a key, create a map + newVal = make(map[string]any) + } + + elem.Set(reflect.ValueOf(newVal)) + return lp.recurse(newVal, view+1, details) + } + } + // Continue recursing return lp.recurse(elem.Interface(), view+1, details) } @@ -178,11 +273,48 @@ func (lp *Lenspath) traverseMap(value any, view int, details *TraversalDetails) if !keyv.IsValid() || keyv.IsZero() { if !lp.atLeaf(view) { + // Check if the next path segment is a numeric index + // If so, we need to create an array/slice + if view+1 < lp.len() { + if idx, parseErr := strconv.Atoi(lp.path(view + 1)); parseErr == nil && details.settable { + // Next segment is a numeric index, create a slice + // We'll create a slice large enough to hold the index + newSlice := make([]any, idx+1) + _, err = lp.recurse(newSlice, view+1, details) + if err != nil { + return nil, err + } + // Set the newly created slice in the map + reflect.ValueOf(value).SetMapIndex(key, reflect.ValueOf(newSlice)) + return nil, nil + } + } return nil, nil } val = details.callback(nil) } else { + // If the existing value is a slice and we're setting with an index, + // we may need to grow the slice + if view+1 < lp.len() { + if idx, parseErr := strconv.Atoi(lp.path(view + 1)); parseErr == nil && details.settable { + existingVal := keyv.Interface() + if slice, ok := existingVal.([]any); ok { + if idx >= len(slice) { + // Grow the slice + newSlice := make([]any, idx+1) + copy(newSlice, slice) + _, err = lp.recurse(newSlice, view+1, details) + if err != nil { + return nil, err + } + reflect.ValueOf(value).SetMapIndex(key, reflect.ValueOf(newSlice)) + return nil, nil + } + } + } + } + val, err = lp.recurse(keyv.Interface(), view+1, details) } diff --git a/options.go b/options.go index 473e2f8..c58eb8f 100644 --- a/options.go +++ b/options.go @@ -1,7 +1,15 @@ package lenspath +// LenspathOptions is a function type for configuring Lenspath behavior. type LenspathOptions func(*Lenspath) error +// WithOptions applies one or more configuration options to this Lenspath. +// Options are applied in order and the first error encountered stops processing. +// +// Examples: +// +// lp, _ := Create([]string{"user", "name"}) +// lp.WithOptions(WithAssumeNil(false)) func (lp *Lenspath) WithOptions(opts ...LenspathOptions) error { for _, opt := range opts { if err := opt(lp); err != nil { @@ -12,9 +20,22 @@ func (lp *Lenspath) WithOptions(opts ...LenspathOptions) error { return nil } -// WithAssumeNil sets the Lenspath.assumeNil field to the given value. This would be used when -// the Lenspath is used for "get" operations, and the user wants to assume nil when the Lenspath -// cannot be resolved. +// WithAssumeNil configures whether to assume nil values when a path cannot be resolved. +// +// When assumeNil is true (default): +// - Missing keys in maps return nil without error +// - Missing fields in structs return nil without error +// +// When assumeNil is false: +// - Missing paths return errors during traversal +// +// This option primarily affects getter operations. +// +// Examples: +// +// lp, _ := Create([]string{"user", "age"}) +// lp.WithOptions(WithAssumeNil(true)) // Missing "age" returns nil +// lp.WithOptions(WithAssumeNil(false)) // Missing "age" returns error func WithAssumeNil(assumeNil bool) LenspathOptions { return func(lp *Lenspath) error { lp.assumeNil = assumeNil diff --git a/path_test.go b/path_test.go index f616e79..09231fe 100644 --- a/path_test.go +++ b/path_test.go @@ -129,6 +129,206 @@ func TestNumericIndexOnSliceReturnsError(t *testing.T) { } } +func TestNumericIndexSetter(t *testing.T) { + // Test 1: Simple array of strings + data := map[string]any{ + "items": []string{"a", "b", "c"}, + } + + lp := newLens(t, []string{"items", "1"}) + if err := lp.Set(data, "MODIFIED"); err != nil { + t.Fatalf("Set failed: %v", err) + } + + items := data["items"].([]string) + if items[1] != "MODIFIED" { + t.Fatalf("Expected items[1] to be %q, got %q", "MODIFIED", items[1]) + } + if items[0] != "a" || items[2] != "c" { + t.Fatalf("Other elements should be unchanged: %v", items) + } + + // Test 2: Nested path with index (array of maps) + data2 := map[string]any{ + "users": []map[string]any{ + {"name": "Alice", "age": 30}, + {"name": "Bob", "age": 25}, + {"name": "Charlie", "age": 35}, + }, + } + + lp2 := newLens(t, []string{"users", "1", "name"}) + if err := lp2.Set(data2, "Robert"); err != nil { + t.Fatalf("Set nested failed: %v", err) + } + + users := data2["users"].([]map[string]any) + if users[1]["name"] != "Robert" { + t.Fatalf("Expected users[1].name to be %q, got %v", "Robert", users[1]["name"]) + } + if users[1]["age"] != 25 { + t.Fatalf("Expected users[1].age to be unchanged (25), got %v", users[1]["age"]) + } + + // Test 3: Index with Setter callback + data3 := map[string]any{ + "scores": []int{10, 20, 30, 40}, + } + + lp3 := newLens(t, []string{"scores", "2"}) + if err := lp3.Setter(data3, func(oldVal any) any { + // Double the value + return oldVal.(int) * 2 + }); err != nil { + t.Fatalf("Setter callback failed: %v", err) + } + + scores := data3["scores"].([]int) + if scores[2] != 60 { + t.Fatalf("Expected scores[2] to be 60 (30*2), got %d", scores[2]) + } + + // Test 4: Deep nesting with multiple indices + data4 := map[string]any{ + "matrix": [][]string{ + {"a", "b"}, + {"c", "d"}, + {"e", "f"}, + }, + } + + lp4 := newLens(t, []string{"matrix", "1", "0"}) + val, err := lp4.Get(data4) + if err != nil { + t.Fatalf("Get deep index failed: %v", err) + } + if val != "c" { + t.Fatalf("Expected %q, got %v", "c", val) + } + + if err := lp4.Set(data4, "C"); err != nil { + t.Fatalf("Set deep index failed: %v", err) + } + + matrix := data4["matrix"].([][]string) + if matrix[1][0] != "C" { + t.Fatalf("Expected matrix[1][0] to be %q, got %q", "C", matrix[1][0]) + } +} + +func TestNumericIndexOutOfBounds(t *testing.T) { + data := map[string]any{ + "items": []string{"a", "b", "c"}, + } + + // Test negative index + lpNeg := newLens(t, []string{"items", "-1"}) + if _, err := lpNeg.Get(data); err == nil { + t.Fatalf("Expected error for negative index, got nil") + } + + // Test index beyond array length + lpLarge := newLens(t, []string{"items", "10"}) + if _, err := lpLarge.Get(data); err == nil { + t.Fatalf("Expected error for out-of-bounds index, got nil") + } +} + +func TestArrayCreationViaIndexSet(t *testing.T) { + // Test 1: Create array when setting to non-existent key with index + data := map[string]any{ + "existing": "value", + } + + lp := newLens(t, []string{"newArray", "2"}) + if err := lp.Set(data, "item at index 2"); err != nil { + t.Fatalf("Set to create array failed: %v", err) + } + + // Verify the array was created + arr, ok := data["newArray"] + if !ok { + t.Fatalf("Expected 'newArray' key to be created") + } + + arrSlice, ok := arr.([]any) + if !ok { + t.Fatalf("Expected 'newArray' to be []any, got %T", arr) + } + + if len(arrSlice) != 3 { + t.Fatalf("Expected array length 3, got %d", len(arrSlice)) + } + + if arrSlice[2] != "item at index 2" { + t.Fatalf("Expected arrSlice[2] to be %q, got %v", "item at index 2", arrSlice[2]) + } + + // Test 2: Create nested path with array creation + data2 := map[string]any{} + + lp2 := newLens(t, []string{"users", "1", "name"}) + if err := lp2.Set(data2, "Alice"); err != nil { + t.Fatalf("Set nested with array creation failed: %v", err) + } + + users, ok := data2["users"] + if !ok { + t.Fatalf("Expected 'users' key to be created") + } + + usersSlice, ok := users.([]any) + if !ok { + t.Fatalf("Expected 'users' to be []any, got %T", users) + } + + if len(usersSlice) != 2 { + t.Fatalf("Expected users length 2, got %d", len(usersSlice)) + } + + // usersSlice[1] should be a map with "name": "Alice" + userMap, ok := usersSlice[1].(map[string]any) + if !ok { + t.Fatalf("Expected usersSlice[1] to be map, got %T", usersSlice[1]) + } + + if userMap["name"] != "Alice" { + t.Fatalf("Expected users[1].name to be %q, got %v", "Alice", userMap["name"]) + } + + // Test 3: Set multiple values in newly created array + data3 := map[string]any{} + + lp3a := newLens(t, []string{"items", "0"}) + lp3b := newLens(t, []string{"items", "2"}) + + if err := lp3a.Set(data3, "first"); err != nil { + t.Fatalf("Set items[0] failed: %v", err) + } + + if err := lp3b.Set(data3, "third"); err != nil { + t.Fatalf("Set items[2] failed: %v", err) + } + + items, ok := data3["items"].([]any) + if !ok { + t.Fatalf("Expected 'items' to be []any, got %T", data3["items"]) + } + + if items[0] != "first" { + t.Fatalf("Expected items[0] to be %q, got %v", "first", items[0]) + } + + if items[2] != "third" { + t.Fatalf("Expected items[2] to be %q, got %v", "third", items[2]) + } + + // items[1] should be nil (zero value) + if items[1] != nil { + t.Fatalf("Expected items[1] to be nil, got %v", items[1]) + } +} + func newLens(t *testing.T, path []string) *lenspath.Lenspath { t.Helper() if len(path) == 0 { From 916482adf097919454ec8f8fa5a4867be0cff2d9 Mon Sep 17 00:00:00 2001 From: loganpowell Date: Mon, 3 Nov 2025 17:16:54 -0500 Subject: [PATCH 3/3] fix: intermediate map for index followed by key --- integration_test.go | 202 ++++++++++++++++++++++++++++++++++++++++++++ lenspath.go | 10 +++ 2 files changed, 212 insertions(+) create mode 100644 integration_test.go diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..700140a --- /dev/null +++ b/integration_test.go @@ -0,0 +1,202 @@ +package lenspath_test + +import ( + "encoding/json" + "testing" + + "github.com/loganpowell/lenspath" +) + +// parseDotPath converts a dot-separated path string into path segments. +// Example: "delivery_items.0.description" -> []string{"delivery_items", "0", "description"} +func parseDotPath(path string) []string { + result := []string{} + current := "" + + for _, char := range path { + if char == '.' { + if current != "" { + result = append(result, current) + current = "" + } + } else { + current += string(char) + } + } + + if current != "" { + result = append(result, current) + } + + return result +} + +// setValueAtPath is a helper that combines path parsing and value setting. +func setValueAtPath(data map[string]any, path string, value any) error { + pathParts := parseDotPath(path) + lens, err := lenspath.Create(pathParts) + if err != nil { + return err + } + return lens.Set(data, value) +} + +// TestIntegrationDotPathSyntax tests building complex nested structures using dot-path syntax. +// This validates that the library can handle real-world scenarios of building data structures +// from scratch with mixed maps, arrays, and nested objects. +func TestIntegrationDotPathSyntax(t *testing.T) { + // Start with empty map + result := make(map[string]any) + + // Set array values + if err := setValueAtPath(result, "delivery_items.0.description", "Test Item"); err != nil { + t.Fatalf("Failed to set delivery_items.0.description: %v", err) + } + if err := setValueAtPath(result, "delivery_items.0.quantity", 10); err != nil { + t.Fatalf("Failed to set delivery_items.0.quantity: %v", err) + } + if err := setValueAtPath(result, "delivery_items.0.hs_code", "123456"); err != nil { + t.Fatalf("Failed to set delivery_items.0.hs_code: %v", err) + } + if err := setValueAtPath(result, "delivery_items.1.description", "Second Item"); err != nil { + t.Fatalf("Failed to set delivery_items.1.description: %v", err) + } + if err := setValueAtPath(result, "delivery_items.1.quantity", 20); err != nil { + t.Fatalf("Failed to set delivery_items.1.quantity: %v", err) + } + + // Set nested object values + if err := setValueAtPath(result, "delivery_detail.invoice_number", "INV-001"); err != nil { + t.Fatalf("Failed to set delivery_detail.invoice_number: %v", err) + } + if err := setValueAtPath(result, "delivery_detail.ship_from_city", "TestCity"); err != nil { + t.Fatalf("Failed to set delivery_detail.ship_from_city: %v", err) + } + + // Set top-level values + if err := setValueAtPath(result, "pid_id", "PID-123"); err != nil { + t.Fatalf("Failed to set pid_id: %v", err) + } + if err := setValueAtPath(result, "vat_rate", 15.0); err != nil { + t.Fatalf("Failed to set vat_rate: %v", err) + } + + // Verify structure - top-level keys + if result["pid_id"] != "PID-123" { + t.Errorf("Expected pid_id to be %q, got %v", "PID-123", result["pid_id"]) + } + if result["vat_rate"] != 15.0 { + t.Errorf("Expected vat_rate to be %v, got %v", 15.0, result["vat_rate"]) + } + + // Verify delivery_items array + deliveryItems, ok := result["delivery_items"].([]any) + if !ok { + t.Fatalf("Expected delivery_items to be []any, got %T", result["delivery_items"]) + } + if len(deliveryItems) != 2 { + t.Fatalf("Expected delivery_items length 2, got %d", len(deliveryItems)) + } + + // Verify first item + item0, ok := deliveryItems[0].(map[string]any) + if !ok { + t.Fatalf("Expected delivery_items[0] to be map, got %T", deliveryItems[0]) + } + if item0["description"] != "Test Item" { + t.Errorf("Expected description %q, got %v", "Test Item", item0["description"]) + } + if item0["quantity"] != 10 { + t.Errorf("Expected quantity %v, got %v", 10, item0["quantity"]) + } + if item0["hs_code"] != "123456" { + t.Errorf("Expected hs_code %q, got %v", "123456", item0["hs_code"]) + } + + // Verify second item + item1, ok := deliveryItems[1].(map[string]any) + if !ok { + t.Fatalf("Expected delivery_items[1] to be map, got %T", deliveryItems[1]) + } + if item1["description"] != "Second Item" { + t.Errorf("Expected description %q, got %v", "Second Item", item1["description"]) + } + if item1["quantity"] != 20 { + t.Errorf("Expected quantity %v, got %v", 20, item1["quantity"]) + } + + // Verify delivery_detail + deliveryDetail, ok := result["delivery_detail"].(map[string]any) + if !ok { + t.Fatalf("Expected delivery_detail to be map, got %T", result["delivery_detail"]) + } + if deliveryDetail["invoice_number"] != "INV-001" { + t.Errorf("Expected invoice_number %q, got %v", "INV-001", deliveryDetail["invoice_number"]) + } + if deliveryDetail["ship_from_city"] != "TestCity" { + t.Errorf("Expected ship_from_city %q, got %v", "TestCity", deliveryDetail["ship_from_city"]) + } + + // Optional: Log the JSON for visual verification + jsonBytes, err := json.MarshalIndent(result, "", " ") + if err != nil { + t.Fatalf("Failed to marshal JSON: %v", err) + } + t.Logf("Result:\n%s", string(jsonBytes)) +} + +// TestParseDotPath verifies the dot path parsing helper function. +func TestParseDotPath(t *testing.T) { + tests := []struct { + input string + want []string + }{ + { + input: "simple", + want: []string{"simple"}, + }, + { + input: "one.two", + want: []string{"one", "two"}, + }, + { + input: "delivery_items.0.description", + want: []string{"delivery_items", "0", "description"}, + }, + { + input: "a.b.c.d.e", + want: []string{"a", "b", "c", "d", "e"}, + }, + { + input: "", + want: []string{}, + }, + { + input: "trailing.", + want: []string{"trailing"}, + }, + { + input: ".leading", + want: []string{"leading"}, + }, + { + input: "multiple..dots", + want: []string{"multiple", "dots"}, + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := parseDotPath(tt.input) + if len(got) != len(tt.want) { + t.Errorf("parseDotPath(%q) length = %d, want %d", tt.input, len(got), len(tt.want)) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("parseDotPath(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/lenspath.go b/lenspath.go index 3921615..3853383 100644 --- a/lenspath.go +++ b/lenspath.go @@ -287,6 +287,16 @@ func (lp *Lenspath) traverseMap(value any, view int, details *TraversalDetails) // Set the newly created slice in the map reflect.ValueOf(value).SetMapIndex(key, reflect.ValueOf(newSlice)) return nil, nil + } else if details.settable { + // Next segment is a map key, create a nested map + newMap := make(map[string]any) + _, err = lp.recurse(newMap, view+1, details) + if err != nil { + return nil, err + } + // Set the newly created map in the parent map + reflect.ValueOf(value).SetMapIndex(key, reflect.ValueOf(newMap)) + return nil, nil } } return nil, nil