パーティション化テーブルへの参照は物理パーティション全体で機能する必要があるため、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の例と想定されるカタログ構造は、取得したドキュメントとソースに照らして確認しました。

表示または調査する内容を決める

制約一覧が予想より長く見える場合は、次の手順を使ってください。論理リレーションシップのレポートと実装の全件一覧を区別しながら、有用な証拠を保持できます。

  1. リレーションとサーバーバージョンを確認します。 スキーマ修飾されたリレーション名を使い、ドキュメントと動作を比較する前にSHOW server_version;の結果を記録します。
  2. 親へのリンクをたどります。 この例では、ルートOIDを共有する子行は、宣言されたリレーションシップの制約グループに属しています。
  3. 両端を比べます。 参照元テーブルが同じままconfrelidが変わっているなら、上に示した参照先パーティションの行を説明できます。
  4. レポートの目的を決めます。 選択したテーブルに宣言されたルートのリレーションシップには、contype = 'f' AND conparentid = 0で絞り込みます。制約グループ全体を表示するには、再帰レポートを残します。
  5. 別のルートは個別に調べます。 2つの宣言が冗長だと判断する前に、列の組、参照先テーブル、アクション、検証状態、遅延可能性を比較します。

このレポート方法は、pg_constraintで説明されている識別子とリレーションシップフィールドに沿ったものです。ルートだけに絞る条件の適用範囲は意図的に限定されています。パーティションは継承された子制約を通じて関係することがあるため、リーフテーブルをルートだけに絞ると、そのテーブルに影響するすべてのリレーションシップを網羅できません。

強制適用を担うオブジェクトの証拠が必要な場合、pg_trigger.tgconstraintはトリガーを制約に関連付け、tgrelidはトリガーを持つテーブルを示します。カタログには内部で生成されたトリガーを識別するフィールドもあります。これらのフィールドを使って、pg_triggerをさらに調べることができます。

宣言されたリレーションシップが引き続き必要なら、生成された行は保持してください。今回再現したケースでは、実用的な解決策は、論理スキーマビューにルートのリレーションシップを表示し、診断ビューにはその子を残すことです。親へのリンクと両方のテーブル端点が見えれば、追加の行によって、PostgreSQLがパーティション全体で参照を実装する仕組みが分かります。

出典