PostgreSQLのUPDATEで仮想カラムを自動リロード — RETURNING句を活用した余分なラウンドトリップの排除
PostgreSQLアダプターがUPDATE実行時にRETURNING句を自動付与し、仮想カラムの再計算値をインプレースで取得できるようになりました。これにより、update後にreloadを呼ぶ必要がなくなります。
背景
仮想カラムの値が更新後に古いままになるという問題は、create時とupdate時の双方で発生していました。create時の問題は #48241 でINSERT ... 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時にも同様の仕組みを導入することで、createとupdateの動作を一致させます。
技術的な変更
変更は複数のレイヤーにまたがっており、アダプター層・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
アダプター層の拡張
AbstractAdapterにsupports_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.rbにupdate_with_resultメソッドが追加されました。returningキーワード引数でカラム名の配列を受け取り、Arel::UpdateManagerにRETURNING句を設定したうえで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.rbのvisit_Arel_Nodes_UpdateStatementにRETURNING句の出力ロジックを追加しました。INSERTやDELETEには既に実装されていましたが、UPDATEは未対応でした。
if o.returning.empty?
collector
else
collector << " RETURNING "
visit o.returning, collector
end
設計判断
createとupdateで対称的なAPIを整備する方針が一貫して採られています。_returning_columns_for_insert / _returning_columns_for_update、auto_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をサポートした際の拡張パスも明確に示しています。