diff --git a/genj/genj.go b/genj/genj.go index 8afa992..09f716a 100644 --- a/genj/genj.go +++ b/genj/genj.go @@ -211,4 +211,42 @@ func SetAny(d *Document, v any, path ...ID) error { return nil } +// Create adds a new value at the end if the path. If the second last element of the path +// points to an object or array, the last element will be used as key or index. In case of +// an index multiple number higher than the current length of the array the array will be +// extended with nil values. Is the path is longer than existing entries, the missing ones +// will be created as objects or arrays only containing the keys or indexes pointing to the +// next element. +func Create[V ValueConstraint](d *Document, v V, path ...ID) error { + // Create the path. + id, e, err := create(d.root, path) + if err != nil { + return fmt.Errorf("cannot create element: %v", err) + } + // Check the element. + switch et := e.(type) { + case Object: + if _, ok := et[id]; ok { + return fmt.Errorf("cannot create element: element already exists") + } + et[id] = v + case Array: + i, err := strconv.Atoi(id) + if err != nil { + return fmt.Errorf("cannot create element: %v", err) + } + if i < 0 { + return fmt.Errorf("cannot create element: negative index %d", i) + } + if i >= len(et) { + et = append(et, make([]any, i-len(et)+1)...) + } + if et[i] != nil { + return fmt.Errorf("cannot create element: element already exists") + } + et[i] = v + } + return nil +} + // EOF diff --git a/genj/genj_test.go b/genj/genj_test.go index e00a36d..db8342e 100644 --- a/genj/genj_test.go +++ b/genj/genj_test.go @@ -1,4 +1,4 @@ -// Tideland Go Stew - Generic JSON - Unit Tests +// Tideland Go Stew - Generic JSON - Private Unit Tests // // Copyright (C) 2019-2023 Frank Mueller / Tideland / Oldenburg / Germany // @@ -269,8 +269,34 @@ func TestSetAny(t *testing.T) { Assert(t, ErrorContains(err, "current element is not allowed to be an object or array"), "array to int") } +// TestCreate tests the creation of values in an existing JSON document. +func TestCreate(t *testing.T) { + doc, err := genj.Read(bytes.NewReader(createJSON())) + Assert(t, NoError(err), "document must be read w/o error") + Assert(t, NotNil(doc), "document must exist") + + // Valid creation. + err = genj.Create(doc, "new value", "string") + Assert(t, NoError(err), "string must be used") + + s, err := genj.Get[string](doc, "string") + Assert(t, NoError(err), "string must be accessible") + Assert(t, Equal(s, "new value"), "string must be set correct") + + err = genj.Create(doc, 4711, "nested", "0", "x") + Assert(t, NoError(err), "int must be created") + + i, err := genj.Get[int](doc, "nested", "0", "x") + Assert(t, NoError(err), "int must be accessible") + Assert(t, Equal(i, 4711), "int must be correct") + + // Invalid creation. + err = genj.Create(doc, 4711, "nested", "0", "d") + Assert(t, ErrorContains(err, "current element is not allowed to be an object or array"), "array to int") +} + //-------------------- -// TESTS +// HELPER //-------------------- // createJSON creates a simple JSON test document as bytes. diff --git a/genj/navigation.go b/genj/navigation.go index 12237e3..1647b84 100644 --- a/genj/navigation.go +++ b/genj/navigation.go @@ -14,12 +14,93 @@ package genj // import "tideland.dev/go/stew/genj" import ( "fmt" "strconv" + + "tideland.dev/go/stew/slices" ) //-------------------- // DOCUMENT TREE NAVIGATION //-------------------- +// moonwalk walks down the path starting at the given element and +// returns the last ID of the whole path as well as the last element found. +// Next value is the rest of the path if it is longer than one element. In +// case of a non-integer path ID for an array the error is returned. +func moonwalk(start Element, path Path) (ID, Element, Path, error) { + var id ID + // Check basic path length. + switch len(path) { + case 0: + return "", start, nil, fmt.Errorf("path is empty") + case 1: + path, id = slices.InitLast(path) + return id, start, path, nil + } + // Start walking. + id = slices.Last(path) + current := start + for { + // Get head and tail of path. + head, tail := slices.HeadTail(path) + // Walk the path. + switch ct := current.(type) { + case Object: + next, ok := ct[head] + if !ok { + // End of walk but not of path. + return id, ct, path, nil + } + current = next + case Array: + i, err := strconv.Atoi(head) + if err != nil { + return id, current, path, fmt.Errorf("invalid array index %q", head) + } + if i < 0 { + return id, current, path, fmt.Errorf("array index %d out of bounds", i) + } + if i >= len(ct) { + // End of walk but not of path. + return id, ct, path, nil + } + current = ct[i] + default: + return id, nil, path, fmt.Errorf("path %q is illegal", path) + } + // Check if we are done. + if len(tail) == 0 { + return id, current, nil, nil + } + // Continue with the tail. + path = tail + } +} + +// contains takes an Element and an ID and checks if the element +// is an Object or Array and contains the ID. In this case the found +// Element and nil are returned, otherwise nil and an error. +func contains(elem Element, id ID) (Element, error) { + switch et := elem.(type) { + case Object: + value, ok := et[id] + if !ok { + return nil, fmt.Errorf("element %q not found", id) + } + return value, nil + case Array: + i, err := strconv.Atoi(id) + if err != nil { + return nil, err + } + if i < 0 || i >= len(et) { + return nil, fmt.Errorf("index %d out of bounds", i) + } + value := et[i] + return value, nil + } + return nil, fmt.Errorf("element is no Object or Array") +} + // walk walks down the path starting at the given element and // returns the found element as head and all elements on the path // as tail. @@ -70,4 +151,52 @@ func walk(start Element, path Path) (Element, []Element, error) { return head, tail, nil } +// create walks down the path starting at the given element and ends with +// the last one or creates the missing elements. It return the last ID and +// the last element. +func create(start Element, path Path) (ID, Element, error) { + // Check the path. + switch len(path) { + case 0: + return "", start, fmt.Errorf("path is empty") + case 1: + return path[0], start, nil + } + // Fetch end of path as ID and dive into the tree until the + // path ended or the document tree ended. + begin, _ := slices.InitLast(path) + ph, pt := slices.HeadTail(begin) + head := start + for { + current := head + switch et := current.(type) { + case Object: + v, ok := et[ph] + if !ok { + break + } + head = v + case Array: + i, err := strconv.Atoi(ph) + if err != nil { + return "", nil, err + } + if i < 0 { + return "", nil, fmt.Errorf("negative array index %d", i) + } + if i >= len(et) { + // Enlarge array and break. + for j := len(et); j <= i; j++ { + et = append(et, nil) + } + head = et + break + } + } + ph, pt = slices.HeadTail(pt) + } + // Now create the missing elements. + +} + // EOF diff --git a/genj/private_test.go b/genj/private_test.go new file mode 100644 index 0000000..1c05af2 --- /dev/null +++ b/genj/private_test.go @@ -0,0 +1,192 @@ +// Tideland Go Stew - Generic JSON - Private Unit Tests +// +// Copyright (C) 2019-2023 Frank Mueller / Tideland / Oldenburg / Germany +// +// All rights reserved. Use of this source code is governed +// by the new BSD license. + +package genj // import "tideland.dev/go/stew/genj" + +//-------------------- +// IMPORTS +//-------------------- + +import ( + "bytes" + "testing" + + . "tideland.dev/go/stew/qaone" +) + +//-------------------- +// TESTS +//-------------------- + +// TestMoonwalk tests the new moonwalk function. +func TestMoonwalk(t *testing.T) { + doc, err := Read(bytes.NewReader(deepNestedJSON())) + Assert(t, NoError(err), "document must be read w/o error") + Assert(t, NotNil(doc), "document must exist") + + // Exact walks. + id, elem, path, err := moonwalk(doc.root, Path{"l1a", "l2a", "a"}) + Assert(t, NoError(err), "path must be walked w/o error") + Assert(t, Equal(id, "a"), "path ID must be 'a'") + Assert(t, Equal(elem, 1.0), "element must be 1") + Assert(t, Nil(path), "path must be empty") + + id, elem, path, err = moonwalk(doc.root, Path{"l1a", "l2b", "z", "l4a", "color"}) + Assert(t, NoError(err), "path must be walked w/o error") + Assert(t, Equal(id, "color"), "path ID must be 'color'") + Assert(t, Equal(elem, "red"), "element must be 'red'") + Assert(t, Nil(path), "path must be empty") + + // Short paths. + id, elem, path, err = moonwalk(doc.root, Path{"l1a", "l2b"}) + Assert(t, NoError(err), "path must be walked w/o error") + Assert(t, Equal(id, "l2b"), "path ID must be 'l2b'") + object, ok := elem.(Object) + Assert(t, OK(ok), "element must be an object") + Assert(t, Length(object, 3), "element must be not nil") + Assert(t, Nil(path), "path must be empty") + + id, elem, path, err = moonwalk(doc.root, Path{"l1a", "l2c"}) + Assert(t, NoError(err), "path must be walked w/o error") + Assert(t, Equal(id, "l2c"), "path ID must be 'l2c'") + array, ok := elem.(Array) + Assert(t, OK(ok), "element must be an array") + Assert(t, Length(array, 5), "element must be not nil") + Assert(t, Nil(path), "path must be empty") + + // Long paths. + id, elem, path, err = moonwalk(doc.root, Path{"l1a", "l2b", "z", "l4a", "production", "count"}) + Assert(t, NoError(err), "path must be walked w/o error") + Assert(t, Equal(id, "count"), "path ID must be 'count'") + object, ok = elem.(Object) + Assert(t, OK(ok), "element must be an object") + Assert(t, Length(object, 2), "element must have length 2") + Assert(t, Length(path, 2), "path must contain production and count") + + // Little element test using contains. + value, err := contains(elem, "color") + Assert(t, NoError(err), "contains must be successful") + Assert(t, Equal(value, "red"), "value must be 'red'") + + id, elem, path, err = moonwalk(doc.root, Path{"l1a", "l2b", "x", "999"}) + Assert(t, NoError(err), "path must be walked w/o error") + Assert(t, Equal(id, "999"), "path ID must be '999'") + array, ok = elem.(Array) + Assert(t, OK(ok), "element must be an array") + Assert(t, Length(array, 3), "element have length 3") + Assert(t, Length(path, 1), "path must have length 1") + + // Illegal paths. + id, elem, path, err = moonwalk(doc.root, Path{"l1a", "l2b", "x", "-99", "myid"}) + Assert(t, ErrorContains(err, `array index -99 out of bounds`), "path must not be walked") + Assert(t, Equal(id, "myid"), "path ID must be 'myid'") + Assert(t, NotNil(elem), "element must be not nil") + Assert(t, Length(path, 2), "path contains a rest") + + id, elem, path, err = moonwalk(doc.root, Path{"l1a", "l2a", "a", "not", "existing"}) + Assert(t, ErrorMatches(err, `path .* is illegal`), "path must not be walkable") + Assert(t, Equal(id, "existing"), "path ID must be 'existing'") + Assert(t, Nil(elem), "element must be nil") + Assert(t, Length(path, 2), "path contains a rest") +} + +// TestContains tests the contains function. +func TestContains(t *testing.T) { + doc, err := Read(bytes.NewReader(deepNestedJSON())) + Assert(t, NoError(err), "document must be read w/o error") + Assert(t, NotNil(doc), "document must exist") + + // Elements in Object. + id, elem, path, err := moonwalk(doc.root, Path{"l1a", "l2b"}) + Assert(t, NoError(err), "path must be walked w/o error") + Assert(t, Equal(id, "l2b"), "path ID must be 'l2b'") + Assert(t, Length(path, 0), "path must be empty") + + value, err := contains(elem, "x") + Assert(t, NoError(err), "contains must be successful") + Assert(t, NotNil(value), "value must be not nil") + array, ok := value.(Array) + Assert(t, OK(ok), "value must be an array") + Assert(t, Length(array, 3), "value must have length 3") + + value, err = contains(elem, "z") + Assert(t, NoError(err), "contains must be successful") + Assert(t, NotNil(value), "value must be not nil") + object, ok := value.(Object) + Assert(t, OK(ok), "value must be an object") + Assert(t, Length(object, 2), "value must have length 2") + + value, err = contains(elem, "not") + Assert(t, ErrorContains(err, `element "not" not found`), "contains must fail") + Assert(t, Nil(value), "value must be nil") + + // Elements in Array. + id, elem, path, err = moonwalk(doc.root, Path{"l1a", "l2c"}) + Assert(t, NoError(err), "path must be walked w/o error") + Assert(t, Equal(id, "l2c"), "path ID must be 'l2c'") + Assert(t, Length(path, 0), "path must be empty") + + value, err = contains(elem, "0") + Assert(t, NoError(err), "contains must be successful") + Assert(t, Equal(value, "one"), "value must be 'one'") + + value, err = contains(elem, "999") + Assert(t, ErrorContains(err, `index 999 out of bounds`), "contains must fail") + Assert(t, Nil(value), "value must be nil") + + // Neither Object nor Array. + id, elem, path, err = moonwalk(doc.root, Path{"l1a", "l2a", "a"}) + Assert(t, NoError(err), "path must be walked w/o error") + Assert(t, Equal(id, "a"), "path ID must be 'a'") + Assert(t, Length(path, 0), "path must be empty") + + value, err = contains(elem, "not") + Assert(t, ErrorContains(err, `element is no Object or Array`), "cannot test element type") + Assert(t, Nil(value), "value must be nil") +} + +//-------------------- +// HELPER +//-------------------- + +// deepNestedJSON creates a deep nested tree of elements. +func deepNestedJSON() []byte { + return []byte(`{ + "l1a": { + "l2a": { + "a": 1, + "b": 2 + }, + "l2b": { + "x": [1, 2, 3], + "y": [4, 5, 6], + "z": { + "l4a": { + "color": "red", + "size": 42 + }, + "l4b": { + "color": "blue", + "size": 23 + } + } + }, + "l2c": [ + "one", + "two", + "three", + "four", + { + "l3a": ["a", "b", "c"], + "l3b": ["d", "e", "f"] + } + ] + } + }`) +} + +// EOF diff --git a/slices/slices.go b/slices/slices.go index 2ef372b..f03f25d 100644 --- a/slices/slices.go +++ b/slices/slices.go @@ -19,6 +19,52 @@ import ( // SLICES //-------------------- +// Head returns the first value of a slice. +func Head[V any](ivs []V) V { + if len(ivs) == 0 { + var ov V + return ov + } + return ivs[0] +} + +// HeadTail returns the first value of a slice as head and the rest as tail. +func HeadTail[V any](ivs []V) (V, []V) { + if ivs == nil { + var ov V + return ov, nil + } + if len(ivs) == 0 { + var ov V + return ov, []V{} + } + return ivs[0], ivs[1:] +} + +// Last returns the last value of a slice. +func Last[V any](ivs []V) V { + if len(ivs) == 0 { + var ov V + return ov + } + l := len(ivs) - 1 + return ivs[l] +} + +// InitLast returns the last value of a slice as last and the rest as init. +func InitLast[V any](ivs []V) ([]V, V) { + if len(ivs) == 0 { + var ov V + return nil, ov + } + if len(ivs) == 0 { + var ov V + return []V{}, ov + } + l := len(ivs) - 1 + return ivs[:l], ivs[l] +} + // Append appends the values of all slices to one new slice. func Append[V any](ivss ...[]V) []V { var ovs []V diff --git a/slices/sort_test.go b/slices/sort_test.go index 5c5b383..493d8d8 100644 --- a/slices/sort_test.go +++ b/slices/sort_test.go @@ -25,6 +25,60 @@ import ( // TESTS //-------------------- +// TestHeadTailInitLast verifies the splitting of slices. +func TestHeadTailInitLast(t *testing.T) { + tests := []struct { + descr string + values []int + head int + tail []int + init []int + last int + }{ + { + descr: "Simple slice", + values: []int{1, 2, 3, 4, 5}, + head: 1, + tail: []int{2, 3, 4, 5}, + init: []int{1, 2, 3, 4}, + last: 5, + }, { + descr: "Single value slice", + values: []int{1}, + head: 1, + tail: []int{}, + init: []int{}, + last: 1, + }, { + descr: "Empty slice", + values: []int{}, + head: 0, + tail: []int{}, + init: []int{}, + last: 0, + }, { + descr: "Nil slice", + values: nil, + head: 0, + tail: nil, + init: nil, + last: 0, + }, + } + + for _, test := range tests { + t.Logf("test: %s", test.descr) + + head, tail := slices.HeadTail(test.values) + init, last := slices.InitLast(test.values) + + Assert(t, Equal(head, test.head), test.descr) + Assert(t, DeepEqual(tail, test.tail), test.descr) + Assert(t, DeepEqual(init, test.init), test.descr) + Assert(t, Equal(last, test.last), test.descr) + } +} + // TestSort verifies the standard sorting of slices. func TestSort(t *testing.T) { tests := []struct {