diff --git a/json.carp b/json.carp index bb5916b..4a1cbf1 100644 --- a/json.carp +++ b/json.carp @@ -679,15 +679,27 @@ array or the index is out of bounds. Returns a copy of the value.") (Maybe.Nothing) (Maybe.Nothing)) _ (Maybe.Nothing))) - (doc get-in "performs nested key lookup through objects. Returns a copy. + (private get-step) + (hidden get-step) + (defn get-step [j k] + (match-ref j + (JSON.Arr _) + (match (Int.from-string k) + (Maybe.Just i) (if (>= i 0) (JSON.nth j i) (Maybe.Nothing)) + (Maybe.Nothing) (Maybe.Nothing)) + _ (JSON.get j k))) -``` -(JSON.get-in &j &[@\"data\" @\"users\"]) -```") + (doc get-in "performs nested lookup through objects and arrays. String path +elements that parse as non-negative integers index into arrays; all other +elements are used as object keys. Returns a copy. + + (JSON.get-in &j &[@\"data\" @\"users\" @\"0\" @\"name\"])") (defn get-in [j keys] (Array.reduce &(fn [mj k] - (match mj (Maybe.Nothing) (Maybe.Nothing) (Maybe.Just j) (JSON.get &j k))) + (match mj + (Maybe.Nothing) (Maybe.Nothing) + (Maybe.Just j) (JSON.get-step &j k))) (Maybe.Just @j) keys)) @@ -697,16 +709,93 @@ automatically boxed.") (doc obj "builds a JSON object from an array of key-value pairs. -``` -(JSON.obj [(JSON.entry @\"a\" (JSON.Num 1.0)) - (JSON.entry @\"b\" (JSON.Bool true))]) -```") + (JSON.obj [(JSON.entry @\"a\" (JSON.Num 1.0)) + (JSON.entry @\"b\" (JSON.Bool true))])") (defn obj [entries] (JSON.Obj (Array.reduce &(fn [m p] (Map.put m (Pair.a p) (Pair.b p))) (the (Map String (Box JSON)) {}) - &entries)))) + &entries))) + + (doc set-key "sets a key in a JSON object, replacing any existing value. +If the value is not an object, returns it unchanged.") + (defn set-key [j k v] + (match j + (JSON.Obj m) (let [bv (Box.init v)] (JSON.Obj (Map.put m k &bv))) + other (do (ignore v) other))) + + (doc delete-key "removes a key from a JSON object. +If the value is not an object or the key is absent, returns it unchanged.") + (defn delete-key [j k] + (match j (JSON.Obj m) (JSON.Obj (Map.remove m k)) other other)) + + (doc push "appends a value to a JSON array. +If the value is not an array, returns it unchanged.") + (defn push [j v] + (match j + (JSON.Arr a) (JSON.Arr (Array.push-back a (Box.init v))) + other (do (ignore v) other))) + + (doc set-nth "replaces the value at index `i` in a JSON array. +If the value is not an array or the index is out of bounds, returns it +unchanged.") + (defn set-nth [j i v] + (match j + (JSON.Arr a) + (if (and (>= i 0) (< i (Array.length &a))) + (JSON.Arr (Array.aset a i (Box.init v))) + (do (ignore v) (JSON.Arr a))) + other (do (ignore v) other))) + + (private set-in-at) + (hidden set-in-at) + (register set-in-at (Fn [JSON (Ref (Array String)) Int JSON] JSON)) + (defn set-in-at [j keys idx v] + (if (>= idx (Array.length keys)) + (do (ignore j) v) + (let [k (Array.unsafe-nth keys idx)] + (match j + (JSON.Arr a) + (match (Int.from-string k) + (Maybe.Just i) + (if (and (>= i 0) (< i (Array.length &a))) + (let [child (Box.unbox @(Array.unsafe-nth &a i)) + updated (JSON.set-in-at child keys (Int.inc idx) v)] + (JSON.Arr (Array.aset a i (Box.init updated)))) + (do (ignore v) (JSON.Arr a))) + (Maybe.Nothing) (do (ignore v) (JSON.Arr a))) + (JSON.Obj m) + (let [child (match (Map.get-maybe &m k) + (Maybe.Just bx) (Box.unbox bx) + (Maybe.Nothing) (JSON.Obj (the (Map String (Box JSON)) {}))) + updated (JSON.set-in-at child keys (Int.inc idx) v) + bv (Box.init updated)] + (JSON.Obj (Map.put m k &bv))) + other (do (ignore v) other))))) + + (doc set-in "sets a value at a nested path through objects and arrays. + +Path elements that parse as non-negative integers index into arrays; +all other elements are used as object keys. Missing intermediate object +keys are created automatically. + + (JSON.set-in j &[@\"users\" @\"0\" @\"name\"] (JSON.Str @\"Alice\"))") + (defn set-in [j keys v] (JSON.set-in-at j keys 0 v)) + + (doc update-in "applies a function to the value at a nested path. + +Path elements that parse as non-negative integers index into arrays. +If the path does not resolve to a value, returns the input unchanged. + + (JSON.update-in j &[@\"count\"] (fn [n] + (match (JSON.as-num &n) + (Maybe.Just x) (JSON.Num (+ x 1.0)) + (Maybe.Nothing) n)))") + (defn update-in [j keys f] + (match (JSON.get-in &j keys) + (Maybe.Just old) (JSON.set-in j keys (f old)) + (Maybe.Nothing) (do (ignore f) j)))) (doc to-json "converts a Carp value to its JSON representation.") (definterface to-json (Fn [a] JSON)) diff --git a/test/json.carp b/test/json.carp index 01d9c13..1571890 100644 --- a/test/json.carp +++ b/test/json.carp @@ -920,4 +920,228 @@ break")) "str string with newline") (Result.Success j2) (= &(ser &j) &(ser &j2)) _ false) _ false)) - "pretty-str output parses back to equivalent structure")) + "pretty-str output parses back to equivalent structure") + + (assert-equal test + "{\"a\":1,\"b\":2}" + &(ser + &(JSON.set-key + (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))]) + "b" + (JSON.Num 2.0))) + "set-key adds a new key") + + (assert-equal test + "{\"a\":99}" + &(ser + &(JSON.set-key + (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))]) + "a" + (JSON.Num 99.0))) + "set-key replaces an existing key") + + (assert-equal test + "null" + &(ser &(JSON.set-key (JSON.Null) "x" (JSON.Num 1.0))) + "set-key on non-object returns unchanged") + + (assert-equal test + "{}" + &(ser &(JSON.delete-key (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))]) "a")) + "delete-key removes existing key") + + (assert-true test + (let [j (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))])] + (match (JSON.get &(JSON.delete-key j "z") "a") (Maybe.Just _) true _ false)) + "delete-key on missing key returns unchanged") + + (assert-equal test + "[1]" + &(ser &(JSON.delete-key (JSON.Arr [(Box.init (JSON.Num 1.0))]) "x")) + "delete-key on non-object returns unchanged") + + (assert-equal test + "[1,2,3]" + &(ser + &(JSON.push + (JSON.Arr [(Box.init (JSON.Num 1.0)) (Box.init (JSON.Num 2.0))]) + (JSON.Num 3.0))) + "push appends to array") + + (assert-equal test + "[1]" + &(ser &(JSON.push (JSON.Arr (the (Array (Box JSON)) [])) (JSON.Num 1.0))) + "push to empty array") + + (assert-equal test + "null" + &(ser &(JSON.push (JSON.Null) (JSON.Num 1.0))) + "push on non-array returns unchanged") + + (assert-equal test + "[1,99,3]" + &(ser + &(JSON.set-nth + (JSON.Arr + [(Box.init (JSON.Num 1.0)) + (Box.init (JSON.Num 2.0)) + (Box.init (JSON.Num 3.0))]) + 1 + (JSON.Num 99.0))) + "set-nth replaces element at index") + + (assert-equal test + "[99]" + &(ser + &(JSON.set-nth (JSON.Arr [(Box.init (JSON.Num 1.0))]) 0 (JSON.Num 99.0))) + "set-nth replaces first element") + + (assert-equal test + "[1,2]" + &(ser + &(JSON.set-nth + (JSON.Arr [(Box.init (JSON.Num 1.0)) (Box.init (JSON.Num 2.0))]) + 5 + (JSON.Num 99.0))) + "set-nth out of bounds returns unchanged") + + (assert-equal test + "null" + &(ser &(JSON.set-nth (JSON.Null) 0 (JSON.Num 1.0))) + "set-nth on non-array returns unchanged") + + (assert-true test + (let [j (JSON.Arr [(Box.init (JSON.Str @"a")) (Box.init (JSON.Str @"b"))])] + (match (JSON.get-in &j &[@"1"]) + (Maybe.Just v) (= &(ser &v) "\"b\"") + _ false)) + "get-in indexes into array with numeric string") + + (assert-true test + (match (JSON.parse "{\"users\":[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]}") + (Result.Success j) + (match (JSON.get-in &j &[@"users" @"1" @"name"]) + (Maybe.Just v) (= &(ser &v) "\"Bob\"") + _ false) + _ false) + "get-in traverses mixed objects and arrays") + + (assert-true test + (let [j (JSON.Arr [(Box.init (JSON.Num 1.0))])] + (Maybe.nothing? &(JSON.get-in &j &[@"5"]))) + "get-in returns Nothing for out-of-bounds array index") + + (assert-true test + (let [j (JSON.Arr [(Box.init (JSON.Num 1.0))])] + (Maybe.nothing? &(JSON.get-in &j &[@"abc"]))) + "get-in returns Nothing for non-numeric key on array") + + (assert-equal test + "{\"a\":99}" + &(ser + &(JSON.set-in + (JSON.obj [(JSON.entry @"a" (JSON.Num 1.0))]) + &[@"a"] + (JSON.Num 99.0))) + "set-in replaces value at single key") + + (assert-true test + (match (JSON.parse "{\"a\":{\"b\":1}}") + (Result.Success j) + (let [updated (JSON.set-in j &[@"a" @"b"] (JSON.Num 99.0))] + (match (JSON.get-in &updated &[@"a" @"b"]) + (Maybe.Just v) (= &(ser &v) "99") + _ false)) + _ false) + "set-in replaces nested value") + + (assert-true test + (let [j (JSON.obj []) + updated (JSON.set-in j &[@"a" @"b"] (JSON.Num 1.0))] + (match (JSON.get-in &updated &[@"a" @"b"]) + (Maybe.Just v) (= &(ser &v) "1") + _ false)) + "set-in creates intermediate objects for missing keys") + + (assert-equal test + "[1,99,3]" + &(ser + &(JSON.set-in + (JSON.Arr + [(Box.init (JSON.Num 1.0)) + (Box.init (JSON.Num 2.0)) + (Box.init (JSON.Num 3.0))]) + &[@"1"] + (JSON.Num 99.0))) + "set-in replaces array element by index") + + (assert-true test + (match (JSON.parse "{\"users\":[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]}") + (Result.Success j) + (let [updated (JSON.set-in j + &[@"users" @"1" @"name"] + (JSON.Str @"Charlie"))] + (match (JSON.get-in &updated &[@"users" @"1" @"name"]) + (Maybe.Just v) (= &(ser &v) "\"Charlie\"") + _ false)) + _ false) + "set-in modifies nested value in mixed object/array structure") + + (assert-equal test + "[1,2]" + &(ser + &(JSON.set-in + (JSON.Arr [(Box.init (JSON.Num 1.0)) (Box.init (JSON.Num 2.0))]) + &[@"5"] + (JSON.Num 99.0))) + "set-in on out-of-bounds array index returns unchanged") + + (assert-equal test + "42" + &(ser &(JSON.set-in (JSON.Num 42.0) &[@"x"] (JSON.Num 1.0))) + "set-in on non-container returns unchanged") + + (assert-true test + (let [j (JSON.obj [(JSON.entry @"x" (JSON.Num 10.0))]) + updated (JSON.update-in j + &[@"x"] + (fn [n] + (match (JSON.as-num &n) + (Maybe.Just x) (JSON.Num (+ x 1.0)) + (Maybe.Nothing) n)))] + (match (JSON.get &updated "x") (Maybe.Just v) (= &(ser &v) "11") _ false)) + "update-in applies function to value at path") + + (assert-true test + (match (JSON.parse "{\"a\":{\"b\":5}}") + (Result.Success j) + (let [updated (JSON.update-in j + &[@"a" @"b"] + (fn [n] + (match (JSON.as-num &n) + (Maybe.Just x) (JSON.Num (* x 2.0)) + (Maybe.Nothing) n)))] + (match (JSON.get-in &updated &[@"a" @"b"]) + (Maybe.Just v) (= &(ser &v) "10") + _ false)) + _ false) + "update-in applies function at nested path") + + (assert-true test + (let [j (JSON.obj [(JSON.entry @"x" (JSON.Num 1.0))]) + updated (JSON.update-in j &[@"missing"] (fn [n] (JSON.Num 99.0)))] + (match (JSON.get &updated "x") (Maybe.Just v) (= &(ser &v) "1") _ false)) + "update-in on missing path returns unchanged") + + (assert-true test + (let [j (JSON.Arr [(Box.init (JSON.Num 10.0)) (Box.init (JSON.Num 20.0))]) + updated (JSON.update-in j + &[@"0"] + (fn [n] + (match (JSON.as-num &n) + (Maybe.Just x) (JSON.Num (+ x 5.0)) + (Maybe.Nothing) n)))] + (match (JSON.get-in &updated &[@"0"]) + (Maybe.Just v) (= &(ser &v) "15") + _ false)) + "update-in works with array indices"))