採点されません。匿名です。 間違えることは学習の一部です。読むだけでもかまいません。

PostgreSQL 固有の文法と機能

テクノロジ系 / db (PDO / SQL / マルチDB)

PostgreSQL 固有の文法と機能

Lesson 11 / Chapter 12

なぜ PostgreSQL を使うのか (MySQL との選択ポイント)

場面PostgreSQL の強み
採番後すぐ id を使いたいRETURNING (1 ステートメント)
タグなど可変長の値配列型 (TEXT[]) で 1 カラム表現
半構造化データJSONB で検索・GIN インデックス可
厳密な型システム文字列 → 数値の暗黙変換が無い・型エラーで気付ける
高度な SQLCTE / Window 関数 / GENERATED ALWAYS

→ 「データ整合性 + 半構造化への対応」が要件なら PostgreSQL。

SERIAL で連番 ID を発行する

-- PostgreSQL
CREATE TABLE notes (
    id SERIAL PRIMARY KEY,   -- 内部でシーケンスを作って 1, 2, 3, ... を採番
    body TEXT NOT NULL
);
内部の正体SEQUENCE + DEFAULT nextval('notes_id_seq')
大きい型が欲しいBIGSERIAL (= BIGINT 連番)
MySQL 相当INT AUTO_INCREMENT

PostgreSQL 10 以降は GENERATED ALWAYS AS IDENTITY が推奨 (標準SQL準拠) だが、現場では SERIAL が広く使われる。

RETURNING 句 — INSERT 直後の値を 1 往復で取る

INSERT INTO notes (body)
VALUES ('hello')
RETURNING id, created_at;
観点MySQL lastInsertId()PostgreSQL RETURNING
取れる値AUTO_INCREMENT 1 つだけ任意のカラム複数
往復回数INSERT + 取得1 ステートメント
UPDATE / DELETE不可可 (更新前後の値を取れる)

PHP からは $stmt->execute(...) 後に $stmt->fetchColumn() / $stmt->fetch() で受け取る。

\dtpg_tables — テーブル一覧

用途PostgreSQLMySQL
psql 上で一覧\dtSHOW TABLES;
SQL でプログラマブルにSELECT tablename FROM pg_tables WHERE schemaname='public';SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_SCHEMA = DATABASE();
カラム定義を見たい\d notesDESCRIBE notes;

\d, \dt, \dn などの バックスラッシュコマンド は psql クライアントの機能。SQL ではないので PHP からは叩けない。

データ型のクイックリファレンス

用途PostgreSQLMySQL 相当
小さい整数SMALLINTSMALLINT
普通の整数INTEGERINT
大きい整数BIGINTBIGINT
長文TEXT (長さ無制限)TEXT (型でサイズ別)
短文VARCHAR(n)VARCHAR(n)
日時 (TZ 無視)TIMESTAMPDATETIME
日時 (TZ あり)TIMESTAMPTZ(なし・アプリで管理)
真偽値BOOLEANTINYINT(1) で代用が多い

PostgreSQL は 型に厳密'5' + 3 のような暗黙変換は基本的にエラー。

配列型 TEXT[] — タグを 1 カラムで持つ

CREATE TABLE articles (
    id INT PRIMARY KEY,
    title TEXT NOT NULL,
    tags TEXT[] NOT NULL
);
INSERT INTO articles VALUES (1, 'PHP入門', ARRAY['php','tutorial']);

-- 'php' を含む記事を取る
SELECT title FROM articles WHERE 'php' = ANY(tags);
演算子意味
'x' = ANY(arr)x が含まれる
arr1 @> arr2arr1 は arr2 を含む (集合の包含)
arr1 && arr2共通要素あり (積集合 ≠ ∅)
`arr1 \\arr2`連結

中間テーブル不要で GIN インデックス が貼れる。

JSONB — 検索可能な JSON

CREATE TABLE products (
    id INT PRIMARY KEY,
    spec JSONB NOT NULL
);

-- brand を取り出す
SELECT spec->>'brand' FROM products;  -- text として
SELECT spec->'brand'  FROM products;  -- jsonb のまま

-- brand = 'acme' で検索
SELECT * FROM products WHERE spec->>'brand' = 'acme';
観点JSONJSONB
表現テキストバイナリ (正規化)
空白・キー順保持失う
重複キー保持後勝ち
インデックス不可GIN インデックス可
用途原文ログ保管通常はこちら

ハマりどころ 1 — TIMESTAMPTZ vs TIMESTAMP

TIMESTAMP        -- タイムゾーン情報を持たない (壁時計のような値)
TIMESTAMPTZ      -- UTC で保存・取得時にセッション TZ に変換
  • TIMESTAMP '2026-05-18 12:00' → どこの12時か分からない
  • TIMESTAMPTZ '2026-05-18 12:00+09' → UTC 03:00 として保存

実務では基本 TIMESTAMPTZ を選ぶ。複数タイムゾーンのユーザーがいると TIMESTAMP は破綻する。

ハマりどころ 2 — 識別子の大文字小文字

CREATE TABLE Users (id INT);    -- 実体は "users" に小文字化される
SELECT * FROM "Users";          -- ERROR (実体は users なので)
SELECT * FROM Users;            -- OK (小文字化されてマッチ)
SELECT * FROM "users";          -- OK
  • PostgreSQL は クォートしない識別子をデフォルトで小文字に畳む
  • ダブルクォートで囲うと大文字小文字を保持するが「以降ずっとクォートが必要」になる
  • 常に snake_case (users, created_at) で書く のが鉄則

MySQL は OS の大文字小文字区別設定に依存 (Linux 大小区別 / macOS 区別しない) と更にややこしい。

このチャプターでできるようになること

SERIAL / BIGSERIAL で連番 ID を発行できる

RETURNING 句で INSERT 直後の値を 1 ステートメントで取れる

\dt / pg_tables でテーブル一覧を取れる

✅ 主要データ型 (INTEGER / TEXT / TIMESTAMPTZ / BOOLEAN) を選べる

✅ 配列型 TEXT[]ANY() 演算子でタグ検索できる

JSONB->> / -> で JSON のキーを抽出できる

JSONB vs JSON を使い分けられる

→ ドリル 3 問へ (SQLite で動かしつつ PostgreSQL 固有の構文を理解)

考えてみよう

既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。

ドリル 01 — SERIAL + RETURNING で 1 ステートメント採番

問題

notes テーブルに body を 3 件 INSERT し、INSERT 直後の id を取得して 1 件ずつ次のフォーマットで出力する。

inserted id: <id>

PostgreSQL では INSERT ... RETURNING id が使えるため INSERT と id 取得が 1 ステートメント で済む。MySQL / SQLite には RETURNING が無い (※) ので、INSERTlastInsertId() の 2 段階になる。

> ※ MySQL 8 / SQLite 3.35 で RETURNING がサポートされたが、ポータビリティのため本ドリルでは lastInsertId() で書く想定。

期待される出力

inserted id: 1
inserted id: 2
inserted id: 3

採点

php scripts/grade.php topics/db/ch12-postgres-specific/drill/01-serial-returning/

採点ランナーはデフォルトで SQLite を使うので、AUTOINCREMENT カラムへの INSERT → lastInsertId() の経路で PASS する。

PostgreSQL モード (DOJO_DB_DRIVER=pgsql) で動かしたときは tests/setup.pgsql.sql が選ばれ、SERIAL + RETURNING の経路を通る。

ヒント

  • 採用ドライバは getenv('DOJO_DB_DRIVER') ?: 'sqlite' で判定する
  • PostgreSQL: RETURNING id の戻り値は $stmt->fetchColumn() で 1 つの値として取れる
  • SQLite / MySQL: INSERT 後に $pdo->lastInsertId() で取得する
  • ループで body を 3 回 INSERT すれば id は 1, 2, 3 と採番される
  • scripts/shared/db-connect.phpdojo_db_connect() を使うと PDO を driver 非依存に取得できる

なぜ PostgreSQL の RETURNING が便利か

INSERT した行の (シーケンスで採番された) idcreated_at などの DB 側で自動入力された値を 1 往復 で取得できる。MySQL の lastInsertId() は AUTO_INCREMENT カラム 1 つに限定だが、RETURNING任意のカラム を返せる:

INSERT INTO notes (body) VALUES ('hello')
RETURNING id, created_at, edit_token;

→ 「INSERT 後に SELECT で取り直す」が不要になる。

ドリル 02 — タグ配列を扱う (PostgreSQL TEXT[])

問題

articles テーブルから タグに "php" を含む記事の title を id 順で出力する。

PHP入門
Laravel入門

タグの格納形式は driver で異なる:

driverカラム型中身
PostgreSQLTEXT[] (配列型)ARRAY['php','tutorial','beginner']
MySQLJSON (配列型は無いので JSON で代替)'["php","tutorial","beginner"]'
SQLiteTEXT (JSON 文字列)'["php","tutorial","beginner"]'

→ WHERE 句も driver で変わる。

driverWHERE 句
PostgreSQL'php' = ANY(tags)
MySQLJSON_CONTAINS(tags, '"php"')
SQLiteEXISTS (SELECT 1 FROM json_each(tags) WHERE value = 'php')

期待される出力

PHP入門
Laravel入門

採点

php scripts/grade.php topics/db/ch12-postgres-specific/drill/02-text-array/

デフォルトで SQLite 経路 (json_each) が走って PASS する。

ヒント

  • getenv('DOJO_DB_DRIVER') ?: 'sqlite' で分岐
  • SQLite の json_each(tags) は JSON 配列を行に展開するテーブル値関数
  • PostgreSQL の ANY(配列)配列内のどれかと一致 という意味
  • 結果は ORDER BY id で並べる
  • fetchAll(PDO::FETCH_COLUMN)title 列だけ取り出すと echo が楽

なぜ配列型を持つ DB は便利か

タグや「複数選択値」を「中間テーブル + JOIN」せずに 1 カラム で表せる。

  • 中間テーブル不要 → クエリがシンプル
  • GIN インデックスを張れる → WHERE 'php' = ANY(tags) が高速
  • 配列演算子 (@>, &&, ||) で集合演算ができる

ただし「タグごとに属性 (created_by など) を持たせたい」なら中間テーブルが必要なので 使い分け が肝心。

ドリル 03 — JSON 内の値で検索する (PostgreSQL JSONB)

問題

products テーブルから spec.brand'acme' の商品を id 順で抽出し、name: brand の形式で出力する。

ノートPC: acme
マウス: acme

driver で JSON 抽出のシンタックスが異なる:

driverカラム型brand 抽出
PostgreSQLJSONBspec->>'brand'
MySQLJSONJSON_UNQUOTE(JSON_EXTRACT(spec, '$.brand'))
SQLiteTEXT (JSON 文字列)json_extract(spec, '$.brand')

期待される出力

ノートPC: acme
マウス: acme

採点

php scripts/grade.php topics/db/ch12-postgres-specific/drill/03-jsonb-or-json/

ヒント

  • getenv('DOJO_DB_DRIVER') ?: 'sqlite' で分岐
  • PostgreSQL の ->JSON のまま 返し、->>テキスト で返す。比較したいときは ->>
  • SQLite の json_extract(col, '$.key') は組み込み関数 (3.38 以降は col -> '$.key' でも OK)
  • MySQL の JSON_EXTRACT() は二重引用符付き値を返すので JSON_UNQUOTE() で剥がす
  • 出力フォーマット: name: brand (コロンの後ろにスペース 1 つ)
  • ORDER BY id

JSONB と JSON の違い (PostgreSQL)

観点JSONJSONB
内部表現テキスト (入力そのまま)バイナリ (正規化)
入力時の空白保持削除
キーの重複保持最後の値で上書き
順序保持失われる
インデックス不可GIN インデックス可
検索性能遅い (パース必須)速い
推奨ログのような「原文保存」用途検索・更新する JSON は JSONB

→ 実務では基本的に JSONB を選ぶ。

なぜ JSON 型を使うか

「商品ごとに任意のスペックを持つ」ような スキーマレス な要件で、

  • 中間テーブルを作るほどでもない
  • カラムを増やすほど明確でもない

ようなときに JSONB が便利。ただし「検索する属性が決まったら」普通のカラムに切り出した方が高速。