Repository: edgedb/edgedb

Issue:
Update parts of JSON properties with a json_set function
Let's say initially `myMovie.json = {"a": 10, "b": 20}`

Instead of `update Movie set {myJson := to_json('{"a": 11, "b": 20}')};`, make it possible to do something like: `update Movie set {myJson := json_set(.myJson, 'a', .myJson['a'] + 1)}`.

It could just be syntactic sugar for cloning the whole JSON object and updating a deep field.

The syntax could be similar to `json_get` where the first argument specifies the JSON property, then the middle arguments are a path to a location within the JSON object to update, and the last argument is the value to set.

What if the path is invalid, e.g. `json_set(.myJson, 'a', 'x', 'y', 11)`? Maybe create singleton objects along the way to make it valid like `{"a": {"x": {"y": 11}}}`.

If there's an array along the path, could use numbers to specify indexes, and error if the path part isn't a number.

The command can potentially have a merge flag, so if `myMovie.myJson` initially equals `{"a": {"x": 5, "y": 6}}`, then `json_set(.myJson, 'a', to_json('{"y": 7, "z": 8}'))` could return `{"a": {"x": 5, "y": 7, "z": 8}}`.


Discussion:
I propose the following signature:

```
json_set(
  target: json,
  path: array<str>,  # We can't use variadic here, because new_value follows.
  new_value: optional json,
  named only create_if_missing: bool = false,
  named only empty_treatment: JsonSetEmptyTreatment = JsonSetEmptyTreatment.ReturnEmpty,
) -> optional json
```

Where `create_if_missing=true` causes intermediate elements to be created if missing, and `empty_treatment` is an enum determining what to do if *new_value* is an empty set:

```
scalar type JsonSetEmptyTreatment extending enum<
  ReturnEmpty,     # Return empty set, default 
  ReturnTarget,    # Return *target* unmodified
  RaiseException,  # Raise a ValueError
  UseJsonNull,     # Use a 'null` JSON value
  DeleteKey,       # Delete the object key
>;
```

This is basically the equivalent of PostgreSQL's `jsonb_set` and `jsonb_set_lax` functions.
Looks about right.

`path` probably needs to be something like `array<str|int32>` to index into JSON arrays along the path.

I guess you're thinking `ReturnEmpty` would cause json_set to always be a no-op, i.e. doesn't make sense to call it this way in a command by itself, but sometimes useful as part of a larger expression?

Maybe the idea about being able to merge objects together at the end of the path, rather than replacing the whole leaf object, could be yet another named enum argument. Or a separate function `json_update` instead of `json_set`.
> `path` probably needs to be something like `array<str|int32>` to index into JSON arrays along the path.

Not really, in JavaScript `foo["1"]` and `foo[1]` are equivalent, so it's safe to use strings to index into arrays (`json_get` uses a `variadic` `str`).

> Maybe the idea about being able to merge objects together at the end of the path

This is where you should probably just use `++` instead.

Resolving patch:
diff --git a/edb/buildmeta.py b/edb/buildmeta.py
--- a/edb/buildmeta.py
+++ b/edb/buildmeta.py
@@ -44,7 +44,7 @@
 
 
 # Increment this whenever the database layout or stdlib changes.
-EDGEDB_CATALOG_VERSION = 2022_07_25_00_00
+EDGEDB_CATALOG_VERSION = 2022_07_26_00_00
 EDGEDB_MAJOR_VERSION = 3
 
 


Test patch:
diff --git a/tests/test_edgeql_json.py b/tests/test_edgeql_json.py
--- a/tests/test_edgeql_json.py
+++ b/tests/test_edgeql_json.py
@@ -1094,6 +1094,132 @@ async def test_edgeql_json_get_04(self):
             ['"42!"']
         )
 
+    async def test_edgeql_json_set_01(self):
+        await self.assert_query_result(
+            r'''
+                WITH JT0 := (SELECT JSONTest FILTER .number = 0)
+                SELECT json_set(JT0.j_object, 'a', value := <json>42);
+            ''',
+            [{"a": 42, "b": 2}],
+            ['{"a": 42, "b": 2}'],
+        )
+
+        # by default create_if_missing should create new value
+        await self.assert_query_result(
+            r'''
+                WITH JT0 := (SELECT JSONTest FILTER .number = 0)
+                SELECT json_set(JT0.j_object, 'c', value := <json>42);
+            ''',
+            [{"a": 1, "b": 2, "c": 42}],
+            ['{"a": 1, "b": 2, "c": 42}'],
+        )
+
+        # create_if_missing should also work inside of nested objects
+        # by variadic path
+        await self.assert_query_result(
+            r'''
+                WITH j_object := to_json('{"a": {"b": {}}}')
+                SELECT json_set(j_object, 'a', 'b', 'c', value := <json>42);
+            ''',
+            [{"a": {"b": {"c": 42}}}],
+            ['{"a": {"b": {"c": 42}}}'],
+        )
+
+        await self.assert_query_result(
+            r'''
+                WITH JT0 := (SELECT JSONTest FILTER .number = 0)
+                SELECT json_set(
+                    JT0.j_object,
+                    'c',
+                    value := <json>42,
+                    create_if_missing := false,
+                );
+            ''',
+            [{"a": 1, "b": 2}],
+            ['{"a": 1, "b": 2}'],
+        )
+
+        # by default empty_treatment should return empty set
+        await self.assert_query_result(
+            r'''
+                WITH JT0 := (SELECT JSONTest FILTER .number = 0)
+                SELECT json_set(JT0.j_object, 'a', value := <json>{});
+            ''',
+            [],
+            [],
+        )
+
+        await self.assert_query_result(
+            r'''
+                WITH JT0 := (SELECT JSONTest FILTER .number = 0)
+                SELECT json_set(
+                    JT0.j_object,
+                    'a',
+                    value := <json>{},
+                    empty_treatment := JsonEmpty.ReturnEmpty,
+                );
+            ''',
+            [],
+            [],
+        )
+
+        await self.assert_query_result(
+            r'''
+                WITH JT0 := (SELECT JSONTest FILTER .number = 0)
+                SELECT json_set(
+                    JT0.j_object,
+                    'a',
+                    value := <json>{},
+                    empty_treatment := JsonEmpty.ReturnTarget,
+                );
+            ''',
+            [{"a": 1, "b": 2}],
+            ['{"a": 1, "b": 2}'],
+        )
+
+        await self.assert_query_result(
+            r'''
+                WITH JT0 := (SELECT JSONTest FILTER .number = 0)
+                SELECT json_set(
+                    JT0.j_object,
+                    'a',
+                    value := <json>{},
+                    empty_treatment := JsonEmpty.UseNull,
+                );
+            ''',
+            [{"a": None, "b": 2}],
+            ['{"a": null, "b": 2}'],
+        )
+
+        await self.assert_query_result(
+            r'''
+                WITH JT0 := (SELECT JSONTest FILTER .number = 0)
+                SELECT json_set(
+                    JT0.j_object,
+                    'a',
+                    value := <json>{},
+                    empty_treatment := JsonEmpty.DeleteKey,
+                );
+            ''',
+            [{"b": 2}],
+            ['{"b": 2}'],
+        )
+
+        with self.assertRaisesRegex(
+                edgedb.InvalidValueError,
+                r"invalid empty JSON value"):
+            await self.con.execute(
+                r'''
+                    WITH JT0 := (SELECT JSONTest FILTER .number = 0)
+                    SELECT json_set(
+                        JT0.j_object,
+                        'a',
+                        value := <json>{},
+                        empty_treatment := JsonEmpty.Error,
+                    );
+                ''',
+            )
+
     async def test_edgeql_json_cast_object_to_json_01(self):
         res = await self.con.query("""
             WITH MODULE schema