jsonb_to_recordset — JSON配列の明細を型付きの行へ開く
明細をJSON配列で持つテーブルは、そのままでは集計できません。jsonb_to_recordset は列名と型を宣言して配列を行へ開く関数で、開いた後は普通の表と同じように SUM や GROUP BY をかけられます。行ごとの列を参照するため、LATERAL と組にします。
SELECT t.id_col, x.* FROM table_name t LEFT JOIN LATERAL jsonb_to_recordset(t.json_col) AS x(key_col text, num_col int) ON TRUE; -- 列名と型は自分で宣言する
CROSS JOIN LATERAL(カンマ区切りも同じ)では元の行ごと結果から落ちます。残すなら LEFT JOIN LATERAL … ON TRUE にします。orders テーブルから、注文ごとの合計金額を求めてください。items は明細オブジェクトの配列で、各要素は sku(テキスト)、qty(個数)、unit_price(単価)を持ちます。合計金額は明細ごとの qty × unit_price の総和とし、明細が1件も無い注文は 0 とします。取得列は order_id, customer, total_amount、order_id 昇順で返してください。
| order_id | customer | items(jsonb) |
|---|---|---|
| 1 | 佐藤 | [{"sku":"A-1","qty":2,"unit_price":1200},{"sku":"B-2","qty":1,"unit_price":3000}] |
| 2 | 鈴木 | [{"sku":"A-1","qty":1,"unit_price":1200}] |
| 3 | 田中 | [{"sku":"C-3","qty":3,"unit_price":800},{"sku":"A-1","qty":2,"unit_price":1200},{"sku":"B-2","qty":1,"unit_price":3000}] |
| 4 | 高橋 | [] |
| order_id | customer | total_amount |
|---|---|---|
| 1 | 佐藤 | 5400 |
| 2 | 鈴木 | 1200 |
| 3 | 田中 | 7800 |
| 4 | 高橋 | 0 |
SELECT o.order_id, o.customer, COALESCE(SUM(i.qty * i.unit_price), 0) AS total_amount -- 明細ゼロを 0 に寄せる FROM orders o LEFT JOIN LATERAL jsonb_to_recordset(o.items) AS i(sku text, qty int, unit_price numeric) ON TRUE -- 型を宣言して行へ開く GROUP BY o.order_id, o.customer ORDER BY o.order_id; /* 実行順序(SQLの論理的な評価順): 1. FROM orders → 4行読み込み 2. LEFT JOIN LATERAL jsonb_to_recordset → 注文ごとに明細を行へ展開(7行) 3. GROUP BY o.order_id, o.customer → 注文単位の4グループへ戻す 4. SELECT COALESCE(SUM(...), 0) → 明細金額を合計、明細ゼロは 0 5. ORDER BY o.order_id → order_id の昇順 */
LEGEND
① FROM orders
FROM ordersorders 全4行を読み込みます。明細は items の中に配列として畳まれたままで、この時点では1注文が1行です。order 4 の items は空配列で、明細を1件も持ちません。| order_id | customer | items(jsonb) |
|---|---|---|
| 1 | 佐藤 | [{"sku":"A-1","qty":2,...},{"sku":"B-2","qty":1,...}] |
| 2 | 鈴木 | [{"sku":"A-1","qty":1,...}] |
| 3 | 田中 | [{"sku":"C-3","qty":3,...},{"sku":"A-1","qty":2,...},{"sku":"B-2","qty":1,...}] |
| 4 | 高橋 | [] |
jsonb_to_recordset は列の型情報をJSONから推測しません。AS i(sku text, qty int, unit_price numeric) の宣言が契約で、値が入っていない要素はその列が NULL になり、数値として読めない値が入っていれば実行時エラーになります。金額を numeric で受けるのは、float の丸め誤差を持ち込まないためです。LEFT JOIN … ON TRUE にしておくと、明細ゼロの注文が NULL 行として残り、あとから COALESCE で意味づけできます。COUNT は 0 を返しますが、SUM / AVG / MAX は対象が1件も無いと NULL を返します。「0円の注文」を出したいなら、集約の外側で COALESCE をかけるのが定石です。FROM orders o, jsonb_to_recordset(o.items) AS i(...) は CROSS JOIN LATERAL と同じ意味で、明細ゼロの注文が黙って消えます。結果の行数が入力より減っていることに気付けるのは、件数を突き合わせたときだけです。CHECK も効きません。明細を「毎日の集計対象」として扱い始めた時点が、order_items テーブルへ切り出す合図です。移行の第一歩として、この設問のような展開クエリをそのままビューにしておくと、参照側を書き換えずにテーブル化へ進めます。WITH ORDINALITY — 配列の並び順を添字として取り出す
JSON配列は順序を持ちますが、行へ開いた時点でその情報は列になりません。WITH ORDINALITY を付けると、集合を返す関数の出力に1始まりの連番列が追加され、「何番目の要素か」を WHERE や ORDER BY で扱えるようになります。
SELECT x.elem, x.pos FROM table_name t CROSS JOIN LATERAL jsonb_array_elements_text(t.json_col) WITH ORDINALITY AS x(elem, pos); -- pos は 1 始まりの bigint
ORDER BY を書かない限り保証されません。位置が意味を持つなら、必ず列にしてから並べ替えます。playlists テーブルの tracks は曲名(文字列)の配列です。各プレイリストの先頭3曲を、曲順の番号を付けて取得してください。番号は配列の先頭を 1 とします。取得列は playlist_id, pos, title、playlist_id 昇順・pos 昇順で返してください。
| playlist_id | name | tracks(jsonb) |
|---|---|---|
| 1 | morning | ["Aurora","Bloom","Cinder","Drift"] |
| 2 | focus | ["Ember","Frost"] |
| 3 | night | ["Glow","Halo","Iris"] |
| playlist_id | pos | title |
|---|---|---|
| 1 | 1 | Aurora |
| 1 | 2 | Bloom |
| 1 | 3 | Cinder |
| 2 | 1 | Ember |
| 2 | 2 | Frost |
| 3 | 1 | Glow |
| 3 | 2 | Halo |
| 3 | 3 | Iris |
SELECT p.playlist_id, t.pos, t.title FROM playlists p CROSS JOIN LATERAL jsonb_array_elements_text(p.tracks) WITH ORDINALITY AS t(title, pos) -- 曲名と 1 始まりの位置 WHERE t.pos <= 3 -- 先頭3曲だけ残す ORDER BY p.playlist_id, t.pos; /* 実行順序(SQLの論理的な評価順): 1. FROM playlists → 3行読み込み 2. CROSS JOIN LATERAL jsonb_array_elements_text → 曲を行へ展開(9行) 3. WITH ORDINALITY → 展開順に 1 始まりの pos を付与 4. WHERE t.pos <= 3 → 8行に絞り込み 5. SELECT playlist_id, pos, title → 3列を選択 6. ORDER BY playlist_id, pos → プレイリスト順・曲順 */
LEGEND
① FROM playlists
FROM playlistsplaylists 全3行を読み込みます。曲は tracks の中に配列として畳まれたままで、曲数はプレイリストごとに違います。| playlist_id | name | tracks(jsonb) |
|---|---|---|
| 1 | morning | ["Aurora","Bloom","Cinder","Drift"] |
| 2 | focus | ["Ember","Frost"] |
| 3 | night | ["Glow","Halo","Iris"] |
WITH ORDINALITY の連番で表現します。列になっていれば WHERE、ORDER BY、ウィンドウ関数の PARTITION BY まで、通常の列と同じ道具が使えます。LATERAL の中で行ごとに関数が呼ばれるため、pos は各プレイリストで 1 から始まります。全体を通した通し番号が欲しいときは ROW_NUMBER() OVER (ORDER BY …) と役割が分かれます。jsonb_array_elements は要素を jsonb のまま返すので、文字列は "Aurora" と引用符付きになります。要素が文字列と分かっていて、そのままテキストとして使うなら jsonb_array_elements_text を選ぶとキャストが1つ減ります。LIMIT 3 は結果全体に効くので、プレイリストごとの先頭3曲にはなりません。グループごとの上位N件は、位置列や ROW_NUMBER による絞り込みで表現します。sort_order 列を持たせて、間に挿入できるよう間隔を空けた採番(10, 20, 30 …)にするほうが扱いやすくなります。JSON配列が向くのは、まとめて差し替えるか読むだけ、という使い方のときです。JSONPath — ネストした配列から条件に合う要素を抜く
包含演算子 @> が書けるのは「この値を含むか」までで、数値の大小は表現できません。SQL/JSON path なら、パスの途中に ? ( … ) のフィルタ式を挟んで、条件に合う要素だけを取り出せます。フィルタの中の @ は「いま見ている要素」を指します。
SELECT x.elem FROM table_name t CROSS JOIN LATERAL jsonb_path_query(t.json_col, '$.arr[*] ? (@.num_key > 0)') AS x(elem); -- $ = 全体、.arr[*] = 配列の全要素、? ( … ) = 絞り込み
jsonb_path_query は集合を返す関数なので、条件に合う要素が1つも無い行は CROSS JOIN LATERAL で落ちます。行を残したいなら LEFT JOIN LATERAL … ON TRUE、真偽だけ欲しいなら述語演算子 @? を使います。products テーブルの spec は、製品ごとに variants 配列を持ちます。各要素は color(色)と stock(在庫数)を持ちます。在庫が1以上のバリエーションだけを取り出してください。取得列は product_id, color, stock、product_id 昇順・color 昇順で返してください。stock は数値、color は引用符の付かないテキストにします。
| product_id | spec(jsonb) |
|---|---|
| 1 | {"brand":"Nova","variants":[{"color":"red","stock":0},{"color":"blue","stock":12}]} |
| 2 | {"brand":"Orbit","variants":[{"color":"black","stock":4},{"color":"white","stock":7}]} |
| 3 | {"brand":"Pico","variants":[{"color":"green","stock":0}]} |
| 4 | {"brand":"Quill","variants":[]} |
| product_id | color | stock |
|---|---|---|
| 1 | blue | 12 |
| 2 | black | 4 |
| 2 | white | 7 |
SELECT p.product_id, v.elem ->> 'color' AS color, (v.elem ->> 'stock')::int AS stock -- テキスト経由で数値へ FROM products p CROSS JOIN LATERAL jsonb_path_query(p.spec, '$.variants[*] ? (@.stock > 0)') AS v(elem) ORDER BY p.product_id, color; /* 実行順序(SQLの論理的な評価順): 1. FROM products → 4行読み込み 2. CROSS JOIN LATERAL jsonb_path_query → $.variants[*] で全要素を走査 3. ? (@.stock > 0) → 在庫のある要素だけを返す(3行) 4. SELECT color, stock → 要素から2つのキーを取り出す 5. ORDER BY product_id, color → 製品順・色名順 */
LEGEND
① FROM products
FROM productsproducts 全4行を読み込みます。variants はオブジェクトの配列で、要素数は製品ごとに違います。Quill は variants が空配列です。| product_id | spec(jsonb) |
|---|---|
| 1 | {"brand":"Nova","variants":[{red,0},{blue,12}]} |
| 2 | {"brand":"Orbit","variants":[{black,4},{white,7}]} |
| 3 | {"brand":"Pico","variants":[{green,0}]} |
| 4 | {"brand":"Quill","variants":[]} |
$.variants[*] ? (@.stock > 0) は「降りる」「広げる」「絞る」を1本のパスにまとめた形です。展開してから WHERE で絞る書き方と結果は同じですが、条件がJSON側に閉じているぶん、複数階層をまたぐ条件を短く書けます。@.stock > 0 は JSON の数値どうしの比較です。値が "12" のように文字列で入っていると、比較は真でも偽でもなく未定義になり、その要素は返りません。数値として検索する項目は、書き込み側で数値として入れておくのが前提になります。jsonb_path_query、条件に合う要素があるかだけ知りたいときは @? です。WHERE spec @? '$.variants[*] ? (@.stock > 0)' なら行は増えず、GINインデックスも効きます。spec @> '{"variants":[{"stock":0}]}' は「在庫0の要素を含む」という等価の判定で、「在庫が1以上」は表現できません。@> は等価の包含専用と割り切り、大小比較はパス式に寄せます。jsonb_path_query(spec, '$.variants[*] ? (@.stock > $min)', jsonb_build_object('min', 0)) のように、値は変数として渡します。? で始まりますが、多くのドライバは ? をバインド変数の記号として横取りします。JDBC や一部のORMでは、パス文字列の中の ? がパラメータとして解釈され、意味の分からないエラーになります。回避策は3つあります。パス全体をパラメータとして渡す、jsonb_path_query(spec, CAST(:path AS jsonpath)) のようにキャストで包む、あるいはドライバのエスケープ(?? など)に従う——どれを採るかは接続層の都合で決まるので、JSONPath を使い始める前に、まず短いパスで1本通してみるのが早道です。jsonb_object_agg — 縦持ちの属性をJSONへ畳む
1行1属性の縦持ち(キーと値の表)は列を増やさずに項目を足せますが、参照側では1行1エンティティの形が欲しくなります。jsonb_object_agg は、グループ内の行をキーと値のペアとしてJSONオブジェクトへ畳みます。同じキーが複数回現れると後から来た値が残るため、集約の中に ORDER BY を書いて「後」を決めます。
SELECT id_col, jsonb_object_agg(key_col, val_col ORDER BY ts_col) AS obj FROM table_name GROUP BY id_col; -- 後勝ちなので、昇順に並べると最後=最新が残る
null になりますが、キーが NULL の行が1つでもあると実行時エラーで落ちます。キー列に NOT NULL が無いなら、集約の前に除外します。device_attrs テーブルは、デバイスの属性を1行1属性で持ちます。デバイスごとに、attr_key をキー・attr_value を値とするJSONオブジェクトへまとめてください。同じ属性が複数回記録されている場合は、recorded_at が最も新しい値を採用します。取得列は device_id, attrs、device_id 昇順で返してください。
| device_id | attr_key | attr_value | recorded_at |
|---|---|---|---|
| d-01 | os | iOS 16 | 2026-01-10 |
| d-01 | carrier | NTT | 2026-01-10 |
| d-01 | os | iOS 17 | 2026-03-01 |
| d-02 | os | Android 14 | 2026-02-05 |
| d-02 | storage | NULL | 2026-02-05 |
| d-03 | os | iOS 17 | 2026-03-02 |
| device_id | attrs |
|---|---|
| d-01 | {"os": "iOS 17", "carrier": "NTT"} |
| d-02 | {"os": "Android 14", "storage": null} |
| d-03 | {"os": "iOS 17"} |
SELECT device_id, jsonb_object_agg(attr_key, attr_value ORDER BY recorded_at) AS attrs -- 後勝ち=最新が残る FROM device_attrs GROUP BY device_id ORDER BY device_id; /* 実行順序(SQLの論理的な評価順): 1. FROM device_attrs → 6行読み込み 2. GROUP BY device_id → 3グループに分割 3. ORDER BY recorded_at(集約内) → グループ内の適用順を古い順に固定 4. SELECT jsonb_object_agg(...) → キー・値のペアを1つのJSONへ畳む 5. ORDER BY device_id → device_id の昇順 */
LEGEND
① FROM device_attrs
FROM device_attrsdevice_attrs 全6行を読み込みます。1行が1属性で、d-01 だけ os が2回記録されています。d-02 の storage は値が NULL です。| device_id | attr_key | attr_value | recorded_at |
|---|---|---|---|
| d-01 | os | iOS 16 | 2026-01-10 |
| d-01 | carrier | NTT | 2026-01-10 |
| d-01 | os | iOS 17 | 2026-03-01 |
| d-02 | os | Android 14 | 2026-02-05 |
| d-02 | storage | NULL | 2026-02-05 |
| d-03 | os | iOS 17 | 2026-03-02 |
jsonb_object_agg(k, v ORDER BY ts) の1語で「最新を採る」が表現でき、副問い合わせで最新行を先に絞る必要がなくなります。jsonb はキーを「長さ順 → バイト順」に正規化して格納します。積んだ順序も、元テーブルの並びも残りません。キーの順序に意味を持たせたいなら、配列(jsonb_agg)で持つか json 型を選びます。{"storage": null} は「storage は記録されていて、値が空」を表します。キーごと無いのは「まだ記録されていない」です。取り出し側では attrs ? 'storage'(キーの有無)と attrs ->> 'storage' IS NULL(値が空)を意識して使い分けます。配列の組み直し — 要素を絞って並び順ごと復元する
JSON配列から条件に合う要素だけを取り除く演算子はありません。展開して、絞って、組み直すのが定石です。組み直しでは2つの注意点があります。並び順は集約内の ORDER BY で指定すること、そして要素が1つも残らないグループでは jsonb_agg が NULL を返すことです。
SELECT id_col, COALESCE(jsonb_agg(x.elem ORDER BY x.pos) FILTER (WHERE 条件式), '[]'::jsonb) AS arr FROM table_name t LEFT JOIN LATERAL jsonb_array_elements(t.json_col) WITH ORDINALITY AS x(elem, pos) ON TRUE GROUP BY id_col;
WHERE に書くと、残る要素が0個のグループはグループそのものが消えます。行を残して空配列を返したいなら、絞り込みは集約に付ける FILTER に置きます。carts テーブルの items は明細オブジェクトの配列で、各要素は sku と qty を持ちます。qty が 0 の明細を取り除いたカートを作ってください。元の並び順は保ち、明細が1件も残らないカートは空配列 [] を返します。取得列は cart_id, items、cart_id 昇順で返してください。
| cart_id | items(jsonb) |
|---|---|
| 1 | [{"sku":"A","qty":2},{"sku":"B","qty":0},{"sku":"C","qty":1}] |
| 2 | [{"sku":"D","qty":0}] |
| 3 | [] |
| 4 | [{"sku":"E","qty":5}] |
| cart_id | items |
|---|---|
| 1 | [{"qty": 2, "sku": "A"}, {"qty": 1, "sku": "C"}] |
| 2 | [] |
| 3 | [] |
| 4 | [{"qty": 5, "sku": "E"}] |
SELECT c.cart_id, COALESCE( jsonb_agg(e.elem ORDER BY e.pos) FILTER (WHERE (e.elem ->> 'qty')::int > 0), '[]'::jsonb -- 全滅したカートは空配列へ ) AS items FROM carts c LEFT JOIN LATERAL jsonb_array_elements(c.items) WITH ORDINALITY AS e(elem, pos) ON TRUE -- 元の位置を pos に残す GROUP BY c.cart_id ORDER BY c.cart_id; /* 実行順序(SQLの論理的な評価順): 1. FROM carts → 4行読み込み 2. LEFT JOIN LATERAL … WITH ORDINALITY → 明細を行へ展開し位置を付与(6行) 3. GROUP BY c.cart_id → カート単位の4グループへ戻す 4. FILTER (WHERE qty > 0) → 集約対象から qty 0 の明細を外す 5. SELECT jsonb_agg(... ORDER BY pos) → 元の順で配列へ組み直す 6. COALESCE(..., '[]') → 対象ゼロのカートを空配列へ 7. ORDER BY c.cart_id → cart_id の昇順 */
LEGEND
① FROM carts
FROM cartscarts 全4行を読み込みます。cart 1 は3明細、cart 2 と cart 4 は1明細、cart 3 は空配列です。qty が 0 の明細は cart 1 と cart 2 に入っています。| cart_id | items(jsonb) |
|---|---|
| 1 | [{"sku":"A","qty":2},{"sku":"B","qty":0},{"sku":"C","qty":1}] |
| 2 | [{"sku":"D","qty":0}] |
| 3 | [] |
| 4 | [{"sku":"E","qty":5}] |
WHERE はグループ分けの前に行を捨てるので、全滅したグループは結果から消えます。FILTER は集約関数ごとに対象を選ぶ仕組みなので、グループは残り「対象が0件」という状態を表現できます。1つのクエリで条件別の集計を並べたいときにも同じ形が使えます。WITH ORDINALITY で位置を残しておくと、組み直しの ORDER BY e.pos がそのまま「元の並び」を意味します。位置を残さずに組み直すと、要素の並びが実行計画任せになります。[] は「要素ゼロの配列」、NULL は「配列そのものが無い」です。アプリのコードが配列を前提に length を読むなら、SQL側で COALESCE(…, '[]'::jsonb) まで済ませておくほうが、分岐が1つ減ります。WHERE (e.elem ->> 'qty')::int > 0 にすると、明細が全滅した cart 2 と、もともと空だった cart 3 が結果から消えます。入力4行に対して出力2行になり、「消えたカートは存在しないのか、空なのか」が呼び出し側から区別できません。replace(items::text, …) のようなテキスト処理は、値に区切り文字が含まれた瞬間に壊れます。JSONの構造を保証したまま編集できるのは、展開と再集約か jsonb_set 系の関数だけです。UPDATE の対象が1バイトでも、Postgres は新しい行バージョンを1つ作るため、更新コストは配列の大きさに比例します。明細が数百件を超え、1件ずつ更新が走る使い方になってきたら、それは配列で持つ限界のサインです。加えて、変更の消失に注意が要ります。UPDATE … SET items = jsonb_set(items, …) のように列を参照する1文なら、後続の更新は行ロックを待ってから更新後の行へ適用されるので、別要素への同時更新は両方残ります。消えるのは、アプリが古い文書を読み、手元で組み直して全体を書き戻す形です——この場合、先に入った変更が後勝ちで上書きされます。読みと書きを分けるなら SELECT … FOR UPDATE か版数の照合を挟みます。要素単位の更新が要件に入った時点で、明細テーブルへの切り出しを検討します。