この記事は株式会社エス・エム・エス Advent Calendar 2023の17日目の記事です。
Railsアプリケーションのライフサイクルにおいて、データベーススキーマの適切な変更は、システムの整合性と拡張性を保つ上で不可欠な要素です。
本記事では、Active Recordがデータベーススキーマ情報をどのように取得し、キャッシュしているのか、そしてこれを利用してRailsアプリケーションがどのように動作するかについて掘り下げます。
それを踏まえて、スキーマ変更の適切なタイミングについて検討して行きます。
※本記事で使用するRailsとDBのバージョンは、Rails 7.1.2
とPostgreSQL 15.5
です。
Active Recordとデータベーススキーマ
Active Recordを通じて得られるスキーマ情報
Active Recordは、データベーススキーマに関する情報をDBに問い合わせ、取得した情報をキャッシュします。これには、テーブル名、カラム、プライマリーキー、インデックスが含まれます。これらの情報はActiveRecord::ConnectionAdapters::SchemaCache
オブジェクトで管理されます。
スキーマ情報の取得のタイミング
Active Recordは、Webサーバー起動後に以下のタイミングでデータベーススキーマ情報を取得します。
- モデルクラスの最初のロード時: モデルクラスが最初にロードされると、Active Recordは対応するテーブルのスキーマ情報を取得します。
- データベース操作時: モデルがデータベース操作を行う際(例: レコードの挿入、更新、削除)、Active Recordは必要に応じてスキーマ情報を取得します。
取得したスキーマ情報をどのように保持しているのか
取得したスキーマ情報はSchemaCache
オブジェクトのインスタンス変数に格納され、テーブル情報以外は外部からアクセスできるようになっています。
以下の例では、特定のテーブル・モデルが存在するケースを考えて、データベースから取得したスキーマ情報がどのようにSchemaCache
オブジェクトにマッピングされているのかを見ていきます。
# articles
create_table "articles", force: :cascade do |t|
t.string "title"
t.text "body"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["title"], name: "index_articles_on_title", unique: true
end
テーブル情報
Active Recordはモデルに対応するテーブル名がキャッシュされてない場合、データベースから以下のSQLでテーブル名の一覧を取得し、テーブル名がキー、ブール値がバリューのハッシュオブジェクトで保持します。
※ config.active_record.schema_cache_ignored_tables
で定義されたテーブルはキャッシュしません。
SELECT
c.relname
FROM
pg_class c
LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE
n.nspname = ANY (current_schemas(false))
AND c.relkind IN ('r', 'v', 'm', 'p', 'f');
relname
----------------------
schema_migrations -- マイグレーションタイムスタンプを格納するテーブル
ar_internal_metadata -- Rails環境とスキーマに関する情報を格納するテーブル
articles
(3 rows)
# 取得したテーブル情報はインスタンス変数 @data_sources に格納される。
puts @data_sources
=> {"schema_migrations"=>true, "ar_internal_metadata"=>true, "articles"=>true}
プライマリーキー
データベースから取得したプライマリーキー名はschema_cache#primary_keys
から参照できます。
schema_cache = ActiveRecord::ConnectionAdapters::SchemaCache.new(ActiveRecord::Base.connection)
schema_cache.primary_keys("articles")
=> "id"
カラム情報
データベースから取得したカラム情報はschema_cache#columns
から参照できます。
カラムの情報はActiveRecord::ConnectionAdapters::PostgreSQL::Column
オブジェクトにマッピングされ、配列で保持します。
- カラム名
- カラムのデータ型
- デフォルト値
- NOT-NULL 制約かどうか
- オブジェクト識別子等のメタデータ
- デフォルト値を取得するための関数
- コレーション(照合順序の定義)
- カラムに対するコメント
- etc...
schema_cache = ActiveRecord::ConnectionAdapters::SchemaCache.new(ActiveRecord::Base.connection)
schema_cache.columns("articles")
=>
[#<ActiveRecord::ConnectionAdapters::PostgreSQL::Column:0x000000010cf5d7e8
@collation=nil,
@comment=nil,
@default=nil,
@default_function="nextval('articles_id_seq'::regclass)",
@generated="",
@identity=nil,
@name="id",
@null=false,
@serial=true,
@sql_type_metadata=#<ActiveRecord::ConnectionAdapters::SqlTypeMetadata:0x000000010cf5ea80 @limit=8, @precision=nil, @scale=nil, @sql_type="bigint", @type=:integer>>,
#<ActiveRecord::ConnectionAdapters::PostgreSQL::Column:0x000000010cf5c9b0
@collation=nil,
@comment=nil,
@default=nil,
@default_function=nil,
@generated="",
@identity=nil,
@name="title",
@null=true,
@serial=nil,
@sql_type_metadata=#<ActiveRecord::ConnectionAdapters::SqlTypeMetadata:0x000000010cf5cbb8 @limit=nil, @precision=nil, @scale=nil, @sql_type="character varying", @type=:string>>,
# ------ 以下省略 -------- #
インデックス情報
データベースから取得したインデックス情報はschema_cache#indexes
から参照できます。
インデックスの情報はActiveRecord::ConnectionAdapters::IndexDefinition
オブジェクトにマッピングされ、配列で保持します。
- インデックス名
- ユニークインデックスかどうかを示すブール値
- インデックスが対応しているカラム名
- etc...
schema_cache = ActiveRecord::ConnectionAdapters::SchemaCache.new(ActiveRecord::Base.connection)
schema_cache.indexes("articles")
=>
[#<ActiveRecord::ConnectionAdapters::IndexDefinition:0x000000010be4f5c8
@columns=["title"],
@comment=nil,
@include=nil,
@lengths={},
@name="index_articles_on_title",
@nulls_not_distinct=false,
@opclasses={},
@orders={},
@table="articles",
@type=nil,
@unique=true,
@using=:btree,
@valid=true,
@where=nil>]
計画的なデータベーススキーマ変更
データベーススキーマの変更は、アプリケーションの正常な動作に影響を与えないよう計画的に実施する必要があります。以下では、スキーマ変更を計画し、アプローチする際の重要な事項について検討します。
データ量の多いテーブルへのスキーマ変更はダウンタイムの発生の有無など、別の観点での注意が必要となります。
本記事ではあくまでアプリケーションコードとスキーマ変更のデプロイ計画に焦点を当ててお話しします。
データベーススキーマ変更とアプリケーションコード変更の順序
前述の通り、Active RecordはWebサーバー起動後にスキーマ情報をキャッシュするという特性があることを踏まえると、データベーススキーマの変更はアプリケーションコードの変更よりも前に行う必要があることがわかります。この順序は、データベーススキーマに依存するアプリケーションコードの変更を適切に行うためです。
既存のテーブルにカラムを追加・削除する場合には注意が必要
新規にテーブルを追加するケースなどはDBマイグレーションを実行後に、アプリケーションコードの変更をデプロイすると良いのですが、既存テーブルにカラムを追加する場合には少し注意が必要です。
DBマイグレーション実行後からアプリケーションコードの変更をデプロイするアプローチを取った場合、Active Recordが認識しているカラム情報とデータベーススキーマに差分が生じます。その状態で selece * from
などのSQLを実行すると、prepared statement
のキャッシュエラーが発生してしまいます。
enumerate_columns_in_select_statements
を有効にする
PostgreSQLの場合は、この問題を回避するために、enumerate_columns_in_select_statements
を有効にすることで、カラム追加・削除時のPreparedStatementCacheExpired errorを回避することができます。
# config.active_record.enumerate_columns_in_select_statements が false の場合
Article.all
=> SELECT "articles".* FROM "articles"
# config.active_record.enumerate_columns_in_select_statements が true の場合
Article.all
=> SELECT "articles"."id", "articles"."title", "articles"."body", "articles"."created_at", "articles"."updated_at" FROM "articles"
ignore_columns
を使う
また、別のアプローチとしてDBマイグレーションの実行前にignore_columns
に変更対象のカラムを定義しておくことでも回避できます。
以下、titleカラムを除外した場合に発行されるSQLの例です。
class Article < ApplicationRecord
self.ignored_columns += [:title]
end
# title を除いたカラムを取得するようになる
Article.all
=> SELECT "articles"."id", "articles"."body", "articles"."created_at", "articles"."updated_at" FROM "articles"
DBマイグレーションの実行前に、変更対象のカラムを ignore_columns
に指定しておく必要があります。
-
ignore_columns
に変更対象のカラム名を定義してデプロイ - DBマイグレーションを実行
- アプリケーションコードの変更をデプロイ(
ignore_columns
の定義も削除)
おわりに
Active Recordがデータベースのスキーマ情報をどのようにして取得し、キャッシュするのかを学んだ後に、DBマイグレーションの適切なタイミングについて検討しました。
Active Recordはデータベースとのインタラクションをうまく隠蔽してくれるので、我々開発者は詳細を知らずに開発を進めることができますが、その背後にある仕組みを理解することは、非常に重要です。