フューチャー技術ブログ

PostgreSQL 18の新機能、仮想生成列の使い方や制約、格納生成列との使い分けについて

top.jpg

PostgreSQL 18連載の3本目です。

PostgreSQL 18がリリースされ、仮想生成列についてまとめます。PostgreSQLで従来から利用できた格納生成列や、生成列自体と合わせて紹介します。

生成列

生成列は他の列から計算される列のことで、テーブルに対するビューをつくるように、ある列に対してビューのような列を作ることができます。ビューにも、MViewと通常のViewがあるように、生成列も「格納生成列」と「仮想生成列」の2種類があります。格納生成列は、登録/更新時に計算されて物理的にストレージが割り当てられます(MVIEWに似ています)。仮想列は列が読み取られる時に動的に計算されます(Viewに似ています)。

PostgreSQL 12で「格納」生成列が利用可能となり、今回18から「仮想」生成列が利用可能となりました。ここまで説明した内容をざっと、表でまとめました。

[object Object] undefined

生成列の全般に共通する使い方としては、「導出列」があります。導出列とは、他のカラムから算出できる列です。まさに生成列の用途にドンピシャ被りですね。一般論としては、導出列をもたせることは冗長性であるため、SSoT(信頼できる唯一の情報源)原則を守るため、作成しない方針を取るチームが多いでしょう。ただ、導出列にインデックスを貼りたいといった性能要件や、その他、設計の見える化や運用要件などで作成することがあります。

例をいくつか上げます。(凡例: 元の列 -> 導出列)

  • 例1: 単価×数量x税係数x割引係数 -> 請求金額
  • 例2: 姓 + 名 -> フルネーム
  • 例3: 日付カラム -> 曜日
  • 例4: 生年月日 -> 年齢
  • 例5: メールアドレス -> 検索用メールアドレス(全て小文字にするなど加工し、検索用に正規化する)

どれもアプリケーション側で計算して明示的にインサートしても良いものですが、生成列を使用することでその列が読み取り専用であることを開発者に伝えることができ、また整合性を伴わない更新事故を無くすことができます。また、テーブル定義上で宣言的に意図を伝えられる点で、きっと生成AIとも親和性が良いと思います(これは未検証、想像で書いています)。

ただ、PostgreSQL設計ガイドライン では、ライフサイクルがアプリケーションに近く、格納生成列の定義変更はAccessExclusiveLock(SELECTもブロックされるロック)を取って全行更新の処理が必要となるため、この用途での格納生成列の使用は非推奨としていました。同様の部分の懸念はそれなりの規模感のシステムでは共通的であるため、あまり利用頻度は高くないと思います(※2025年10月29日時点では、仮想生成列についての記述はありません)。ただし、仮想生成列であれば、おそらく定義変更してもAccessExclusiveLockを取らないと思うので、障壁は下がるかもしれません。このあたりを検証していきます。

使用方法の基礎

検証環境情報やセットアップは村田さんのB-treeインデックスのスキップスキャン記事の手順に従います。Dockerで postgres:18 のイメージを利用します。

生成列の使い方ですが、ドキュメント にあるように、 GENERATED ALWAYS AS (ロジック) STORED で格納生成列、VIRTUAL を付けると仮想生成列になります。デフォルトは VIRTUAL です。身長[cm]をインチ版と尺版を生成列で作ってみます。

CREATE TABLE people (
person_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
height_cm numeric,
height_in numeric GENERATED ALWAYS AS (height_cm / 2.54) STORED, -- 格納生成列
height_syaku numeric GENERATED ALWAYS AS (height_cm * 0.033) VIRTUAL -- 仮想生成列
);

実際にデータを登録 & 検索します。

postgres=# INSERT INTO people (height_cm) VALUES (170.5), (158.2), (181.0);
INSERT 0 3
postgres=# SELECT * FROM people;
person_id | height_cm | height_in | height_syaku
-----------+-----------+---------------------+--------------
1 | 170.5 | 67.1259842519685039 | 5.6265
2 | 158.2 | 62.2834645669291339 | 5.2206
3 | 181.0 | 71.2598425196850394 | 5.9730
(3 rows)

読み取りすると、自動変換された値が取得できました。インチ・尺のどちらも実生活で意識して使ったことが無いでので合っているかよく分からないですが、自動計算されるのは便利です。また、INSERT文がシンプルになるという心理的な嬉しさを思ったより感じました。

検証サマリ

格納生成列、仮想生成列それぞれで以下の観点を比較します。

[object Object] undefined

1. 生成列で自分自身の列を参照できるか

試しに、生成列で自分自身(self_value)を参照した定義を実行してみます。内容に意味は無いですが、 self_value = self_value + 1 となるような計算式を設定しています。

CREATE TABLE self_reference (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,

-- 自分自身(value)を参照して値を生成しようとする仮想列
"self_value" int GENERATED ALWAYS AS (self_value + 1) VIRTUAL
);
ERROR: cannot use generated column "self_value" in column generation expression
LINE 5: "self_value" int GENERATED ALWAYS AS (self_value + 1) VI...
^
DETAIL: A generated column cannot reference another generated column.

結果はNGです。「生成列は他の生成列を参照できない」とありますね。実現したいことも意味不明なので、失敗して当然なので想定通りかなと。この結果は、VIRTUALSTORED に変えても同じです。

2. IDENTITY列を参照できるか

IDENTITY列(シリアル)を参照できるか確認します。

postgres=# CREATE TABLE m_product (
item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
product_name TEXT,

-- 格納生成列
item_code_stored TEXT GENERATED ALWAYS AS (item_id*10) STORED,

-- 仮想生成列
item_code_virtual TEXT GENERATED ALWAYS AS (item_id*100) VIRTUAL
);
CREATE TABLE

postgres=# INSERT INTO m_product (product_name) VALUES ('Apple');
INSERT 0 1

postgres=# SELECT * FROM m_product;
item_id | product_name | item_code_stored | item_code_virtual
---------+--------------+------------------+-------------------
1 | Apple | 10 | 100
(1 row)

格納生成列・仮想生成列のどちらも問題なく、IDENTITY列を参照することができました。

3. 他のテーブルの列を参照できるか

まず、税率を保持するテーブルを作成します。

CREATE TABLE m_tax (
region_code CHAR(2) PRIMARY KEY,
tax_rate NUMERIC(4, 2)
);

続いて、さきほど作った税率テーブルを参照する、生成列を作ってみます。

CREATE TABLE t_order (
order_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
base_price NUMERIC,
region CHAR(2),

-- taxテーブルのtax_rate列を参照する計算式
total_price NUMERIC GENERATED ALWAYS AS (
base_price * (1.0 + (SELECT tax_rate FROM m_tax WHERE region_code = region))
) VIRTUAL
);
ERROR: cannot use subquery in column generation expression
LINE 8: base_price * (1.0 + (SELECT tax_rate FROM m_tax WHER...

これはエラーになりました。サブクエリはNG(つまり、別テーブルの参照は不可)のようです。

ドキュメントにも References to other tables are not allowed. (他のテーブルは参照できない)と書いていますので、その通りの結果です。格納生成列、仮想生成列ともに結果は変わりません。

4. ネストした生成列定義は可能か

「割引額」という生成列を参照する、「料金」という生成列の定義を試みます。

CREATE TABLE m_price (
item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
base_price NUMERIC,
discount_rate NUMERIC(3, 2) DEFAULT 0.0,

-- 割引額という生成列を定義
discount_amount NUMERIC GENERATED ALWAYS AS ( base_price * discount_rate ) VIRTUAL,

-- 他の生成列「割引率」を「料金」という生成列を定義
price NUMERIC GENERATED ALWAYS AS ( base_price - discount_amount ) VIRTUAL
);
ERROR: cannot use generated column "discount_amount" in column generation expression
LINE 10: ... price NUMERIC GENERATED ALWAYS AS ( base_price - discount_a...
^
DETAIL: A generated column cannot reference another generated column.

こちらもエラーになります。「他の生成列を参照することはできません」という内容です。デジャブ感があるのは、「生成列で自分自身の列を参照できるか」節でもこのメッセージを見たためです。格納生成列、仮想生成列のどちらも同じ結果になります。

ドキュメントにも、 The generation expression can refer to other columns in the table, but not other generated columns. (生成式はテーブル内の他の列を参照できますが、他の生成列を参照することはできません。)とあるので、記載通りの挙動です。

5. 計算途中でnull値が混入したらどうなるか

例えば、総額=単価x数量 という生成列を定義します。この時、単価がNULLの場合にはどのように挙動するか確かめます。

CREATE TABLE t_order (
item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
unit_price NUMERIC,
quantity INT,

-- 生成列(単価x数量)で構成
total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) VIRTUAL
);

-- データ登録(unit_priceにNULLを入れる)
INSERT INTO t_order (unit_price, quantity) VALUES (NULL, 5);

-- 検索
SELECT * FROM t_order;
item_id | unit_price | quantity | total_price
---------+------------+----------+-------------
1 | | 5 |
(1 row)

unit_priceがNULLの場合は、total_priceもNULLという結果です。SQL的に自然な挙動ですね。回避するには、unit_priceやquantityにNOT NULL制約を付けたり、COALESCEでNULLを実値に置き換える必要があります。

6. NOT NULL制約を付けることができるか

ちょっとテクニカルなテーブル定義に書き換えます。生成列のtotal_priceのみNOT NULL制約を付けて、ソースのunit_price, quantity はNULL許容にします。

postgres=# CREATE TABLE t_order (
item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
unit_price NUMERIC,
quantity INT,

-- 生成列(単価x数量)で構成。★さらに、NOT NULL制約を追加
total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) VIRTUAL NOT NULL
);
CREATE TABLE

これは成功します。生成列でもNOT NULL制約を付与は可能です。

続いて、先ほどと同様 unit_price に NULL 値を含んだINSERT文を実行します。格納生成列の場合はインサート時に計算しますが、仮想生成列の場合は読み取り時に計算されるため、検知できない気がしますが…?

postgres=# INSERT INTO t_order (unit_price, quantity) VALUES (NULL, 5);
ERROR: null value in column "total_price" of relation "t_order" violates not-null constraint
DETAIL: Failing row contains (1, null, 5, virtual).

なんと、仮想生成列でも、インサート時に登録が失敗しました。仮想生成列も登録時に計算しているようです。仮想生成列は格納生成列に比べて、登録時に計算しないから高速というのは、必ずしも成立する話ではないように感じます。

ちなみに、格納生成列でもこの挙動は変わりません(こちらは直感的な動作かなと思います)。

この検証は「11」節ではさらに詳しく調べています。

7. 生成列は一意制約を付けることができるか

格納生成列の場合は成功します。

postgres=# CREATE TABLE t_order_detail (
order_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
quantity INT,
order_item_key TEXT GENERATED ALWAYS AS (order_id::TEXT || '-' || item_id::TEXT) STORED UNIQUE
);
CREATE TABLE

仮想生成の場合は、失敗します。

postgres=# CREATE TABLE t_order_detail (
order_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
quantity INT,
order_item_key TEXT GENERATED ALWAYS AS (order_id::TEXT || '-' || item_id::TEXT) VIRTUAL UNIQUE
);
ERROR: unique constraints on virtual generated columns are not supported

これは後述するインデックスのサポート有無の挙動の差でしょう。なお、これまた後述する式インデックスに一意制約をつけることで、実質的に、仮想生成列に一意制約をつけることはできます。

8. 生成列はインデックスに使えるか

格納生成列、仮想生成列それぞれにインデックスを追加してみます。

CREATE TABLE m_user (
user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name TEXT,
last_name TEXT,
email TEXT,
-- (1) 格納生成列: 大文字小文字を区別せずにメールアドレスを検索するため
email_lower TEXT GENERATED ALWAYS AS (LOWER(email)) STORED,
-- (2) 仮想生成列: 用途: 姓と名を連結して表示・検索するため
full_name TEXT GENERATED ALWAYS AS (last_name || ' ' || first_name) VIRTUAL
);

-- 格納生成列へのインデックス追加は成功
postgres=# CREATE INDEX idx_01_m_user ON m_user (email_lower);
CREATE INDEX

-- 仮想生成列へのインデックス追加は失敗
postgres=# CREATE INDEX idx_02_m_user ON m_user (full_name);
ERROR: indexes on virtual generated columns are not supported

仮想生成列へのインデックス追加はサポートされていないようです(まぁ実体がないのでそれはそう)。直接的な回避方法としては、式インデックスを使うことになるでしょう。つまり、仮想生成列の式定義と同じ式を、式インデックスに指定します。

例えば、以下のように idx_02_m_user の定義を変更します。テーブル作成と式が重複するのですが仕方なしです。

postgres=# CREATE INDEX idx_02_m_user ON m_user ((last_name || ' ' || first_name));
CREATE INDEX

-- ダミーデータ登録
postgres=# INSERT INTO m_user (first_name, last_name, email)
VALUES ('Taro', 'Yamada', 'Taro.Yamada@example.com'),
('Hanako', 'Suzuki', 'hanako.suzuki@example.jp');
INSERT 0 2

-- 件数が少ないとbitmap scanが選択されがちなのでOFF
postgres=# SET enable_bitmapscan = OFF;
SET

-- インデックスが使われていることを確認(実質、式インデックスの確認)
postgres=# EXPLAIN SELECT * FROM m_user
WHERE full_name = 'Yamada Taro';
QUERY PLAN
--------------------------------------------------------------------------------
Index Scan using idx_02_m_user on m_user (cost=0.15..12.19 rows=2 width=168)
Index Cond: (((last_name || ' '::text) || first_name) = 'Yamada Taro'::text)
(2 rows)

実行計画レベルで、式インデックスが使われていることを確認できました。多少の回避方法が必要ですが、仮想列も事実上、インデックスを貼れると思ってよいでしょう。

9. 生成列はPKにできるか

例えば、受注明細トランで、受注番号+商品IDを組み合わせてPKにするケースを考えます(普通は、サロゲートにして欲しい案件ですが、あくまで動作確認上の”例”です)。

まず格納生成列で試します。

CREATE TABLE t_order_detail (
order_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
quantity INT,

-- 受注番号+商品IDの組み合わせでPKを作成してみる
order_item_key TEXT GENERATED ALWAYS AS (order_id::TEXT || '-' || item_id::TEXT) STORED,

PRIMARY KEY (order_item_key)
);

-- データ登録
INSERT INTO t_order_detail (order_id, item_id, quantity) VALUES
(1001, 201, 2), (1001, 205, 1), (1002, 201, 5);

-- 検索
SELECT * FROM t_order_detail;
order_id | item_id | quantity | order_item_key
----------+---------+----------+----------------
1001 | 201 | 2 | 1001-201
1001 | 205 | 1 | 1001-205
1002 | 201 | 5 | 1002-201
(3 rows)

普通にPKとして扱えました。

続いて、仮想生成列で試します。

CREATE TABLE t_order_detail (
order_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
quantity INT,

-- 受注番号+商品IDの組み合わせでPKを作成してみる
order_item_key TEXT GENERATED ALWAYS AS (order_id::TEXT || '-' || item_id::TEXT) VIRTUAL,

PRIMARY KEY (order_item_key)
);

ERROR: primary keys on virtual generated columns are not supported

これはエラーになりました。仮想生成列のPKはサポートされていないようです。仮想生成列はインデックスを使えないため、PKにできないのでしょう。これについては先程の式インデックスで代替することはできません。ただし、式インデックスは、一意制約とNOT NULL制約を付与できるので、類似の機能を持たせることはできます。

注意として式インデックスでは、 :::txt による型変換が使えず CAST() で変換するなど微妙にクセがあることです。

postgres=# CREATE UNIQUE INDEX idx_01_t_order_detail
ON t_order_detail ( (CAST(order_id AS TEXT) || '-' || CAST(item_id AS TEXT)) );
CREATE INDEX

一意性のチェックです。

postgres=# INSERT INTO t_order_detail (order_id, item_id, quantity) VALUES (1003, 202, 3);
INSERT 0 1
postgres=# INSERT INTO t_order_detail (order_id, item_id, quantity) VALUES (1003, 202, 3);
ERROR: duplicate key value violates unique constraint "idx_01_t_order_detail"
DETAIL: Key (((order_id::text || '-'::text) || item_id::text))=(1003-202) already exists.

無事動作しました。ただし、あくまで仮想列自体に一意制約+NOT NULL制約をつけたわけではなく、仮想列と同等の定義を持った式インデックスに、一意制約+NOT NULL制約をつけたことになります。そのため、外部キー制約の参照の対象にはできないでしょう。

10. 生成列はパーティションキーに使えるか

受注日時から受注日付(yyyy-MM-dd)を生成列で作成し、それをパーティションキーとするようなケースで試します。

postgres=# CREATE TABLE t_order (
order_id BIGINT GENERATED BY DEFAULT AS IDENTITY,
item_name TEXT,
order_at TIMESTAMPTZ NOT NULL,
order_date DATE GENERATED ALWAYS AS ((timezone('JST', order_at)::date)) STORED,
PRIMARY KEY (order_id, order_date)
) PARTITION BY RANGE (order_date);
ERROR: cannot use generated column in partition key
LINE 7: ) PARTITION BY RANGE (order_date);
^
DETAIL: Column "order_date" is a generated column.

生成列はパーティションキーに使えないようです。この用途ですと、生成列を実カラムに戻し、アプリケーション側で明示的に設定する方が良いように思えます(アプリケーションの代わりにトリガーを使用しても良いですが、さすがにテクニカル過ぎるでしょう)。仮想生成列でも同じ結果になります。

もちろんドキュメントにも、A generated column cannot be part of a partition key.(生成列はパーティションキーには利用できません。)と書かれています。

11. テーブル作成後に後から生成列を追加できるか

m_user に格納生成列、仮想生成列の順番で足してみます。

postgres=# CREATE TABLE m_user (
user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name TEXT,
last_name TEXT,
email TEXT
);
CREATE TABLE

-- 格納生成列の追加
postgres=# ALTER TABLE m_user
ADD COLUMN email_lower TEXT GENERATED ALWAYS AS (LOWER(email)) STORED;
ALTER TABLE

-- 仮想生成列の追加
postgres=# ALTER TABLE m_user
ADD COLUMN full_name TEXT GENERATED ALWAYS AS (last_name || ' ' || first_name) VIRTUAL;
ALTER TABLE

結果は成功でした。ちなみに、ALTER文実行前にはBEGINEを実行し、別プロセスでpg_locks を確認したところ、どちらも AccessExclusiveLock を取っていました。格納生成列は既存行が多ければ長時間、参照もできないので注意が必要です。仮想生成列はメタデータの書き換えのみで済むため、AccessExclusiveLock を取りますが一瞬で終わります。

12. NOT NULL制約を付けた生成列を後から追加すると処理時間はどうなるか

以下の t_order に1万件のダミーデータを登録し、[格納|生成] x[NOT NULL有無]の4パターンで処理時間を計測しました。

-- 初期テーブル
CREATE TABLE t_order (
item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
unit_price NUMERIC,
quantity INT
);

-- ダミーデータ登録
INSERT INTO t_order (unit_price, quantity)
SELECT
(random() * 9999 + 1)::numeric(10, 2), -- 1.00 ~ 10000.99 のランダムな単価
(random() * 99 + 1)::int -- 1 ~ 100 のランダムな数量
FROM
generate_series(1, 10000000);

-- 統計情報の最新化
ANALYZE t_order;

-- 確認
SELECT count(*) FROM t_order;
count
----------
10000000
(1 row)

検証は以下のフローです。

SET autovacuum = OFF;
\timing on

-- 格納生成列(NULL許容)
ALTER TABLE t_order ADD COLUMN total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) STORED;
ALTER TABLE t_order DROP COLUMN total_price;

-- 格納生成列(NOT NULL)
ALTER TABLE t_order ADD COLUMN total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) STORED NOT NULL;
ALTER TABLE t_order DROP COLUMN total_price;

-- 仮想生成列(NULL許容)
ALTER TABLE t_order ADD COLUMN total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) VIRTUAL;
ALTER TABLE t_order DROP COLUMN total_price;

-- 仮想生成列(NOT NULL)
ALTER TABLE t_order ADD COLUMN total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) VIRTUAL NOT NULL;
ALTER TABLE t_order DROP COLUMN total_price;
[object Object] undefined

格納生成列もNOT NULL化すると少し処理時間が増します。理由を深く調査はしていませんが、NOT NULL計算分が上乗せになるからでしょう。そして、仮想生成列ですが、NOT NULL制約を追加すると大幅に時間がかかります。これはおそらくテーブルフルスキャンでNOT NULLにならないかチェックするからでしょう。

(2025.11.7追記)

ちなみに、元テーブルに生成列の計算元列にNOT NULL制約を付けると、フルスキャンが論理的にはスキップできるのでは?という声をもらいましたので、検証しました。

テーブル定義だけ以下で、残りは同じです。

テーブル定義
CREATE TABLE t_order (
item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
unit_price NUMERIC NOT NULL, -- NOT NULL制約を追加
quantity INT NOT NULL -- NOT NULL制約を追加
);
[object Object] undefined

元列のNOT NULL制約無し版に比べ、少し早くなっていますが、実行の度に処理時間は変動するため気にしないでください。

重要なのは、NOT NULL制約をつけても、4の結果は4.9秒かかっている(≒フルスキャンが発生していると推測できる)ことです。現状のPostgreSQLでは、元列にNOT NULL制約がついていていたとしても、生成列の「式」を確認して、この結果だとNOT NULLになりえないから、チェックは不要であると言った判定は行っていないと言えます。

13. 生成列の定義変更はできるか

ドキュメントを読む限り、生成列の定義を直接変更することはできないように思えます(文法の読み取りが間違っていたらご指摘ください)。

そのため、一度そのカラムを削除してから作り直すことになると思われます。例えば、先程の email_lower をいう検索専用の生成列を、さらに前後の空白をトリムする処理を追加します。

-- (1) 既存の格納列を削除
ALTER TABLE m_user DROP COLUMN email_lower;

-- (2) 新しい定義で格納列を再度追加
ALTER TABLE m_user
ADD COLUMN email_lower TEXT GENERATED ALWAYS AS (LOWER(TRIM(email))) STORED;

流れ自体は仮想生成列でも同様です。格納生成列の場合は、(2)の処理でテーブルサイズによってはかなり時間がかかると思うので、注意が必要そうです(格納生成列のまま、瞬時に切り替える手順は今のところ、テーブル単位で新旧Verを作ってリネームする方法しか思いつきませんでした。また、格納生成列をDROP & ADDするということは、統計情報も消えるということなので、インデックス項目の場合はANALYZEもしたほうが良いでしょう)。

14. 利用しているカラムをRENAME COLUMNしたらどうなるか

生成列で利用しているカラムをリネームはできるのでしょうか?試してみます。

CREATE TABLE m_user (
user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name TEXT,
last_name TEXT,
email TEXT,
email_lower TEXT GENERATED ALWAYS AS (LOWER(email)) STORED,
full_name TEXT GENERATED ALWAYS AS (last_name || ' ' || first_name) VIRTUAL
);

-- 生成列で利用されているカラムをリネームする
postgres=# ALTER TABLE m_user RENAME COLUMN first_name TO given_name;
ALTER TABLE
postgres=# ALTER TABLE m_user RENAME COLUMN email TO email_address;
ALTER TABLE

-- 検索
postgres=# SELECT * FROM m_user;
user_id | given_name | last_name | email_address | email_lower | full_name
---------+------------+-----------+----------------------+----------------------+---------------
1 | Hanako | Suzuki | h.suzuki@example.com | h.suzuki@example.com | Suzuki Hanako
(1 row)

格納・仮想のどちらの生成列で利用しているカラム名を変更が成功し、挙動も問題なかったです。

\d m_user でテーブル定義を確認すると、生成列定義の列名も書き換わっていました。

postgres=# \d m_user
Table "public.m_user"
Column | Type | Collation | Nullable | Default
---------------+--------+-----------+----------+--------------------------------------------------------------
user_id | bigint | | not null | generated by default as identity
given_name | text | | |
last_name | text | | |
email_address | text | | |
email_lower | text | | | generated always as (lower(email_address)) stored
full_name | text | | | generated always as ((last_name || ' '::text) || given_name)
Indexes:
"m_user_pkey" PRIMARY KEY, btree (user_id)

リネームにも追随してくれるの、気が効いていますね。賢い。

15. 利用しているカラムをDROP COLUMNしたときどうなるか

格納生成列、仮想生成列それぞれで利用しているカラムを、DROPできるか試しました。

CREATE TABLE m_user (
user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
first_name TEXT,
last_name TEXT,
email TEXT,
email_lower TEXT GENERATED ALWAYS AS (LOWER(email)) STORED,
full_name TEXT GENERATED ALWAYS AS (last_name || ' ' || first_name) VIRTUAL
);

-- 格納生成列で利用されているカラムを削除
postgres=# ALTER TABLE m_user DROP COLUMN email;
ERROR: cannot drop column email of table m_user because other objects depend on it
DETAIL: column email_lower of table m_user depends on column email of table m_user
HINT: Use DROP ... CASCADE to drop the dependent objects too.

-- 仮想生成列で利用されているカラムを削除
postgres=# ALTER TABLE m_user DROP COLUMN first_name;
ERROR: cannot drop column first_name of table m_user because other objects depend on it
DETAIL: column full_name of table m_user depends on column first_name of table m_user
HINT: Use DROP ... CASCADE to drop the dependent objects too.

どちらもエラーかつ、ヒントで依存しているオブジェクト(列)もCASCADEオプションで消せるとありますね。CASCADEつけて実行してみます。

-- 格納生成列で利用されているカラムを削除(CASCADE追加)
postgres=# ALTER TABLE m_user DROP COLUMN email CASCADE;
NOTICE: drop cascades to column email_lower of table m_user
ALTER TABLE

-- 仮想生成列で利用されているカラムを削除(CASCADE追加)
postgres=# ALTER TABLE m_user DROP COLUMN first_name CASCADE;
NOTICE: drop cascades to column full_name of table m_user
ALTER TABLE

postgres=# \d m_user;
Table "public.m_user"
Column | Type | Collation | Nullable | Default
-----------+--------+-----------+----------+----------------------------------
user_id | bigint | | not null | generated by default as identity
last_name | text | | |
Indexes:
"m_user_pkey" PRIMARY KEY, btree (user_id)

CASCADE を利用したら、利用していた元のカラムも同時に削除されました。強力ですね…。事故不可避なので存在自体を忘れたほうが良いでしょう。

格納生成列と仮想生成列の使い分け

格納生成列ですが、最初に紹介したPostgreSQL設計ガイドラインにある通り、業務要件の変更でロジックを変更したい場合の、テーブルマイグレーション(デプロイ作業)が大変過ぎるため避けるべきは変わりませんでした。

仮想生成列は、その手の苦労は今回動かした範囲内ではあまり感じませんでした(実データの変更は伴わず、メタデータの変更のみだからです)。一方で、NOT NULL制約を付けたときの挙動には注意で、定義変更時は既存の全行をフルスキャンするような動きになっていると思われます。システムメンテナンスタイムを確保できるシステムであっても、それなりのデータ量になりえる場合は、選択しにくいでしょう(パーティションテーブルごとに定義変更できるなどの手順が確立できればまだ考えようがありますが..)。

総合すると、NOT NULLを絶対に付けないかつ、アプリケーション側でロジックが散らばるのであれば、いっそDB定義側で仮想生成列を用いて、統制を図るのも一手ではないかと感じました。一方で、将来的にNOT NULL制約をつける変更もありえなくない場合は、防御的な考えから異現時点では採用すべきでない気がします。式インデックスなどハマりどころもあるので、大規模だと利用は現時点では禁止にしたい。

みなさんの意見もいただけると嬉しいです。

まとめ

格納生成列、仮想生成列の両方について触ってみました。私の見解としては以下の意見です。

  • 格納生成列は使わない
  • 仮想生成列は、NOT NULL制約を付けたときの挙動は気になるけど、NOT NULL制約を絶対に付けないのであれば害は少ないので、統制が取れるなら利用してもよいのでは。大規模ではハマりどころも多いのでテックリード的な視点では、現時点では禁止にしておきたい
  • どちらも、パーティションキーに使えないなど制約があるので、ドキュメントを一読することを推奨します