アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Railsのバグでは?マイグレーションでMySQLのTIMESTAMP型にNULL 値を許可すると異常系がある

こんにちは、キエンです。

このエントリはアクトインディアドベントカレンダー2019の6日目です。 adventar.org

さっそく、Railsのマイグレーションで一つ問題を気づいています。Railsのバグか、それとも僕がどこか勘違いしていますが、一旦共有させていただきます。どなたか疑問があれば、教えていただけば嬉しいです。

問題

先日、既存のTIMESTAMP型のカラムをマイグレーションでただコメント属性を追記しようと思いましたが、エラーが発生されました。

  • 既存テーブル作成マイグレーション
class CreateArticles < ActiveRecord::Migration[6.0]
  def change
    create_table :articles do |t|
      t.string :name
      t.timestamp :published_at

      t.timestamps
    end
  end
end
  • published_atカラムにコメントを追記するマイグレーション
class ChangePublishedAtCommentOnArticles < ActiveRecord::Migration[6.0]
  def change
    change_column :articles, :published_at, :timestamp, comment: 'comment'
  end
end
  • コメントを追記するマイグレーションを実行するとエラーが発生される
$ bundle exec rake db:migrate
Mysql2::Error: Invalid default value for 'published_at'

調査

環境バージョン

Rails MySQL
6.0.1 5.7

※Rails 5.xも同じ現象を確認しました。

スキーマとカラム変更SQLの確認

既存テーブルのスキーマ

mysql> SHOW CREATE TABLE articles\G
*************************** 1. row ***************************
       Table: articles
Create Table: CREATE TABLE `articles` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `published_at` timestamp NULL DEFAULT NULL,
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
1 row in set (0.00 sec)

スキーマを見ると、現在published_atはTIMESTAMP型にNULL 値を許可していますね。

ここで軽くMySQLのTIMESTAMP型のNULL制約とデフォルト値を説明します。デフォルトでは、TIMESTAMP型はNOT NULLであり、NULL値を含めることはできません。 TIMESTAMP型にNULL値を許可するため、明示的にNULL属性を追加する必要です。

MySQL :: MySQL 5.7 Reference Manual :: 11.3.5 Automatic Initialization and Updating for TIMESTAMP and DATETIME

CREATE TABLE t1 (
  ts1 TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,     -- default 0
  ts2 TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP -- default NULL
);

RailsのマイグレーションでNULLのoptionがTRUE、また指定しない場合、NULL値が許可されています。TIMESTAMP型の場合、NULL値を許可するため、Rails側にNULL属性を追加してくれると思っています。

# timestamp型の場合
t.timestamp :published_at`published_at` timestamp NULL DEFAULT NULL

カラム変更のSQL

change_column :articles, :published_at, :timestamp, comment: 'comment'ALTER TABLE `articles` CHANGE `published_at` `published_at` timestamp DEFAULT NULL COMMENT 'comment'

published_atの変更SQLがおかしいですね。timestamp DEFAULT NULLの原因でMysql2::Error: Invalid default value for 'published_at'エラーが出ていると思います。

NULL属性がないので、published_atのデフォルトの値は0のに、DEFAULT NULLで定義するとinvalidエラーが発生されているからです。 正確なSQLはこちらだと思います。

- ALTER TABLE `articles` CHANGE `published_at` `published_at` timestamp DEFAULT NULL COMMENT 'comment'
+ ALTER TABLE `articles` CHANGE `published_at` `published_at` timestamp NULL DEFAULT NULL COMMENT 'comment'

結論、Railsのスキーマ生成処理に何か問題があるでしょう。次、Railsのスキーマ生成処理を確認しましょう。

Railsのスキーマ生成処理確認

schema_creation.rb

https://github.com/rails/rails/blob/6-0-stable/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb#L28

# TIMESTAMP NULLになるかこちらの処理で決める
def add_column_options!(sql, options)
  # By default, TIMESTAMP columns are NOT NULL, cannot contain NULL values,
  # and assigning NULL assigns the current timestamp. To permit a TIMESTAMP
  # column to contain NULL, explicitly declare it with the NULL attribute.
  # See https://dev.mysql.com/doc/refman/5.7/en/timestamp-initialization.html
  if /\Atimestamp\b/.match?(options[:column].sql_type) && !options[:primary_key]
    sql << " NULL" unless options[:null] == false || options_include_default?(options)
  end
end

schema_statements.rb

https://github.com/rails/rails/blob/6-0-stable/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb#L1204

def options_include_default?(options)
  options.include?(:default) && !(options[:null] == false && options[:default].nil?)
end

上記のschema_creation.rbとschema_statements.rbの処理を見ると、NULL属性が付かれるため、マイグレーションで以下のすべて条件を満たす必要です。

  • TIMESTAMP型
  • プライマリーキーではない
  • NULL: TRUE
  • DEFAULTを指定しない

今回コメントを追記するマイグレーションは上記の条件をすべて満たしましたが、なんでNULL属性が付かないですね。

abstract_mysql_adapter.rb

カラム変更の場合、スキーマ生成前に、以下のoptions調整ってメッソドが実行されています。

https://github.com/rails/rails/blob/6-0-stable/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L635

def change_column_for_alter(table_name, column_name, type, options = {})
  column = column_for(table_name, column_name)
  type ||= column.sql_type

  unless options.key?(:default)
    options[:default] = column.default
  end

  unless options.key?(:null)
    options[:null] = column.null
  end

  unless options.key?(:comment)
    options[:comment] = column.comment
  end

  td = create_table_definition(table_name)
  cd = td.new_column_definition(column.name, type, options)
  schema_creation.accept(ChangeColumnDefinition.new(cd, column.name))
end

マイグレーションでDEFAULTを指定しない場合既存のカラムのDEFAULTが設定されます。なので、DEFAULTを指定しない 条件を満たさなくになってしまいます。なるほど、動きが理解できました。

ちなみに、マイグレーションでTIMESTAMP型にDEFAULT: NULLを指定してもNULL属性が付かられないです。この条件で問題があると思います。 DEFAULTを指定しないの条件からDEFAULTを指定しないまたはNULLを指定に変更すれば方が良いかと考えておきます。

# schema_creation.rb
sql << " NULL" unless options[:null] == false || options_include_default?(options)
↓
sql << " NULL" unless options[:null] == false || options[:default].present?

Railsのソースを上書きする

TIMESTAMP型は2038年の問題も含めて、TIMESTAMP型を使用するのはよろしくなくて、DATETIME型にした方が良いと思いますが、絶対にTIMESTAMP型のまましないとだめ、かつこの記事の問題を解決したければ、以下の方針があるだと考えておきます。

MySQLで timestamp型 を使うのはNG!2038年問題の対処法 | PisukeCode - Web開発まとめ

class_evalを使用方法

class_evalを使用して、add_column_optionsメソッドを上書きします。

ActiveRecord::ConnectionAdapters::MySQL::SchemaCreation.class_eval do
  private
  
  def add_column_options!(sql, options)
    # By default, TIMESTAMP columns are NOT NULL, cannot contain NULL values,
    # and assigning NULL assigns the current timestamp. To permit a TIMESTAMP
    # column to contain NULL, explicitly declare it with the NULL attribute.
    # See https://dev.mysql.com/doc/refman/5.7/en/timestamp-initialization.html
    if /\Atimestamp\b/.match?(options[:column].sql_type) && !options[:primary_key]
      sql << " NULL" unless options[:null] == false || options[:default].present?
    end

    ...
  end
end

DockerイメージでRailsのソースを修正

Dockerファイルに以下の行を追加すれば良いと思います。

RUN sed -i '34s/options_include_default?(options)/options[:default].present?/' \
    /usr/local/bundle/gems/activerecord-6.0.1/lib/active_record/connection_adapters/mysql/schema_creation.rb

結果

Railsのソースを上書きしてから、無事にマイグレーションでカラムを変更できました。

# マイグレーションで生成したSQL
ALTER TABLE `articles` CHANGE `published_at` `published_at` timestamp NULL DEFAULT NULL COMMENT 'comment'

# コメントが追加できました。
mysql> SHOW CREATE TABLE articles\G
*************************** 1. row ***************************
       Table: articles
Create Table: CREATE TABLE `articles` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `published_at` timestamp NULL DEFAULT NULL COMMENT 'comment',
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
1 row in set (0.00 sec)

最後に

いかがでしょうか。Railsのソースを読んだら、詳しく処理の仕組みを理解できれば良いですね。 さて、アクトインディではエンジニアを募集していますね、ぜひご応募してください。 actindi.net