Rails

ジェネレータ:自作のmigraionファイルを生成する

やりたいこと

devise: https://github.com/plataformatec/devise

$ rails generate devise MODEL

みたいに、自分の作成しているgemにgenerateコマンドをつけたい。

基本的なこと:ジェネレータの基本を理解する

サンプル

Rails ジェネレータとテンプレート入門

lib/generators/initializer_generator.rb
class InitializerGenerator < Rails::Generators::NamedBase
  desc "このジェネレータはconfig/initializersにイニシャライザファイルを作成する"
  source_root File.expand_path("../templates", __FILE__)

  def copy_initializer_file
    copy_file "initializer.rb", "config/initializers/#{file_name}.rb"
  end
end

テンプレートファイルを作成しておく

lib/generators/templates/initializer.rb
puts "hoge"

コマンドを実行する。

terminal
$ ails generate initializer core_extensions

config/initializers/#{file_name}.rbで指定したとおり、config/initializers/core_extensions.rbが作成されます。

ポイント

  • class InitializerGenerator < Rails::Generators::NamedBaseで定義されたメソッドは全て実行される。
  • Rails::Generators::NamedBaseは、rails generateコマンドの時に引数を1つもつことを前提とする。
  • 引数がいらない場合、Rails::Generators::Baseを継承してclass定義を行う
  • copy_fileは、Module: Thor::Actionsで定義されています。
  • file_nameメソッドはRails::Generators::NamedBaseを継承することにより利用できます (Rails::Generators::Baseでは利用できません)。

Railsのバージョンが固定されている場合にmigrationファイルを作成する

lib/generators/templates/initializer.rb
require 'rails/generators/active_record'

class InitializerGenerator <  ActiveRecord::Generators::Base
  desc "このジェネレータはmigration.rbをdb/migration/にコピーします"

  source_root File.expand_path("../templates", __FILE__)

  def copy_devise_migration
    migration_template "migration.rb", "db/migrate/test_create_#{table_name}.rb"
  end
end

コマンドを実行します。

$ rails g initializer Hoge

以下にlib/generators/templates/migration.rbで定義したファイルがコピーされます。

db/migrate/20180502231441_test_create_hoges.rb

以上。

deviseは、様々なRailsのバージョンで使用されるので、migrationを作成する段階と、
migrationファイルそれ自身を工夫する必要があります。

※ migration_template: migration versionがコピー先ファイル名に付け加えられることがポイントです(意訳)

migrationファイルを作成する

ここは、Rails5.2で実際に作成されたmigrationファイルをまず見てみます。

db/migrate/20180502230333_create_users.rb
class CreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      t.string  :name
      t.integer :age

      t.timestamps
    end
  end
end

ポイントは、[5.2]みたいに、Railsのバージョンを指定してあげる必要があることです。
それに対処するために、次のようにdeviseでは定義されています。

def copy_devise_migration
  migration_template "migration.rb", "#{migration_path}/devise_create_#{table_name}.rb", migration_version: migration_version
end


def rails5?
  Rails.version.start_with? '5'
end

def migration_version
  if rails5?
    "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
  end
end

def migration_path
  if Rails.version >= '5.0.3'
    db_migrate_path
  else
    @migration_path ||= File.join("db", "migrate")
  end
end

migrationファイルの定義

deviseではこんな感じに定義されています。

class TestCreate<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
  def change
    create_table :<%= table_name %> do |t|
<%= migration_data -%>
    end
  end
end

migraion_dataに関しては、単純にヒアドキュメントを返すだけです。

lib/generators/templates/initializer.rb
  def migration_data
    binding.pry
<<Ruby
     t.string  :name
     t.integer :age

     t.timestamps
Ruby
  end

ORM

実はこの時点ではまだORM(Object-relational mapping)は行われていません。
(rails db:migrateしても、app/model下に対象のファイルが作られていないことがわかります。)

deviseの実装を参考にして、以下の記述を追加します。

lib/generators/templates/initializer.rb
  def generate_model
    invoke "active_record:model", [name], migration: false unless model_exists? && behavior == :invoke
  end

  def model_exists?
    File.exist?(File.join(destination_root, model_path))
  end

  def model_path
    @model_path ||= File.join("app", "models", "#{file_path}.rb")
  end

migrationファイルは独自に作成するので、Modelだけ作成しています。

terminal
def generate_model
  invoke "active_record:model", [name], migration: false unless model_exists? && behavior == :invoke
end

以下は同じような動きになります。

terminal
$ rails g model User --skip-migration

app/model下にファイルが生成されることでORMが完了します。

まとめ

code: https://gist.github.com/chamao/98c816a9b366f697d871d794795c9d87

$ rails g initializer Sample
$ rails db:migrate

ちゃんと目的のモデルが生成されていることがわかります。

$ rails c

Sample.create(name: "test", age:10 )

argument

今回は使わなかったけどdeviseで定義されているもの。

argument :attributes, type: :array, default: [], banner: "field:type field:type"

例えば、以下のようなコマンドに対応するため。

rails g devise User address:string age:integer 

address:string age:integerの部分が、attributesに格納され、
migrationファイルでは以下のように展開されている。

<% attributes.each do |attribute| -%>
  t.<%= attribute.type %> :<%= attribute.name %>
<% end -%>

class_option

今回は使わなかったけどdeviseで定義されているもの。

class_option :primary_key_type, type: :string, desc: "The type for primary key"

primary_key_typeはメソッドになっていて、こんな感じに定義されている。

def primary_key_type
  primary_key_string if rails5?
end

def primary_key_string
  key_string = options[:primary_key_type]
  ", id: :#{key_string}" if key_string
end

これが、migrationファイルでは以下のように展開されている。

create_table :<%= table_name %><%= primary_key_type %> do |t|

例えば、primary_keyをstring型に変更したい場合はこんな感じにする。

class_option :primary_key_type, type: :string, default: "string" , desc: "The type for primary key"

class_optionに関しては下記の記事が分かりやすかったです。
Thorの使い方まとめ

クラス全体で共通のオプションをclass_optionで指定することができます。

参照

Rails ジェネレータとテンプレート入門
migration_template(source, destination, config = {})