深いマージ — ネストしたキーだけを安全に書き換える
ネストした1つのキーだけを差し替える方法は2つあります。jsonb_set はパスで位置を指して値を置き換え、|| は同じ階層のキーを上書きします。|| の上書きはその階層だけで、値がオブジェクトなら中身を混ぜずに丸ごと置き換わります(浅いマージ)。
SELECT jsonb_set(json_col, '{parent_key,child_key}', 'false'::jsonb, true), json_col || '{"top_key": 1}'::jsonb FROM table_name; -- 第4引数 true = 末端のキーが無ければ追加する
parent_key そのものが無い行では、jsonb_set は何も変えずに元の値を返します。引数のいずれかが NULL のときは、結果全体が NULL になります。settings テーブルの prefs について、notify オブジェクトの中の email を false にした結果を求めてください。notify の他のキーと、最上位の他のキーはそのまま残します。notify を持たない行や prefs が NULL の行でも、email が false の notify を持つ結果にしてください。取得列は user_id, prefs、user_id 昇順で返してください。
| user_id | prefs(jsonb) |
|---|---|
| 1 | {"theme":"dark","notify":{"email":true,"push":true}} |
| 2 | {"notify":{"push":true}} |
| 3 | {"theme":"light"} |
| 4 | NULL |
| user_id | prefs |
|---|---|
| 1 | {"theme": "dark", "notify": {"push": true, "email": false}} |
| 2 | {"notify": {"push": true, "email": false}} |
| 3 | {"theme": "light", "notify": {"email": false}} |
| 4 | {"notify": {"email": false}} |
jsonb_each — 更新前後を突き合わせて変更キーを洗い出す
監査ログや変更履歴では「どのキーが変わったか」を行として出したくなります。前後のドキュメントを jsonb_each で縦へ開き、キーで FULL JOIN すると、変更・追加・削除が1つの表に並びます。片側にしか無いキーは反対側が NULL になるため、比較は <> ではなく IS DISTINCT FROM を使います。
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; -- NULL 同士は「同じ」と扱う
<> では追加・削除が落ちる:キーが片側にしか無い行では比較の片側が NULL になり、b.value <> a.value は UNKNOWN になって WHERE を通りません。値の変わったキーだけが残り、追加と削除が黙って消えます。record_versions テーブルは、1行に更新前の before_doc と更新後の after_doc を持ちます。レコードごとに変化したキーを1行ずつ出してください。値が変わったキー、追加されたキー、削除されたキーがすべて対象です。追加されたキーの before_value と、削除されたキーの after_value は NULL とします。取得列は record_id, key, before_value, after_value、record_id 昇順・key 昇順で返してください。
| record_id | before_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"} |
| record_id | key | before_value | after_value |
|---|---|---|---|
| 101 | price | 100 | 120 |
| 102 | color | NULL | "red" |
| 103 | stock | 3 | NULL |
削除演算子 — 公開用に不要なキーと要素を落とす
JSON から一部を取り除く演算子は2つあります。- は最上位のキー(テキスト指定)または配列の要素(整数の添字)を落とし、#- はパス配列で指した1点を落としてネストの奥にも届きます。どちらも、指した先が存在しなければ何もせず、元の値をそのまま返します。
SELECT json_col - 'top_key' AS a, -- 最上位のキーを落とす json_col #- '{parent_key,child_key}' AS b, -- ネストの奥を落とす json_col #- '{arr_key,0}' AS c -- 配列の先頭要素を落とす FROM table_name;
- にテキストを渡すと対象で意味が変わる:相手がオブジェクトなら「そのキーを落とす」ですが、配列なら「その文字列と等しい要素をすべて落とす」になります。位置で落としたいときは、整数の添字か #- のパスを使います。documents テーブルの body から、社外へ出せない項目を落とした公開用JSONを作ってください。落とすのは、最上位の internal_memo キー、owner オブジェクトの中の email キー、tags 配列の先頭要素(内部ラベル)の3つです。該当が無い文書は、その部分を変えずに返します。取得列は doc_id, public_body、doc_id 昇順で返してください。
| doc_id | body(jsonb) |
|---|---|
| 1 | {"title":"T1","internal_memo":"社内限定","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":[]} |
| doc_id | public_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"} |
@@ 述語 — 数値の範囲でJSONを絞り込む
包含演算子 @> が判定できるのは「この値を含むか」までで、数値の大小は書けません。JSONPath の述語を評価する @@ なら、>= や && を含む条件をそのまま渡せます。@? がパス式に該当する要素の有無を返すのに対し、@@ は述語そのものの真偽を返します。
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 は @>・@?・@@ に効く
$.num_key >= 1000 は JSON の数値どうしの比較です。値が "1000" と文字列で入っている行では結果が未定義になり、WHERE を通りません。キーそのものが無い行は偽になります。events テーブルから、props の amount が 1000 以上、かつ channel が web のイベントを取得してください。取得列は event_id, props、event_id 昇順で返してください。
| event_id | props(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} |
| event_id | props |
|---|---|
| 1 | {"amount": 1500, "channel": "web"} |
| 6 | {"amount": 1000, "channel": "web"} |
配列の包含 — 複数のタグをすべて持つ行を選ぶ
JSON配列に対する @> は「右辺の要素をすべて含むか」を判定します。集合としての判定なので、要素の順序も重複も結果に影響しません。文字列だけの配列なら ?&(すべてを持つ)・?|(どれかを持つ)でも同じことが書け、どちらもGINインデックスが効きます。
SELECT * FROM table_name WHERE json_col -> 'arr_key' @> '["a","b"]'::jsonb; -- 文字列の配列なら ?& array['a','b'] でも同じ判定になる
json_col @> '{"arr_key":"a"}' は「arr_key の値が文字列 a と等しい」という意味なので、配列を持つ行には一致しません。配列の中身を見るなら、右辺も配列で書きます。articles テーブルの meta は tags 配列を持ちます。sql と postgres を両方持つ記事を取得してください。取得列は article_id, tags、article_id 昇順で返してください。tags は meta の中の配列をそのまま返します。
| article_id | meta(jsonb) |
|---|---|
| 1 | {"title":"インデックス設計","tags":["sql","postgres","index"]} |
| 2 | {"title":"SQL入門","tags":["sql"]} |
| 3 | {"title":"実行計画の読み方","tags":["postgres","sql"]} |
| 4 | {"title":"チューニング事例","tags":["sql","sql","postgres"]} |
| 5 | {"title":"下書き","tags":[]} |
| 6 | {"title":"メモ"} |
| article_id | tags |
|---|---|
| 1 | ["sql", "postgres", "index"] |
| 3 | ["postgres", "sql"] |
| 4 | ["sql", "sql", "postgres"] |