計画的なDBスキーマ変更のためのRails Active Recordの実践的知識

この記事は[株式会社エス・エム・エス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/bm-sms)の17日目の記事です。

Railsアプリケーションのライフサイクルにおいて、データベーススキーマの適切な変更は、システムの整合性と拡張性を保つ上で不可欠な要素です。

本記事では、Active Recordがデータベーススキーマ情報をどのように取得し、キャッシュしているのか、そしてこれを利用してRailsアプリケーションがどのように動作するかについて掘り下げます。
それを踏まえて、スキーマ変更の適切なタイミングについて検討して行きます。

※本記事で使用するRailsとDBのバージョンは、`Rails 7.1.2`と`PostgreSQL 15.5`です。

# Active Recordとデータベーススキーマ
### Active Recordを通じて得られるスキーマ情報

Active Recordは、データベーススキーマに関する情報をDBに問い合わせ、取得した情報をキャッシュします。これには、テーブル名、カラム、プライマリーキー、インデックスが含まれます。これらの情報は`ActiveRecord::ConnectionAdapters::SchemaCache`オブジェクトで管理されます。

https://github.com/rails/rails/blob/v7.1.2/activerecord/lib/active_record/connection_adapters/schema_cache.rb

### スキーマ情報の取得のタイミング

Active Recordは、Webサーバー起動後に以下のタイミングでデータベーススキーマ情報を取得します。
1. **モデルクラスの最初のロード時**: モデルクラスが最初にロードされると、Active Recordは対応するテーブルのスキーマ情報を取得します。
2. **データベース操作時**: モデルがデータベース操作を行う際（例: レコードの挿入、更新、削除）、Active Recordは必要に応じてスキーマ情報を取得します。

### 取得したスキーマ情報をどのように保持しているのか

取得したスキーマ情報は`SchemaCache`オブジェクトのインスタンス変数に格納され、テーブル情報以外は外部からアクセスできるようになっています。

以下の例では、特定のテーブル・モデルが存在するケースを考えて、データベースから取得したスキーマ情報がどのように`SchemaCache`オブジェクトにマッピングされているのかを見ていきます。

``` ruby
# 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` で定義されたテーブルはキャッシュしません。
```sql
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)
```

```ruby 
# 取得したテーブル情報はインスタンス変数 @data_sources に格納される。

puts @data_sources
=> {"schema_migrations"=>true, "ar_internal_metadata"=>true, "articles"=>true}
```

#### プライマリーキー
データベースから取得したプライマリーキー名は`schema_cache#primary_keys`から参照できます。

```ruby
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...

```ruby
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...

```ruby
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>]
```


# 計画的なデータベーススキーマ変更

データベーススキーマの変更は、アプリケーションの正常な動作に影響を与えないよう計画的に実施する必要があります。以下では、スキーマ変更を計画し、アプローチする際の重要な事項について検討します。


:::note warn

データ量の多いテーブルへのスキーマ変更はダウンタイムの発生の有無など、別の観点での注意が必要となります。
本記事ではあくまでアプリケーションコードとスキーマ変更のデプロイ計画に焦点を当ててお話しします。 
:::

### データベーススキーマ変更とアプリケーションコード変更の順序
前述の通り、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を回避することができます。

```ruby
# 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の例です。
```ruby:app/models/article.rb
class Article < ApplicationRecord
  self.ignored_columns += [:title]
end
```

```ruby 
# title を除いたカラムを取得するようになる
Article.all
=> SELECT "articles"."id", "articles"."body", "articles"."created_at", "articles"."updated_at" FROM "articles"
```
DBマイグレーションの実行前に、変更対象のカラムを `ignore_columns` に指定しておく必要があります。

1. `ignore_columns`に変更対象のカラム名を定義してデプロイ
1. DBマイグレーションを実行
1. アプリケーションコードの変更をデプロイ（`ignore_columns`の定義も削除）

# おわりに
Active Recordがデータベースのスキーマ情報をどのようにして取得し、キャッシュするのかを学んだ後に、DBマイグレーションの適切なタイミングについて検討しました。

Active Recordはデータベースとのインタラクションをうまく隠蔽してくれるので、我々開発者は詳細を知らずに開発を進めることができますが、その背後にある仕組みを理解することは、非常に重要です。