PostgreSQLのUPDATEで仮想カラムを自動リロード — RETURNING句を活用した余分なラウンドトリップの排除

rails/rails

PostgreSQLアダプターがUPDATE実行時にRETURNING句を自動付与し、仮想カラムの再計算値をインプレースで取得できるようになりました。これにより、update後にreloadを呼ぶ必要がなくなります。

背景

仮想カラムの値が更新後に古いままになるという問題は、create時とupdate時の双方で発生していました。create時の問題は #48241INSERT ... RETURNINGによって解消されましたが、update時は未対応のままでした。#48423 がその問題を明確にレポートしています。

具体的には、次のようなスキーマを持つPostモデルで問題が再現していました。

create_table :posts do |t|
  t.integer :upvotes_count
  t.integer :downvotes_count
  t.virtual :total_votes_count, type: :integer, as: "upvotes_count + downvotes_count", stored: true
end

post.update(upvotes_count: 2, downvotes_count: 2)を呼んでもtotal_votes_countはデータベースが再計算した値を反映せず、post.reloadを明示的に呼ぶ必要がありました。これは開発者にとって気づきにくい落とし穴であり、余分なデータベースラウンドトリップも発生させていました。

本PRは #48241 のフォローアップとして、UPDATE時にも同様の仕組みを導入することで、createupdateの動作を一致させます。

技術的な変更

変更は複数のレイヤーにまたがっており、アダプター層・Arelビジター・モデル層・スキーマ層の4つを順に見ていきます。

カラムクラスの拡張とDeprecation

Column#auto_populated?auto_populated_on_insert?にリネームし、新たにauto_populated_on_update?を追加しました。auto_populated_on_update?virtual?trueのカラムに対してtrueを返します。旧メソッド名はDeprecationWarningを出しながらauto_populated_on_insert?に委譲する形で残されています。

def auto_populated_on_insert?
  auto_incremented_by_db? || default_function
end

def auto_populated?
  auto_populated_on_insert?
end
deprecate auto_populated?: :auto_populated_on_insert?, deprecator: ActiveRecord.deprecator

def auto_populated_on_update?
  virtual?
end

アダプター層の拡張

AbstractAdaptersupports_update_returning?(デフォルトfalse)とreturn_value_after_update?を追加しました。PostgreSQLアダプターでは既存のsupports_insert_returning?aliasを張ることでsupports_update_returning?trueにしています。

alias supports_update_returning? supports_insert_returning?

return_value_after_update?column.auto_populated_on_update?を呼び出すため、仮想カラムのみが対象になります。

update_with_resultの追加

database_statements.rbupdate_with_resultメソッドが追加されました。returningキーワード引数でカラム名の配列を受け取り、Arel::UpdateManagerRETURNING句を設定したうえでcast_resultを返します。

def update_with_result(arel, name = nil, binds = [], returning:) # :nodoc:
  arel.returning(returning.map { |column| Arel.sql(quote_column_name(column)) })

  intent = QueryIntent.new(adapter: self, arel: arel, name: name, binds: binds)

  intent.execute!
  intent.cast_result
end

_update_recordのリファクタリングと_update_record_with_resultの追加

persistence.rbでは、UPDATEクエリ組み立てのロジックを_build_update_managerプライベートメソッドに切り出し、_update_recordと新設の_update_record_with_resultで共用する構造にしました。

def _update_record(values, constraints) # :nodoc:
  um = _build_update_manager(values, constraints)
  with_connection do |c|
    c.update(um, "#{self} Update")
  end
end

def _update_record_with_result(values, constraints, returning) # :nodoc:
  um = _build_update_manager(values, constraints)
  with_connection do |c|
    c.update_with_result(um, "#{self} Update", returning: returning)
  end
end

model_schema.rb_returning_columns_for_updateを追加

_returning_columns_for_insertと対称的な_returning_columns_for_updateをクラスメソッドとして追加しました。connection.return_value_after_update?trueのカラム名をフィルタし、スキーマキャッシュのリセット時に@_returning_columns_for_updateもnilクリアされます。

ArelビジターへのRETURNING句サポート追加

arel/visitors/postgresql.rbvisit_Arel_Nodes_UpdateStatementRETURNING句の出力ロジックを追加しました。INSERTDELETEには既に実装されていましたが、UPDATEは未対応でした。

if o.returning.empty?
  collector
else
  collector << " RETURNING "
  visit o.returning, collector
end

設計判断

createupdateで対称的なAPIを整備する方針が一貫して採られています。_returning_columns_for_insert / _returning_columns_for_updateauto_populated_on_insert? / auto_populated_on_update?supports_insert_returning? / supports_update_returning?のように、insert側に対応するupdate側のメソッドを揃える設計になっています。これにより、将来MySQLやSQLiteがRETURNINGに対応した場合でも、アダプター側でsupports_update_returning?trueにするだけで機能が有効になる拡張性を確保しています。

_build_update_managerへの切り出しは、_update_record_update_record_with_resultでクエリ組み立てロジックが重複しないようにするためです。RETURNING付きとなしの差分を最小化し、将来の変更箇所を一点に集約しています。

RETURNINGの対象を仮想カラムのみに限定していることも重要な判断です。idなどの通常カラムはRETURNINGに含まれないことをテスト(test_returning_columns_on_update_does_not_include_id)で明示的に保証しています。これは不必要なデータ転送を避け、副作用の範囲を最小に抑える意図と読み取れます。

まとめ

本PRはINSERT時に確立されたRETURNING活用パターンをUPDATEにも一貫して適用し、仮想カラムを持つPostgreSQLアプリケーションで余分なreload呼び出しを不要にしました。アダプター・Arel・モデル層にわたる対称的なAPI設計は、将来MySQLやSQLiteがRETURNINGをサポートした際の拡張パスも明確に示しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
bb4f94d6

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)」という記事全体の3部構成が明確に守られています。各セクションの役割も適切です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きのシンタックスハイライト(```ruby:path/to/file.rb)およびGitHubのPR/Issueリンク記法([#123](URL))が、ガイドラインに沿って正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

仮想カラム、Arel、アダプター層といった専門用語を適切に使用しており、対象読者である専門知識を持つエンジニアに適した内容となっています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクション、各パラグラフが総論から各論へと展開されており、トピックセンテンスが段落の冒頭に配置されているため、非常に読みやすい構成です。1段落1トピックの原則も守られています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているすべてのコードブロックは、提供されたDiffの内容と完全に一致しており、正確に技術的変更点を反映しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「仮想カラム」「RETURNING句」「ラウンドトリップ」「アダプター」「Arelビジター」などの技術用語が、文脈に沿って正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

仮想カラムの値が更新後に古くなる問題点と、それを`UPDATE ... RETURNING`で解決する仕組みについて、技術的に正確かつ論理的に説明されています。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべて、PRのDescriptionやDiff内のコード変更、関連Issue番号によって裏付けられており、ハルシネーション(創作)は見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#48628)、関連PR番号(#48241)、関連Issue番号(#48423)がすべて正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRの主題「PostgreSQL reload virtual columns on update via RETURNING clause」を的確に要約し、その意義(余分なラウンドトリップの排除)まで含めて分かりやすく表現しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

「将来MySQLやSQLiteが対応した場合」という言及は、PR Descriptionの記載とアダプターの設計から導かれる妥当な推察の範囲内であり、未確認の外部知識を持ち込んではいません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「未対応のままでした」「導入することで」など、変更前後の状況を示す時間表現がPRの内容と一致しており、正確です。