はじめに
前回では、動的な要件に対応するための「メタデータ駆動設計」と、データを保存する「JSONB/EAV」の役割について学びました。
今回はいよいよ実装編です。メタデータ駆動設計の心臓部となる「メタデータリポジトリ(設計図の保存場所)」を設計・構築していきます。
本記事は、DockerとPostgreSQLを使って手元の環境を汚さずに試せる完全ハンズオン形式になっています。ぜひ実際に手を動かしながら「システムの中にシステムを作る」感覚を味わってみてください。
1. 今回構築するアーキテクチャ(ER図)
目標は、「プログラムのソースコードを書き換えることなく、新しい『業務アプリ(例:日報アプリ)』をデータベース上の設定だけで作れるようにすること」です。
これを実現するために、以下の3つのテーブルを作成します。
-
applications: アプリの存在を管理するテーブル(親) -
field_definitions: アプリの「入力項目」を定義するテーブル(メタデータの本体) -
app_records: 実際のユーザー入力を保存するテーブル(JSONBを活用)
2. Hands-on
version: '3.8'
services:
db:
image: postgres:15-alpine
container_name: metadata_db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: app_db
ports:
- "5435:5432"
volumes:
- ./init:/docker-entrypoint-initdb.d
restart: unless-stopped
-- 1. アプリケーション管理テーブル
CREATE TABLE applications (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 2. フィールド定義テーブル(メタデータ)
CREATE TABLE field_definitions (
id SERIAL PRIMARY KEY,
app_id INT REFERENCES applications(id) ON DELETE CASCADE,
field_key VARCHAR(50) NOT NULL, -- 例: "report_date", "title"
field_label VARCHAR(100) NOT NULL, -- 例: "日報日付", "タイトル"
field_type VARCHAR(20) NOT NULL, -- 例: "date", "text", "number"
is_required BOOLEAN DEFAULT FALSE, -- 必須チェック用フラグ
display_order INT NOT NULL, -- 画面上の並び順
UNIQUE (app_id, field_key) -- 1つのアプリ内でキーは一意
);
-- 3. 実データ保存テーブル(JSONBハイブリッド)
CREATE TABLE app_records (
id SERIAL PRIMARY KEY,
app_id INT REFERENCES applications(id) ON DELETE CASCADE,
-- 動的な入力値はすべてこのJSONBカラムに格納する
dynamic_data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- JSONB検索を爆速にするGINインデックス
CREATE INDEX idx_app_records_dynamic_data ON app_records USING GIN (dynamic_data);
-- アプリを登録 (ID=1になるよう明示的にIDを指定するか、普通にINSERTする。ここではSERIALに任せる)
INSERT INTO applications (name, description)
VALUES ('営業日報アプリ', '日々の営業活動を報告するアプリ');
-- そのアプリの「項目(フィールド)」を定義する (直前にINSERTしたのがID=1と想定)
INSERT INTO field_definitions (app_id, field_key, field_label, field_type, is_required, display_order) VALUES
(1, 'report_date', '報告日', 'date', TRUE, 10),
(1, 'sales_amount', '本日の売上', 'number', FALSE, 20),
(1, 'report_body', '所感', 'text', TRUE, 30);
-- ユーザーが入力した日報データを保存
INSERT INTO app_records (app_id, dynamic_data) VALUES
(1, '{"report_date": "2026-07-02", "sales_amount": 150000, "report_body": "A社から受注しました!"}'),
(1, '{"report_date": "2026-07-03", "sales_amount": 0, "report_body": "終日社内作業。"}');
3. ハンズオン:メタデータ駆動設計でのSQLユースケース
ここからは、登録したJSONBデータに対してどのようなSQLが発行できるのか、その特徴を見ていきます。
より実践的なユースケースを試すため、まずは「タスク管理アプリ」などの追加データを少し投入してみましょう。
-- タスク管理アプリ(app_id=2)を登録
INSERT INTO applications (id, name, description) VALUES (2, 'タスク管理アプリ', 'チームのタスクを管理します');
INSERT INTO field_definitions (app_id, field_key, field_label, field_type, is_required, display_order) VALUES
(2, 'task_title', 'タスク名', 'text', TRUE, 10),
(2, 'status', 'ステータス', 'text', TRUE, 20),
(2, 'due_date', '期限', 'date', FALSE, 30),
(2, 'priority', '優先度', 'number', FALSE, 40);
-- タスクデータの登録
INSERT INTO app_records (app_id, dynamic_data) VALUES
(2, '{"task_title": "要件定義書の作成", "status": "進行中", "due_date": "2026-07-10", "priority": 1}'),
(2, '{"task_title": "DB設計", "status": "未着手", "due_date": "2026-07-12", "priority": 2}'),
(2, '{"task_title": "モックアップ作成", "status": "完了", "due_date": "2026-07-01", "priority": 3}');
-- 営業日報アプリ(app_id=1)のデータも追加
INSERT INTO app_records (app_id, dynamic_data) VALUES
(1, '{"report_date": "2026-07-04", "sales_amount": 50000, "report_body": "B社へ訪問。見積もり提示"}'),
(1, '{"report_date": "2026-07-05", "sales_amount": 200000, "report_body": "C社案件受注。"}');
準備ができたところで、得意なSQLと苦手なSQLを見ていきます。
得意なこと①:柔軟なデータ抽出(RDB風の表形式表示)
JSONのキーを指定して値を取り出す ->> 演算子を使うと、まるで普通のテーブルカラムのようにデータを扱うことができます。
-- 日報アプリの「日付」と「売上」だけを取り出して表示する
SELECT
id,
dynamic_data->>'report_date' AS report_date,
dynamic_data->>'sales_amount' AS sales_amount
FROM app_records
WHERE app_id = 1
ORDER BY dynamic_data->>'report_date' DESC;
得意なこと②:値を使った条件検索
->> で取得した値はテキスト型になるため、数値比較をする際は CAST を利用します。
-- 売上が 100,000 以上の報告だけを抽出
SELECT
id,
dynamic_data->>'report_date' AS report_date,
CAST(dynamic_data->>'sales_amount' AS INTEGER) AS sales_amount
FROM app_records
WHERE app_id = 1
AND CAST(dynamic_data->>'sales_amount' AS INTEGER) >= 100000;
得意なこと③:GINインデックスを活かした爆速な「完全一致」検索
@> (包含演算子)を使うと、「JSONの中にこのキーと値のペアが含まれているか」をチェックできます。あらかじめ張っておいたGINインデックスが強力に効くため、大量データでも一瞬で検索可能です。
-- タスク管理アプリのうち、ステータスが「完了」のものだけを検索
SELECT id, dynamic_data
FROM app_records
WHERE app_id = 2
AND dynamic_data @> '{"status": "完了"}';
得意なこと④:あいまい検索(LIKE)
もちろん、特定の項目に対するテキストの部分一致検索も可能です。
-- 日報の「所感」に「受注」という文字が含まれるものを検索
SELECT
id,
dynamic_data->>'report_date' AS date,
dynamic_data->>'report_body' AS body
FROM app_records
WHERE app_id = 1
AND dynamic_data->>'report_body' LIKE '%受注%';
4. この設計の「苦手なこと」と解決策
柔軟で強力な仕組みですが、以下のようなRDB本来の機能は弱くなります。
苦手なこと①:集計関数(GROUP BY, SUMなど)
JSONから値を取り出し、型変換をしてから集計する必要があるため、SQLが複雑化しパフォーマンスも落ちやすくなります。
-- [苦手例] 月ごとの売上合計を出したい場合(SQLが煩雑になりがち)
SELECT
SUBSTRING(dynamic_data->>'report_date', 1, 7) AS target_month,
SUM(CAST(dynamic_data->>'sales_amount' AS INTEGER)) AS total_sales
FROM app_records
WHERE app_id = 1
GROUP BY SUBSTRING(dynamic_data->>'report_date', 1, 7);
苦手なこと②:データベースレベルでの「型」と「必須」の制約担保
本来であれば NOT NULL や INT などの制約で不正データを弾けますが、JSONBには制約がありません。
-- [苦手例] 型を無視したデータもそのまま保存できてしまう
INSERT INTO app_records (app_id, dynamic_data)
VALUES (1, '{"sales_amount": "たくさん売れた", "hoge": "fuga"}');
【解決策】
ここで重要になるのが、「バックエンド(API)側での制御」です。
フロントエンドから送られてきたデータに対し、バックエンドが field_definitions(メタデータ)を読み込み、「sales_amount は number 型か?」「必須項目は埋まっているか?」をプログラム側でバリデーションする仕組みが必要不可欠となります。





