FORCIA CUBEフォルシアの情報を多面的に発信するブログ

フォルシアで旅行横断検索を主にエンジニアリングをしています。相澤といいます。 普段は主にPostgreSQLを使ってデータ処理の高速化とホテル名寄せに苦戦する日々を送っています。

少し前にPostgreSQL12が登場しましたね!
フォルシアで働く私としては検索が各種インデックスの性能改善がどの程度の物なのかが一番気になるところなのですが、合わせて

JSON Pathに対応

というのが気になりました。 実はいままでjson(jsonb)型データをあまり扱ったことがなかったので(PostgreSQLに他言語のデータ型を持ち込む理由が分からず、積極的に知りにいく機会がなかったので)、これを機に勉強したいと思います。
jsonbを1から学び始める前、スタート地点に立つまでの調査・確認ということで、「0までのjsonb」というタイトルでお送りします!

基本知識編(jsonbとは)

https://www.postgresql.jp/node/320

そもそもJSONというのはJavaScript のデータフォーマットです。 PostgreSQLでも9.2系からjson型がサポートされています。JavaScriptのJSONと違う点は、サーバ符号化方式がUTF-8でなければならない点となっています(公式ドキュメントには 厳密に仕様を満たすJSONに対応することができません と記載されていますが、厳密でないデータ形式に何の意味がありましょうか)。text型にjsonで文字を書くのと違い、json型になっている点で優っていますし、いくつかの関数が使用できます。

※ 以後、区別のためPostgreSQLのjsonのことのみを小文字でjsonと記載します。

余談では、ありますが弊社はかつてPostgreSQLにjsonが実装される前に、json型を独自定義し操作のための関数ライブラリを作成していました。 JSONはシンプルで可読性が高く、何かと便利なので、webアプリを作成する上であると便利なケースが多々あります。

公式文章によれば、PostgreSQL9.4からはjsonbという形式が現れました。これはjson型とは以下の点で異なるようです。

jsonデータ型は入力テキストの正確なコピーで格納し、処理関数を実行するたびに再解析する必要があります。 jsonbデータ型では、分解されたバイナリ形式で格納されます。 格納するときには変換のオーバーヘッドのため少し遅くなりますが、処理するときには、全く再解析が必要とされないので大幅に高速化されます。 また jsonb型の重要な利点はインデックスをサポートしていることです。

json型は入力値のコピーを格納しているので、意味的に重要でないトークン間の空白だけでなく、JSONオブジェクト内のキーの順序も維持します。 また、JSONオブジェクト内に同じキーと値が複数含まれていてもすべてのキー/値のペアが保持されます。(この処理関数は最後の値1つを処理させるようすれば済みます。) これとは対照的に、jsonbは空白を保持しません。オブジェクトキーの順序を保持せず、重複したオブジェクトキーを保持しません。重複キーを入力で指定された場合は、最後の値が保持されます。

PostgreSQL 12.4文書 より引用

JSONと違い、空白とkeyの重複が許されていないようです。とはいえ、まともにJSONを運用する場合、valueかkeyがなかったり空白だったり揺れたりするとバグの原因になりやすいですし、keyの重複ももってのほかですので、ほとんどのアプリケーションではjsonbでまったく問題がないのではないでしょうか。

そしてjsonbで便利な点はvalueのみの全文検索ができる点、そして高速な検索を実装するにあたって重要なことですがインデックスが張れるという点です。jsonbはGINインデックスを使用して、keyとvalueのペアの検索と @> 演算子(左のJSON値はトップレベルにおいて右のJSONパスまたは値を包含するか)をサポートするインデックスを作成することができます。

そしてPostgreSQL12からはjsonpath型というものが実装されました。これによって、jsonbの特定のpathにアクセスしやすくなり、特定の要素が存在するかどうかや一定以上の値かどうかをフィルターできるようになりました。jsonpathの注意点としては、大文字小文字の区別があることと、配列インデックスが1から始まる点で、このあたりはJavaScriptに浸食されてちょっと嫌な感じですね。

ここまで充実しているのであれば、あとは使ってみて理解すれば強い選択肢になりそうです!

基本実践編

さて、簡単にではありますが、これらの機能を使ってみたいと思います。

DB作成

まずUTF-8でDBを作成します。

createdb -E utf-8 jsontest

文字列からのjson型、jsonb型キャスト

jsonやjsonbはtextからキャストすることが出来ます。

# textをキャストできる
select '{"index":1,"value":"a"}'::json, '{"index":1,"value":"a"}'::jsonb;
            json         |           jsonb 
-------------------------+----------------------------
 {"index":1,"value":"a"} | {"index": 1, "value": "a"}

# textがjson形式でないときは以下のようなエラーになる
select '{"index":1:"value":"a"}'::json;
ERROR: invalid input syntax for type json
LINE 1: select '{"index":1:"value":"a"}'::json;
               ^
DETAIL: Expected "," or "}", but found ":".
CONTEXT: JSON data, line 1: {"index":1:... 

外部ファイルの使用

外部ファイルをCOPYしてjson型jsonb型データを作成することもできます。 COPYの際にはダブルクオーテーションとカンマがカラム中に必須になることから、CSVモードにせずtsvで取り込むのがよさそうです。

外部ファイル(タブ区切りtsv)

1   {"idx" : 1, "value" : "a a"}
2   {"idx" : 2, "value" : "b a"}
# 外部ファイルを使用できる。
drop table if exists testjson; 
create table testjson (
   idx int 
   ,json_column json 
); 
copy testjson from '/path/to/json.tsv' delimiter E'\t';

drop table if exists testjsonb; 
create table testjsonb (
   idx int 
   ,jsonb_column jsonb 
); 
copy testjsonb from '/path/to/json.tsv' delimiter E'\t';

まずはjson型, jsonb型のカラムを持つテーブルを作成してみます。

-- 元テーブルの作成 
DROP TABLE IF EXISTS testtext;
CREATE TABLE testtext AS (
  SELECT
    idx,
    concat('{"idx":',idx::text,',"value1":"', substring(md5(idx::text),1,2), '","value2":"', md5(idx::text),'"}') AS text_column
  FROM (
    SELECT generate_series(1,1000000) AS idx
  )s
);
ANALYZE testtext;

-- jsonテーブルの作成
DROP TABLE IF EXISTS testjson;
CREATE TABLE testjson AS (
  SELECT
    idx
    ,text_column::json AS json_colmun
  FROM 
    testtext
);
ANALYZE testjson;

-- jsonbテーブルの作成
DROP TABLE IF EXISTS testjsonb;
CREATE TABLE testjsonb AS (
  SELECT
    idx
    ,text_column::jsonb AS jsonb_colmun
  FROM 
    testtext
);
ANALYZE testjsonb;

データサイズはjsonb型が大きくなっていることがわかります。

SELECT
    relname
    ,(relpages / 128) AS mbytes
FROM pg_class
WHERE relname like 'test%'
ORDER BY relname;
  relname  | mbytes 
-----------+--------
 testjson  |    104
 testjsonb |    120
 testtext  |    104
(3 rows)

簡単な操作の確認

特定のパスの値を取り出す

-> int でjson配列要素、 -> text でjsonオブジェクトフィールドの取り出し、#> path でパスにあるJSONオブジェクトを取得。いずれの場合も >>> と書くとオブジェクトではなくtextにキャストされます。

select '[{"a":"foo"},{"b":"bar"},{"c":"baz"}]'::json->2;
  ?column?   
-------------
 {"c":"baz"}
(1 row)

select '{"a": {"b":"foo"}}'::json->'a';
  ?column?   
-------------
 {"b":"foo"}
(1 row)

select '{"a": {"b":{"c": "foo"}}}'::json#>'{a,b}';
   ?column?   
--------------
 {"c": "foo"}
(1 row)

# -> はjsonbのままなので合わせ技もできます
select '[{"a":"foo"},{"b":"bar"},{"c":"baz"}]'::json->2->'c';
 ?column? 
----------
 "baz"
(1 row)

# 存在しないpathは空になっています(エラーにはなりません)
select '{"a":{"b":{"c":"d"}}}'::jsonb->'a'->'c';
 ?column? 
----------
 
(1 row)

パスの追加と削除

追加は || で 削除は - です。(シンプルですね!)

select '{"a":"b"}'::jsonb || '{"c":"d"}'::jsonb;
       ?column?       
----------------------
 {"a": "b", "c": "d"}
(1 row)

select '{"a":"b","c":"d"}'::jsonb - 'a';
  ?column?  
------------
 {"c": "d"}
(1 row)

なお、追加の際に同じkeyをとることができないので右辺が優先されるようです。

select '{"a":"b"}'::jsonb || '{"a":"c"}'::jsonb;
  ?column?  
------------
 {"a": "c"}
(1 row)

トップレベルキーの存在チェック

? text textというトップレベルキーが存在するかどうか。

select '{"a":"b","c":"d"}'::jsonb?'a';
 ?column? 
----------
 t
(1 row)

select '{"a":"b","c":"d"}'::jsonb?'b';
 ?column? 
----------
 f
(1 row)

select '{"a":"b","c":"d"}'::jsonb?'c';
 ?column? 
----------
 t
(1 row)

?| array text 配列中のtextのトップレベルキーが一つでも存在するかどうか。

select '{"a":"b","c":"d"}'::jsonb?|array['b','c'];
 ?column? 
----------
 t
(1 row)

?& array text 配列中のtextのトップレベルキーがすべて存在するかどうか。

select '{"a":"b","c":"d"}'::jsonb?&array['b','c'];
 ?column? 
----------
 f
(1 row)

select '{"a":"b","c":"d"}'::jsonb?&array['a','c'];
 ?column? 
----------
 t
(1 row)

pathとvalueの組み合わせを問い合わせる

前述の演算子->あるいは #> を組み合わせます。

select '{"a":{"b":{"c":"d"}}}'::jsonb#>'{"a","b","c"}' ? 'd';
 ?column? 
----------
 t
(1 row)

トップレベルにおいて右辺のjsonbを含むかどうか

@>を使用します。

select '{"a":{"b":{"c":"d"}}}'::jsonb @> '{"c":"d"}'::jsonb;
 ?column? 
----------
 f
(1 row)

select '{"a":{"b":{"c":"d"}}}'::jsonb->'a'->'b' @> '{"c":"d"}'::jsonb;
 ?column? 
----------
 t
(1 row)

インデックス付与(高速化)

公式ドキュメントによると「トップレベルキーの存在チェック」「keyとvalueの組み合わせ」「右辺のjsonbを含むかどうか」でindexが有効に活用できるようです。それぞれ確認してみましょう。

トップレベルキーの存在チェック(すべての場合ヒットする場合と一部のみヒットする場合)

indexなしで検索を行う場合。

EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun) ? 'value1' ;
                                                           QUERY PLAN                                                           
--------------------------------------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..21693.33 rows=1000 width=92) (actual time=0.121..235.050 rows=1000000 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Parallel Seq Scan on testjsonb  (cost=0.00..20593.33 rows=417 width=92) (actual time=0.015..121.201 rows=333333 loops=3)
         Filter: (jsonb_colmun ? 'value1'::text)
 Planning Time: 0.068 ms
 Execution Time: 289.437 ms
(7 rows)

UPDATE testjsonb SET jsonb_colmun = jsonb_colmun || '{"value3":"1"}'::jsonb WHERE (jsonb_colmun->'idx')::int4 % 100 = 0; -- 1%のカラムにキーを足す
ANALYZE testjsonb;
EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun) ? 'value3' ;
                                                          QUERY PLAN                                                           
-------------------------------------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..21866.33 rows=1000 width=93) (actual time=36.314..123.155 rows=10000 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Parallel Seq Scan on testjsonb  (cost=0.00..20766.33 rows=417 width=93) (actual time=33.251..118.628 rows=3333 loops=3)
         Filter: (jsonb_colmun ? 'value3'::text)
         Rows Removed by Filter: 330000
 Planning Time: 0.074 ms
 Execution Time: 123.707 ms
(8 rows)

単純にjsonbカラムにGINを張った場合は、トップレベルキーの存在チェックが高速化します。

しかしながら必ずindexが使用されてしまい、すべてのレコードが持っているvalue1というカラムに対して存在チェックを行ってもindexが使用されます。
以下の2つの理由で検索が遅くなるようです。

  • indexを使用している分IOが発生しているため
  • workerが分岐しないため
DROP INDEX IF EXISTS idxgin;
CREATE INDEX idxgin ON testjsonb USING GIN (jsonb_colmun);
ANALYZE testjsonb;
EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun) ? 'value1' ;
                                                         QUERY PLAN                                                         
----------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on testjsonb  (cost=27.75..3186.69 rows=1000 width=92) (actual time=79.083..441.709 rows=1000000 loops=1)
   Recheck Cond: (jsonb_colmun ? 'value1'::text)
   Heap Blocks: exact=15385
   ->  Bitmap Index Scan on idxgin  (cost=0.00..27.50 rows=1000 width=0) (actual time=75.965..75.966 rows=1000000 loops=1)
         Index Cond: (jsonb_colmun ? 'value1'::text)
 Planning Time: 0.117 ms
 Execution Time: 493.719 ms <-- 遅くなっています
(7 rows)

ANALYZE testjsonb;
EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun) ? 'value3' ;
                                                      QUERY PLAN                                                       
-----------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on testjsonb  (cost=27.75..3190.76 rows=1000 width=93) (actual time=0.764..4.349 rows=10000 loops=1)
   Recheck Cond: (jsonb_colmun ? 'value3'::text)
   Heap Blocks: exact=174
   ->  Bitmap Index Scan on idxgin  (cost=0.00..27.50 rows=1000 width=0) (actual time=0.733..0.733 rows=10000 loops=1)
         Index Cond: (jsonb_colmun ? 'value3'::text)
 Planning Time: 0.138 ms
 Execution Time: 4.883 ms
(7 rows)

keyとvalueの組み合わせ

indexなしで検索を行う場合。

EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun->'value1') ? '00';
                                                          QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..22735.00 rows=1000 width=92) (actual time=0.432..210.369 rows=3878 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Parallel Seq Scan on testjsonb  (cost=0.00..21635.00 rows=417 width=92) (actual time=0.371..205.503 rows=1293 loops=3)
         Filter: ((jsonb_colmun -> 'value1'::text) ? '00'::text)
         Rows Removed by Filter: 332041
 Planning Time: 0.068 ms
 Execution Time: 210.628 ms
(8 rows)

GINを以下のように使用することでkeyとvalueの組み合わせが高速化します。

DROP INDEX IF EXISTS idxgintag;
CREATE INDEX idxgintag ON testjsonb USING GIN ((jsonb_colmun->'value1'));
ANALYZE testjsonb;
EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun->'value1') ? '00';
                                                       QUERY PLAN                                                        
-------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on testjsonb  (cost=19.75..3181.19 rows=1000 width=92) (actual time=2.741..23.530 rows=3878 loops=1)
   Recheck Cond: ((jsonb_colmun -> 'value1'::text) ? '00'::text)
   Heap Blocks: exact=3438
   ->  Bitmap Index Scan on idxgintag  (cost=0.00..19.50 rows=1000 width=0) (actual time=1.176..1.176 rows=3878 loops=1)
         Index Cond: ((jsonb_colmun -> 'value1'::text) ? '00'::text)
 Planning Time: 0.150 ms
 Execution Time: 23.939 ms
(7 rows)

なお、確認してみたのですがjsonの内容をtext型で返させる ->> という演算子を使用した場合には、indexは使用されないようです(当たり前といえば当たり前ですが)。

SELECT * FROM testjsonb WHERE (jsonb_colmun->>'value1') = '00';

右辺のjsonbを含むかどうか

indexなしで検索を行う場合。

EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE jsonb_colmun @> '{"value1":"00"}'::jsonb;
                                                          QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..21693.33 rows=1000 width=92) (actual time=0.368..155.156 rows=3878 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Parallel Seq Scan on testjsonb  (cost=0.00..20593.33 rows=417 width=92) (actual time=0.184..151.480 rows=1293 loops=3)
         Filter: (jsonb_colmun @> '{"value1": "00"}'::jsonb)
         Rows Removed by Filter: 332041
 Planning Time: 0.034 ms
 Execution Time: 155.405 ms
(8 rows)

jsonb_path_opsを選択してGINを貼ると @> 検索が高速化します。

DROP INDEX IF EXISTS idxginp;
CREATE INDEX idxginp ON testjsonb USING GIN (jsonb_colmun jsonb_path_ops);
ANALYZE testjsonb;
EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE jsonb_colmun @> '{"value1":"00"}'::jsonb;
                                                      QUERY PLAN                                                       
-----------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on testjsonb  (cost=27.75..3186.69 rows=1000 width=92) (actual time=1.390..6.162 rows=3878 loops=1)
   Recheck Cond: (jsonb_colmun @> '{"value1": "00"}'::jsonb)
   Heap Blocks: exact=3438
   ->  Bitmap Index Scan on idxginp  (cost=0.00..27.50 rows=1000 width=0) (actual time=0.606..0.606 rows=3878 loops=1)
         Index Cond: (jsonb_colmun @> '{"value1": "00"}'::jsonb)
 Planning Time: 0.140 ms
 Execution Time: 6.437 ms
(7 rows)

キーワード検索

jsonbの使い方・・・というわけではありませんが、一部のvalueに部分一致検索をしたいときは、以下のようにしてpg_bigm indexを使用することができます。

DROP EXTENSION IF EXISTS pg_bigm CASCADE;
DROP INDEX IF EXISTS idx_pg_bigm;
CREATE EXTENSION pg_bigm;
CREATE INDEX idx_pg_bigm ON testjsonb USING gin (((jsonb_colmun->>'value2')) gin_bigm_ops);
ANALYZE testjsonb;
EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun->>'value2') like '%abcd%';
                                                          QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on testjsonb  (cost=126.00..13407.36 rows=8000 width=92) (actual time=21.786..39.752 rows=424 loops=1)
   Recheck Cond: ((jsonb_colmun ->> 'value2'::text) ~~ '%abcd%'::text)
   Rows Removed by Index Recheck: 2311
   Heap Blocks: exact=2488
   ->  Bitmap Index Scan on idx_pg_bigm  (cost=0.00..124.00 rows=8000 width=0) (actual time=21.103..21.103 rows=2735 loops=1)
         Index Cond: ((jsonb_colmun ->> 'value2'::text) ~~ '%abcd%'::text)
 Planning Time: 0.251 ms
 Execution Time: 39.804 ms
(8 rows)

それぞれのインデックスサイズは以下の通りです。

SELECT
    indexname
    ,pg_relation_size(indexname::regclass)/(1024*1024) as mbyte -- データサイズをmbyte単位で表示
FROM pg_indexes
WHERE schemaname = 'public' and indexname like 'idx%';
  indexname  | mbyte 
-------------+-------
 idxgin      |   139
 idxgintag   |     2
 idxginp     |    69
 idx_pg_bigm |    38
(4 rows)

インデックスサイズはケースバイケースなのであまりあてにはなりませんが、ご参考までに。
(今回は英数字の乱数のカラムを使っていますが、bigmインデックスを貼る対象として日本語を使うと2文字列の組み合わせが増大してしまいますし、jsonの構造が複雑になるほど他のindexも増加していきます。)

jsonpath演算子

jsonpath演算子はjsonのオブジェクトフィールドにアクセスする記法の一つです。

これを使って、簡単なフィルター式(比較演算子、論理演算子、存在のチェック、パターンマッチ など)を経て得られる値や配列に、簡単な処理(数学的処理、keyvalue)を加えたものを取得できます。フィルタリングにはindexが適用されます。

記法はややJavaScript寄りです。等価演算子でフィルタリングしてみます。等価演算子は ==となっていたりします (なお厳密等価演算子 === は使用できません) 。配列はインデックスも1から始まります。 また、以下の例の場合where句を書いていないですが、すべてのフィルター式に偽値を返すレコードは落ちてしまいます。

SELECT
    idx, jsonb_path_query(jsonb_colmun, '$[*]?(@.value1 == "00").value2') -- トップレベルキーvalue1 == "00" のレコードのvalue2を取得したい
FROM
    testjsonb
ORDER BY idx
LIMIT 5
;

 idx  |          jsonb_path_query          
------+------------------------------------
  168 | "006f52e9102a8d3be2fe5614f42ba989"
  363 | "00411460f7c92d2124a67ea0f4cb5f85"
  381 | "00ec53c4682d36f5c4359f4ae7bd7ba1"
  610 | "00ac8ed3b4327bdd4ebbebcb2ba10a00"
 1164 | "00e26af6ac3b1c1c49d7c3d79c60d000"
(5 rows)

SELECT
    idx
    ,jsonb_path_query(jsonb_colmun, '$[*]?(@.value1 == "00").value2') -- value1 == "00" のレコードのvalue2を取得したい
    ,jsonb_path_query(jsonb_colmun, '$[*]?(@.value1 == "01").value2') -- value1 == "01" のレコードのvalue2を取得したい
FROM
    testjsonb
ORDER BY idx
LIMIT 5
;
 idx |          jsonb_path_query          |          jsonb_path_query          
-----+------------------------------------+------------------------------------
 138 |                                    | "013d407166ec4fa56eb1e1f8cbe183b9"
 168 | "006f52e9102a8d3be2fe5614f42ba989" | 
 236 |                                    | "01161aaa0b6d1345dd8fe4e481144d84"
 348 |                                    | "01386bd6d8e091c2ab4c7c7de644d37b"
 363 | "00411460f7c92d2124a67ea0f4cb5f85" | 
(5 rows)

jsonbの基本的な操作はここまでです。

jsonpath式は若干filiter式に罠がありますが、基本的な操作が出揃っているようですね。

使用についての展望

jsonオブジェクトや配列に何でもデータを突っ込むのは、SQLのアンチパターンにほかなりません。 ここに書いてあることだけでもキャッチアップするのは面倒ですし、いろいろと罠があることが見えてきています。 CSVモードで取り込む際には、工夫が要りますし、工夫がいること自体がバグの温床のように思えます。 jsonpathも、自分が担当しているアプリに新しい担当者が付いた時など、すんなりと理解しミスを犯さず運用してもらうのは難しいと思いました。

ただ、以下のような条件を兼ねそろえている場合は有効に使えるのではないかと思いました。

  • SQL上でカラムからjsonを組み立て、webアプリで使用する
  • 紹介したパターンにありますがcsvやtxtを取り込んでjsonbを作る場合、カラムの型チェックや妥当性の評価、余分な文字の排除などが効きません。まずtsvやcsvを取り込み、アプリで使用する形に組み立てる分にはいいのではないでしょうか
  • 私自身の経験では、jsonを返すはずのAPIの返却値を取り込んでDBに格納しようとした際に、実際には返却値がjsonになっておらず取り込みに失敗した経験もあります
  • 単純なjsonを出力する(言い換えれば以下のようなアンチパターンがありそうです)
    • 外部ファイルのcsv, tsvをjsonb型としてCOPYコマンドで取り込む
    • 人がチェックすることが困難になり、ミスも生まれやすくなりそう
    • なんでもかんでもjsonにしてしまう
    • 複雑なjson構造にしてしまう

例えばですが、私が担当している宿横断検索アプリなどの場合、宿泊施設データ管理上のイメージ画像(urlと画像タイプの2つの情報があり、数は施設ごとにまちまち)をjsonbとして持つのはいいと思いました。 pathとvalueの組み合わせでしか操作することがなく、パターンマッチなどもせず、シンプルです。
こういった画像用のテーブルなどを用意するのが不要だと感じる際には良いと思います。

{
    imageNum: 4,
    images [
        {
            "url" : "https://domain.co.jp/image/hotelXXXX/gaikan.gif",
            "type" : "外観"
        },{
            "url" : "https://domain.co.jp/image/hotelXXXX/huro.gif",
            "type" : "浴室"
        },{
            "url" : "https://domain.co.jp/image/hotelXXXX/heya1.gif",
            "type" : "室内"
        },{
            "url" : "https://domain.co.jp/image/hotelXXXX/dinner.gif",
            "type" : "食事"
        }
    ]
}

こういった選択肢は持っていること自体が強いので、乱用せずに使える範囲で使用していきたいですね!

この記事を書いた人

相澤 幸大朗

2016年新卒入社。事業開発部エンジニア。
主業務は宿泊商材の旅行会社横断検索を提供するアプリケーションの開発保守運用、及びアプリ開発基盤タスクフォース。
会社の熱帯魚の水槽の管理に頭を悩ます29歳、一児の父。
躰道初段。好きな躰道五条訓は「態端正にして心形の一体を図り態位正しきを得れば侮られる事なし」。