SQL JSON — Applied Deep Merge, Diffs, Predicate Search

ADVJSONDeep mergejsonb_eachDelete operatorsJSONPath predicatesContainment operatorsPostgreSQL Compatible5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 6

Deep merge — Rewrite one nested key without losing the rest

jsonb_set|| operatorMissing parent key
Background

There are two ways to replace a single nested key. jsonb_set points at a position with a path and replaces the value there, while || overwrites keys at the same level. The overwrite performed by || covers that level only: if the value is an object, it is replaced whole rather than blended (a shallow merge).

SELECT jsonb_set(json_col, '{parent_key,child_key}', 'false'::jsonb, true),
       json_col || '{"top_key": 1}'::jsonb
FROM   table_name;  -- 4th argument true = add the key if it is missing
No parent means no insert either: the fourth argument only takes effect at the end of the path. On a row where parent_key itself is missing, jsonb_set changes nothing and returns the original value. And when any argument is NULL, the whole result is NULL.
Problem

For prefs in the settings table, produce the result of setting email inside the notify object to false. Leave the other keys of notify and the other top-level keys as they are. Rows that have no notify, and rows whose prefs is NULL, must also end up with a notify whose email is false. Return user_id, prefs, sorted by user_id ascending.

Tables used
▸ settings
user_idprefs (jsonb)
1{"theme":"dark","notify":{"email":true,"push":true}}
2{"notify":{"push":true}}
3{"theme":"light"}
4NULL
Expected Output
user_idprefs
1{"theme": "dark", "notify": {"push": true, "email": false}}
2{"notify": {"push": true, "email": false}}
3{"theme": "light", "notify": {"email": false}}
4{"notify": {"email": false}}
QUESTION 7

jsonb_each — Match two versions and list the changed keys

jsonb_eachFULL JOINIS DISTINCT FROM
Background

An audit log or a change history wants "which key changed" as rows. Open the before and after documents downwards with jsonb_each and FULL JOIN them on the key, and changes, additions and removals all line up in one table. A key present on only one side leaves NULL on the other, so the comparison uses IS DISTINCT FROM rather than <>.

SELECT COALESCE(b.key, a.key) AS key, b.value, a.value
FROM      jsonb_each(before_col) AS b(key, value)
FULL JOIN jsonb_each(after_col)  AS a(key, value) ON a.key = b.key
WHERE  b.value IS DISTINCT FROM a.value;  -- two NULLs count as "the same"
With <> the additions and removals fall out: on a row whose key exists on one side only, one side of the comparison is NULL, so b.value <> a.value is UNKNOWN and does not pass WHERE. Only keys whose value changed survive, and additions and removals vanish silently.
Problem

Each row of the record_versions table holds the pre-update before_doc and the post-update after_doc. For each record, produce one row per changed key. Keys whose value changed, keys that were added and keys that were removed are all in scope. The before_value of an added key and the after_value of a removed key are NULL. Return record_id, key, before_value, after_value, sorted by record_id ascending and key ascending.

Tables used
▸ record_versions
record_idbefore_doc (jsonb)after_doc (jsonb)
101{"name":"A","price":100,"tag":"x"}{"name":"A","price":120,"tag":"x"}
102{"name":"B","price":50}{"name":"B","price":50,"color":"red"}
103{"name":"C","stock":3}{"name":"C"}
104{"name":"D"}{"name":"D"}
Expected Output
record_idkeybefore_valueafter_value
101price100120
102colorNULL"red"
103stock3NULL
QUESTION 8

Delete operators — Drop the keys and elements a public copy must not carry

- operator#- operatorPath notation
Background

There are two operators for taking part of a JSON away. - drops a top-level key (given as text) or an array element (given as an integer index), and #- drops the single point named by a path array, so it reaches into the nesting. Both do nothing and return the original value if what they point at does not exist.

SELECT json_col - 'top_key'                 AS a,  -- drop a top-level key
       json_col #- '{parent_key,child_key}' AS b,  -- drop something deeper down
       json_col #- '{arr_key,0}'            AS c   -- drop the first element of an array
FROM   table_name;
Giving - text means different things to different targets: against an object it is "drop that key", but against an array it is "drop every element equal to that string". To drop by position, use an integer index or a #- path.
Problem

From body in the documents table, build a public JSON with the items that must not leave the company removed. Three things are dropped: the top-level internal_memo key, the email key inside the owner object, and the first element of the tags array (an internal label). A document that has none of them is returned with that part unchanged. Return doc_id, public_body, sorted by doc_id ascending.

Tables used
▸ documents
doc_idbody (jsonb)
1{"title":"T1","internal_memo":"internal only","owner":{"name":"A","email":"a@example.com"},"tags":["draft","sql","index"]}
2{"title":"T2","owner":{"name":"B"}}
3{"title":"T3","internal_memo":"m3","owner":{"name":"C","email":"c@example.com"},"tags":["draft","json"]}
4{"title":"T4","owner":{"name":"D","email":"d@example.com"},"tags":[]}
Expected Output
doc_idpublic_body
1{"tags": ["sql", "index"], "owner": {"name": "A"}, "title": "T1"}
2{"owner": {"name": "B"}, "title": "T2"}
3{"tags": ["json"], "owner": {"name": "C"}, "title": "T3"}
4{"tags": [], "owner": {"name": "D"}, "title": "T4"}
QUESTION 9

@@ predicate — Narrow JSON rows by a numeric range

@@ operatorJSONPathType mismatch
Background

The containment operator @> can only decide "does it contain this value" and cannot express a numeric comparison. @@, which evaluates a JSONPath predicate, takes a condition containing >= or && exactly as written. Where @? reports whether any element matches a path expression, @@ reports the truth of the predicate itself.

SELECT * FROM table_name
WHERE  json_col @@ '$.num_key >= 1000 && $.text_key == "value"';

CREATE INDEX ON table_name USING gin (json_col jsonb_path_ops);
-- jsonb_path_ops serves @>, @? and @@
A type mismatch is neither true nor false: $.num_key >= 1000 is a comparison between JSON numbers. On a row that stores the value as the string "1000", the result is undefined and the row does not pass WHERE. A row missing the key altogether is false.
Problem

From the events table, retrieve the events whose amount in props is 1000 or more and whose channel is web. Return event_id, props, sorted by event_id ascending.

Tables used
▸ events
event_idprops (jsonb)
1{"channel":"web","amount":1500}
2{"channel":"app","amount":2000}
3{"channel":"web","amount":800}
4{"channel":"web","amount":"1200"}
5{"channel":"web"}
6{"channel":"web","amount":1000}
Expected Output
event_idprops
1{"amount": 1500, "channel": "web"}
6{"amount": 1000, "channel": "web"}
QUESTION 10

Array containment — Select the rows that carry every tag

@> operator?& operatorDuplicates and order
Background

Applied to a JSON array, @> decides whether the left side contains every element of the right side. Because the decision is made on sets, neither the order nor the duplication of elements affects the result. For an array of nothing but strings, the same thing can be written with ?& (has all) and ?| (has any), and a GIN index serves both.

SELECT * FROM table_name
WHERE  json_col -> 'arr_key' @> '["a","b"]'::jsonb;
-- for an array of strings, ?& array['a','b'] makes the same decision
Containment against a scalar has a different shape: json_col @> '{"arr_key":"a"}' means "the value of arr_key equals the string a", so it matches no row that holds an array there. To look inside the array, write the right side as an array too.
Problem

In the articles table, meta holds a tags array. Retrieve the articles that carry both sql and postgres. Return article_id, tags, sorted by article_id ascending. tags is the array inside meta, returned as it is.

Tables used
▸ articles
article_idmeta (jsonb)
1{"title":"Index Design","tags":["sql","postgres","index"]}
2{"title":"Intro to SQL","tags":["sql"]}
3{"title":"Reading Query Plans","tags":["postgres","sql"]}
4{"title":"A Tuning Case Study","tags":["sql","sql","postgres"]}
5{"title":"Draft","tags":[]}
6{"title":"Notes"}
Expected Output
article_idtags
1["sql", "postgres", "index"]
3["postgres", "sql"]
4["sql", "sql", "postgres"]