パーティション化テーブルへの参照は物理パーティション全体で機能する必要があるため、PostgreSQLは外部キーのカタログ行を追加で作成します。これらの行が1つのリレーションシップを表すのか、個別の宣言を表すのかを判断するには、親へのリンクと参照先テーブルを調べます。
このウォークスルーは、データベースブラウザーやNode.jsアプリケーションで予期しない制約一覧を調査する、Macを使う開発者向けです。合成した記事行で動作を再現し、制約のまとまりをたどり、論理スキーマのレポートに何を含めるか判断します。
1つの外部キーが複数の制約行を生成するのはなぜですか?
パーティション化テーブルは、行をパーティションに格納する論理テーブルです。親テーブル自体はデータを格納しません。この違いは、別のテーブルがそのテーブルを参照する場合に重要です。PostgreSQLは、物理パーティション内の行が変更されたときもリレーションシップを維持する必要があります。公式のテーブルパーティショニングのドキュメントを参照してください。
PostgreSQL 18の実装を見ると、その仕組みが明確になります。addFkRecurseReferencedルーチンは外部キーのアクショントリガーを作成し、参照先の各パーティションに対して追加のpg_constraint行を再帰的に作成します。検証コードの説明によると、参照先側のこれらの行はアクショントリガーを支えるものであり、個別の検証スキャンではありません。PostgreSQLの外部キーの作成と検証の実装を参照してください。
したがって、これらの行は、参照するすべての値が各パーティションに存在しなければならないという意味ではありません。宣言されたリレーションシップの参照先は、引き続きパーティション化された親テーブルです。PostgreSQLは、参照先側の変更にも対応する必要があります。これには、参照されている行が削除されたときやキーが変更されたときに設定済みの動作を実行することも含まれます。これらの動作については外部キーで説明されています。
この機能をサポートするPostgreSQLのバージョンは?
パーティション化テーブルを参照する外部キーはPostgreSQL 12で導入されました。それ以前のバージョンでは、同じ親テーブルを参照先とする宣言は使用できません。PostgreSQL 12は現在サポート対象外です。これは機能が導入された歴史的な境界であり、推奨するインストール対象ではありません。この記事では、現行のPostgreSQL 18のドキュメントと保守されているソースブランチを使用します。PostgreSQL 12のリリースノートを参照してください。
合成した記事行でリレーションシップを再現する
既存のローカルPostgreSQL開発データベースと、動作するpsqlのインストールを使用します。MacでTerminalを開き、psql -d postgresで接続して、SHOW server_version;でサーバーを確認します。必要に応じて開発データベース名に置き換えてください。公式のpsqlドキュメントでは、データベースの選択と対話型SQLの実行について説明されています。
次のセットアップ全体を1回実行します。既存のオブジェクトを削除せず、専用スキーマを作成します。そのスキーマがすでに存在する場合は中止し、新しい開発データベースを使うか、例全体でスキーマ名を一貫して変更してください。
BEGIN;
CREATE SCHEMA fk_partition_demo;
CREATE TABLE fk_partition_demo.articles (
article_id integer NOT NULL,
publication_year integer NOT NULL,
title text NOT NULL,
PRIMARY KEY (article_id, publication_year)
) PARTITION BY RANGE (publication_year);
CREATE TABLE fk_partition_demo.articles_2025
PARTITION OF fk_partition_demo.articles
FOR VALUES FROM (2025) TO (2026);
CREATE TABLE fk_partition_demo.articles_2026
PARTITION OF fk_partition_demo.articles
FOR VALUES FROM (2026) TO (2027);
CREATE TABLE fk_partition_demo.article_notes (
note_id integer PRIMARY KEY,
article_id integer NOT NULL,
publication_year integer NOT NULL,
note_text text NOT NULL,
CONSTRAINT article_notes_article_fk
FOREIGN KEY (article_id, publication_year)
REFERENCES fk_partition_demo.articles
(article_id, publication_year)
);
INSERT INTO fk_partition_demo.articles
(article_id, publication_year, title)
VALUES
(101, 2025, 'A guide to paper models'),
(102, 2026, 'Choosing a notebook layout');
INSERT INTO fk_partition_demo.article_notes
(note_id, article_id, publication_year, note_text)
VALUES
(1, 101, 2025, 'Add a materials list'),
(2, 102, 2026, 'Include a grid example');
COMMIT;
複合主キーにしているのは意図的です。PostgreSQLでは、パーティション化テーブルの主キー制約または一意制約に、すべてのパーティションキー列を含める必要があります。また、パーティションキーの式には追加の制限があります。このテーブルはpublication_yearでパーティション化されているため、主キーにこの列が含まれ、外部キーも列の組全体を参照します。宣言的パーティショニング: 制限事項を参照してください。
そのため、このサンプルでは記事を(article_id, publication_year)で識別します。article_idだけでグローバルに一意であるとは示していません。アプリケーションでarticle_idだけを使った参照が必要な場合は、この例を採用する前にパーティション化とキーの設計を見直してください。
名前を比べるのではなく、制約のまとまりをたどる
有用なカタログフィールドは、参照元テーブルを示すconrelid、参照先テーブルを示すconfrelid、親制約のOIDを示すconparentidです。制約名は一意とは限らないため、名前だけでは2つの行が独立しているか判断できません。これらのフィールドについてはpg_constraintで説明されています。
セットアップを実行したあと、次のクエリを実行します。article_notesに宣言されたルート外部キーを起点に、その子制約を再帰的にたどります。
WITH RECURSIVE fk_family AS (
SELECT c.oid, c.conparentid, c.oid AS root_oid, 0 AS depth
FROM pg_catalog.pg_constraint AS c
WHERE c.contype = 'f'
AND c.conrelid =
'fk_partition_demo.article_notes'::regclass
AND c.conparentid = 0
UNION ALL
SELECT child.oid, child.conparentid,
parent.root_oid, parent.depth + 1
FROM pg_catalog.pg_constraint AS child
JOIN fk_family AS parent
ON child.conparentid = parent.oid
WHERE child.contype = 'f'
)
SELECT
family.root_oid,
family.depth,
c.oid AS constraint_oid,
c.conname,
c.conparentid,
c.conrelid::regclass AS referencing_table,
c.confrelid::regclass AS referenced_table,
pg_get_constraintdef(c.oid) AS definition
FROM fk_family AS family
JOIN pg_catalog.pg_constraint AS c ON c.oid = family.oid
ORDER BY family.root_oid, family.depth, c.oid;
この単一階層の例では、実装上、外部キー行は3つになります。articlesを参照する行が1つ、その2つのパーティションをそれぞれ参照する行が1つずつです。これはソースから導かれる想定結果であり、実際に実行して取得した出力ではありません。生成された名前やOID値をハードコードしてはいけません。同じソースでは、あとからパーティションを作成またはアタッチした場合も、参照先側の制約が作成されます。tablecmds.cを参照してください。
| この例の行 | 参照元テーブル | 参照先テーブル | 親へのリンク |
|---|---|---|---|
| ルートのリレーションシップ | article_notes |
articles |
conparentid = 0 |
| 参照先パーティションの子 | article_notes |
articles_2025 |
ルート制約のOID |
| 参照先パーティションの子 | article_notes |
articles_2026 |
ルート制約のOID |
見落としやすい点は、3つの行すべてで参照元テーブルがarticle_notesのままであることです。両方のテーブル列と親へのリンクを確認してください。制約に関連付けられたテーブルだけを見ていると、参照先パーティションの構造を見落とすことがあります。
このクエリでは、pg_get_constraintdef()を使って再構成した定義も表示します。PostgreSQLの説明によると、この関数が返すのは元のSQL文ではなく再構成された定義です。リレーションシップの理解に使い、正確なマイグレーション文の復元には使わないでください。システム情報関数と演算子を参照してください。
Macでnode-postgresを使って同じ行を調べる
node-postgresはサーバーのクエリ結果を返します。パーティションに関連する行は、PostgreSQLの制約処理機構によって作成されます。ドキュメントに記載されたクエリAPIは、SQLテキストとパラメーター値の配列を別々に受け取ります。ここでは、リレーション名をテキスト値として渡し、PostgreSQLによってregclassに変換します。クエリを参照してください。
サポート対象の現行Node.jsバージョンをインストールしたら、作業ディレクトリを作成してpgをインストールします。インストールコマンドとランタイムの互換性に関する案内は、node-postgres入門に記載されています。
mkdir pg-fk-inspection
cd pg-fk-inspection
npm init -y
npm install pg
次のプログラム全体をinspect-fks.mjsとして保存します。上記の確定済みSQLセットアップが必要です。再帰SQLレポートとは異なり、このプログラムは指定された参照元テーブルに直接付属する行を一覧表示します。この例の非パーティション化テーブルarticle_notesには、それで十分です。
import pg from 'pg';
const { Client } = pg;
const client = new Client({ connectionTimeoutMillis: 5000 });
const relation = process.argv[2] ??
'fk_partition_demo.article_notes';
try {
await client.connect();
const version = await client.query('SHOW server_version');
console.log('PostgreSQL:', version.rows[0].server_version);
const result = await client.query(`
SELECT oid, conname,
conrelid::regclass::text AS referencing_table,
confrelid::regclass::text AS referenced_table,
conparentid,
conparentid = 0 AS is_root,
pg_get_constraintdef(oid) AS definition
FROM pg_catalog.pg_constraint
WHERE contype = 'f' AND conrelid = $1::regclass
ORDER BY conparentid, oid
`, [relation]);
console.table(result.rows);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
} finally {
await client.end();
}
SQLセットアップで使ったものと同じデータベースに対して実行します。node-postgresはPGDATABASEなどのPostgreSQL接続環境変数を使用し、その既定値は接続に記載されています。
PGDATABASE=postgres node inspect-fks.mjs
リレーションが見つからない場合は、両方のセッションで同じデータベースを使っていることと、セットアップが正常にコミットされたことを確認してください。結果が空の場合、このクエリがそのリレーションに直接付属する外部キーを見つけられなかったという意味です。他のテーブルからの参照は報告されません。この記事の準備時にJavaScriptの構文チェックを実施しました。データベースでの実行はできなかったため、SQLの例と想定されるカタログ構造は、取得したドキュメントとソースに照らして確認しました。
表示または調査する内容を決める
制約一覧が予想より長く見える場合は、次の手順を使ってください。論理リレーションシップのレポートと実装の全件一覧を区別しながら、有用な証拠を保持できます。
- リレーションとサーバーバージョンを確認します。 スキーマ修飾されたリレーション名を使い、ドキュメントと動作を比較する前に
SHOW server_version;の結果を記録します。 - 親へのリンクをたどります。 この例では、ルートOIDを共有する子行は、宣言されたリレーションシップの制約グループに属しています。
- 両端を比べます。 参照元テーブルが同じまま
confrelidが変わっているなら、上に示した参照先パーティションの行を説明できます。 - レポートの目的を決めます。 選択したテーブルに宣言されたルートのリレーションシップには、
contype = 'f' AND conparentid = 0で絞り込みます。制約グループ全体を表示するには、再帰レポートを残します。 - 別のルートは個別に調べます。 2つの宣言が冗長だと判断する前に、列の組、参照先テーブル、アクション、検証状態、遅延可能性を比較します。
このレポート方法は、pg_constraintで説明されている識別子とリレーションシップフィールドに沿ったものです。ルートだけに絞る条件の適用範囲は意図的に限定されています。パーティションは継承された子制約を通じて関係することがあるため、リーフテーブルをルートだけに絞ると、そのテーブルに影響するすべてのリレーションシップを網羅できません。
強制適用を担うオブジェクトの証拠が必要な場合、pg_trigger.tgconstraintはトリガーを制約に関連付け、tgrelidはトリガーを持つテーブルを示します。カタログには内部で生成されたトリガーを識別するフィールドもあります。これらのフィールドを使って、pg_triggerをさらに調べることができます。
宣言されたリレーションシップが引き続き必要なら、生成された行は保持してください。今回再現したケースでは、実用的な解決策は、論理スキーマビューにルートのリレーションシップを表示し、診断ビューにはその子を残すことです。親へのリンクと両方のテーブル端点が見えれば、追加の行によって、PostgreSQLがパーティション全体で参照を実装する仕組みが分かります。
出典
テーブルパーティショニング — PostgreSQL Global Development Group。
pg_constraint — PostgreSQL Global Development Group。
リリース12 — PostgreSQL Global Development Group。
tablecmds.c, REL_18_STABLE — PostgreSQL Global Development Group。
制約 — PostgreSQL Global Development Group。
psql — PostgreSQL Global Development Group。
システム情報関数と演算子 — PostgreSQL Global Development Group。
pg_trigger — PostgreSQL Global Development Group。
node-postgres入門 — node-postgres。
クエリ — node-postgres。
接続 — node-postgres。