Rubyでもっと活用されるべきValue Objectパターン(翻訳)
最近私たちのRailsEventStoreユーザーが、issue #1650を投稿しました。PostGIS拡張を利用しているPostgreSQLデータベースでRailsEventStoreを使いたいが、イベントやストリームで使うテーブルをセットアップするマイグレーションがUnsupportedAdapter
で失敗するとのことでした。
これまでRailsEventStoreでは、PostgreSQLアダプタとMySQL2アダプタとSQLiteアダプタをサポートしていました。しかしPostgreSQLでこのPostGIS拡張を使いたい場合は、activerecord-postgis-adapter
を追加インストールしたうえでdatabase.ymlにadapter: postgis
を設定する必要があります。私たちのコードは、以下で返される値に依存しています。
ActiveRecord::Base.connection.adapter_name.downcase
=> "postgis"
このときの私は「ちょろい修正で済む」と思っていました。PostGISは単なる拡張なので、マイグレーションを生成するときに内部でPostgreSQLと同じように扱う必要があり、同じデータ型を許可する必要があります。
🔗 実際には多くの修正が必要だった
簡単な修正と思いきや、実際には8ファイルもの変更が必要でした(実装4ファイル、テスト4ファイル)。どうも変です。それでは個別の修正を見てみましょう。
VerifyAdapter
クラスのSUPPORTED_ADAPTERS
リストにpostgis
を追加する必要がありました。
module RubyEventStore
module ActiveRecord
UnsupportedAdapter = Class.new(StandardError)
class VerifyAdapter
- SUPPORTED_ADAPTERS = %w[mysql2 postgresql sqlite].freeze
+ SUPPORTED_ADAPTERS = %w[mysql2 postgresql postgis sqlite].freeze
def call(adapter)
raise UnsupportedAdapter, "Unsupported adapter" unless supported?(adapter)
end
private
private_constant :SUPPORTED_ADAPTERS
def supported?(adapter)
SUPPORTED_ADAPTERS.include?(adapter.downcase)
end
end
end
end
次に、ForeignKeyOnEventIdMigrationGenerator#each_migration
メソッドのcase
文も拡張が必要でした。
module RubyEventStore
module ActiveRecord
class ForeignKeyOnEventIdMigrationGenerator
def call(database_adapter, migration_path)
VerifyAdapter.new.call(database_adapter)
each_migration(database_adapter) do |migration_name|
path = build_path(migration_path, migration_name)
write_to_file(path, migration_code(database_adapter, migration_name))
end
end
private
def each_migration(database_adapter, &block)
case database_adapter
- when "postgresql"
+ when "postgresql", "postgis"
[
'add_foreign_key_on_event_id_to_event_store_events_in_streams',
'validate_add_foreign_key_on_event_id_to_event_store_events_in_streams'
]
else
['add_foreign_key_on_event_id_to_event_store_events_in_streams']
end.each(&block)
end
def absolute_path(path)
File.expand_path(path, __dir__)
end
def migration_code(database_adapter, migration_name)
migration_template(template_root(database_adapter), migration_name).result_with_hash(migration_version: migration_version)
end
def migration_template(template_root, name)
ERB.new(File.read(File.join(template_root, "#{name}_template.erb")))
end
def template_root(database_adapter)
absolute_path("./templates/#{template_directory(database_adapter)}")
end
def template_directory(database_adapter)
TemplateDirectory.for_adapter(database_adapter)
end
def migration_version
::ActiveRecord::Migration.current_version
end
def timestamp
Time.now.strftime("%Y%m%d%H%M%S")
end
def write_to_file(path, migration_code)
File.write(path, migration_code)
end
def build_path(migration_path, migration_name)
File.join("#{migration_path}", "#{timestamp}_#{migration_name}.rb")
end
end
end
end
Railsのマイグレーションジェネレータでも同じ変更が必要でした。
begin
require "rails/generators"
rescue LoadError
end
if defined?(Rails::Generators::Base)
module RubyEventStore
module ActiveRecord
class RailsForeignKeyOnEventIdMigrationGenerator < Rails::Generators::Base
class Error < Thor::Error
end
namespace "rails_event_store_active_record:migration_for_foreign_key_on_event_id"
source_root File.expand_path(File.join(File.dirname(__FILE__), "../generators/templates"))
def initialize(*args)
super
VerifyAdapter.new.call(adapter)
rescue UnsupportedAdapter => e
raise Error, e.message
end
def create_migration
case adapter
- when "postgresql"
+ when "postgresql", "postgis"
template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
template "#{template_directory}validate_add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_validate_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
else
template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
end
end
private
def adapter
::ActiveRecord::Base.connection.adapter_name.downcase
end
def migration_version
::ActiveRecord::Migration.current_version
end
def timestamp
Time.now.strftime("%Y%m%d%H%M%S")
end
def template_directory
TemplateDirectory.for_adapter(adapter)
end
end
end
end
end
ここで重要なのは、どちらのマイグレータもVerifyAdapter
クラスを使っていたことです(次の2つのマイグレータもそうでした)。
TemplateDirectory
クラスもプリミティブな強迫観念に悩まされており、これもあらゆるマイグレータで使われていました。
module RubyEventStore
module ActiveRecord
class TemplateDirectory
def self.for_adapter(database_adapter)
case database_adapter.downcase
- when "postgresql"
+ when "postgresql", "postgis"
"postgres/"
when "mysql2"
"mysql/"
end
end
end
end
end
さらにもうひとつ、VerifyDataTypeForAdapter
についてもそうでした。これはVerifyAdapter
のコンポジションで、指定のデータベースエンジンで利用可能なデータ型の検証を追加していました。
しかしその後、文字列値のチェックがより具体的なコンテキストで繰り返されていました。
# frozen_string_literal: true
module RubyEventStore
module ActiveRecord
InvalidDataTypeForAdapter = Class.new(StandardError)
class VerifyDataTypeForAdapter
SUPPORTED_POSTGRES_DATA_TYPES = %w[binary json jsonb].freeze
SUPPORTED_MYSQL_DATA_TYPES = %w[binary json].freeze
SUPPORTED_SQLITE_DATA_TYPES = %w[binary].freeze
def call(adapter, data_type)
VerifyAdapter.new.call(adapter)
raise InvalidDataTypeForAdapter, "MySQL2 doesn't support #{data_type}" if is_mysql2?(adapter) && !SUPPORTED_MYSQL_DATA_TYPES.include?(data_type)
raise InvalidDataTypeForAdapter, "sqlite doesn't support #{data_type}" if is_sqlite?(adapter) && supported_by_sqlite?(data_type)
raise InvalidDataTypeForAdapter, "PostgreSQL doesn't support #{data_type}" unless supported_by_postgres?(data_type)
end
private
private_constant :SUPPORTED_POSTGRES_DATA_TYPES, :SUPPORTED_MYSQL_DATA_TYPES, :SUPPORTED_SQLITE_DATA_TYPES
def is_sqlite?(adapter)
adapter.downcase.eql?("sqlite")
end
def is_mysql2?(adapter)
adapter.downcase.eql?("mysql2")
end
def supported_by_sqlite?(data_type)
!SUPPORTED_SQLITE_DATA_TYPES.include?(data_type)
end
def supported_by_postgres?(data_type)
SUPPORTED_POSTGRES_DATA_TYPES.include?(data_type)
end
end
end
end
🔗 そしてパターンを見出した
- 最初に、指定のアダプタが許可済みかどうかをチェックする必要がある(PostgreSQL、MySQL、SQLite)
- 指定のアダプタが特定のデータ型についてデータベースエンジンに適合するかどうかをチェックする必要がある
- 次に、特定のデータ型向けのマイグレーションを生成すべきかどうかを決定する必要がある
- マイグレーションテンプレートが置かれているディレクトリはアダプタの種別によって異なる
🔗 やってみよう
これらの作業を専用のValue Objectにすべて任せることで、コード内での決定木を削減し、同じプリミティブを何度でもチェックできるようになります。
以下のような感じになります。
DatabaseAdapter.from_string("postgres")
=> DatabaseAdapter::PostgreSQL.new
DatabaseAdapter.from_string("bazinga")
=> UnsupportedAdapter: "bazinga" (RubyEventStore::ActiveRecord::UnsupportedAdapter)
DatabaseAdapter.from_string("sqlite", "jsonb")
=> SQLite doesn't support "jsonb". Supported types are: binary. (RubyEventStore::ActiveRecord::InvalidDataTypeForAdapter)
何度か試行錯誤した後、最終的に以下の実装が完成しました。
# frozen_string_literal: true
module RubyEventStore
module ActiveRecord
UnsupportedAdapter = Class.new(StandardError)
InvalidDataTypeForAdapter = Class.new(StandardError)
class DatabaseAdapter
NONE = Object.new.freeze
class PostgreSQL < self
SUPPORTED_DATA_TYPES = %w[binary json jsonb].freeze
def adapter_name
"postgresql"
end
def template_directory
"postgres/"
end
end
class MySQL < self
SUPPORTED_DATA_TYPES = %w[binary json].freeze
def adapter_name
"mysql2"
end
def template_directory
"mysql/"
end
end
class SQLite < self
SUPPORTED_DATA_TYPES = %w[binary].freeze
def adapter_name
"sqlite"
end
end
def initialize(adapter_name, data_type)
raise UnsupportedAdapter if instance_of?(DatabaseAdapter)
validate_data_type!(data_type)
@data_type = data_type
end
attr_reader :data_type
def supported_data_types
self.class::SUPPORTED_DATA_TYPES
end
def eql?(other)
other.is_a?(DatabaseAdapter) && adapter_name.eql?(other.adapter_name)
end
alias == eql?
def hash
DatabaseAdapter.hash ^ adapter_name.hash
end
def template_directory
end
def self.from_string(adapter_name, data_type = NONE)
raise NoMethodError unless eql?(DatabaseAdapter)
case adapter_name.to_s.downcase
when "postgresql", "postgis"
PostgreSQL.new(data_type)
when "mysql2"
MySQL.new(data_type)
when "sqlite"
SQLite.new(data_type)
else
raise UnsupportedAdapter, "Unsupported adapter: #{adapter_name.inspect}"
end
end
private
def validate_data_type!(data_type)
if !data_type.eql?(NONE) && !supported_data_types.include?(data_type)
raise InvalidDataTypeForAdapter,
"#{class_name} doesn't support #{data_type.inspect}. Supported types are: #{supported_data_types.join(", ")}."
end
end
def class_name
self.class.name.split("::").last
end
end
end
end
DatabaseAdadpter
クラスは、あらゆる個別のアダプタの親クラスのように振る舞います。個別のアダプタにはsupported_data_types
リストが含まれていてクライアントクラスからアクセス可能になっており、選択したデータが指定のデータベースエンジンでサポートされていない場合は適切なエラーメッセージを返します。また、指定したアダプタでtemplate_directory
に設定されている名前も取得できます。
つまり、DatabaseAdapter.from_string
を含む単一のエントリを持つことになります。DatabaseAdapter.from_string
にはadapter_name
と、オプションのdata_type
を渡せます。これらはどちらも個別のアダプタのインスタンス生成時にバリデーションされます。
🔗 成果
以下のユーティリティクラスは削除できます。
VerifyAdapter
VerifyDataTypeForAdapter
TemplateDirectory
処理に必要な情報がValue Objectにすべて含まれているおかげで、4つのクラスと2つのrakeタスクがシンプルになりました。
ForeignKeyOnEventIdMigrationGenerator
module RubyEventStore
module ActiveRecord
class ForeignKeyOnEventIdMigrationGenerator
- def call(database_adapter, migration_path)
- VerifyAdapter.new.call(database_adapter)
+ def call(database_adapter_name, migration_path)
+ database_adapter = DatabaseAdapter.from_string(database_adapter_name)
each_migration(database_adapter) do |migration_name|
path = build_path(migration_path, migration_name)
write_to_file(path, migration_code(database_adapter, migration_name))
def each_migration(database_adapter, &block)
case database_adapter
- when "postgresql", "postgis"
+ when DatabaseAdapter::PostgreSQL
[
'add_foreign_key_on_event_id_to_event_store_events_in_streams',
'validate_add_foreign_key_on_event_id_to_event_store_events_in_streams'
RailsForeignKeyOnEventIdMigrationGenerator
def initialize(*args)
super
- VerifyAdapter.new.call(adapter)
+ @database_adapter = DatabaseAdapter.from_string(adapter_name)
rescue UnsupportedAdapter => e
raise Error, e.message
end
def create_migration
- case adapter
- when "postgresql", "postgis"
- template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
+ case @database_adapter
+ when DatabaseAdapter::PostgreSQL
+ template "#{@database_adapter.template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
- template "#{template_directory}validate_add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
+ template "#{@database_adapter.template_directory}validate_add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_validate_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
else
- template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
+ template "#{@database_adapter.template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
"db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
end
end
private
- def adapter
- ::ActiveRecord::Base.connection.adapter_name.downcase
+ def adapter_name
+ ::ActiveRecord::Base.connection.adapter_name
end
- def template_directory
- TemplateDirectory.for_adapter(adapter)
- end
MigrationGenerator
module RubyEventStore
module ActiveRecord
class MigrationGenerator
- DATA_TYPES = %w[binary json jsonb].freeze
- def call(data_type, database_adapter, migration_path)
- raise ArgumentError, "Invalid value for data type. Supported for options are: #{DATA_TYPES.join(", ")}." unless DATA_TYPES.include?(data_type)
- VerifyDataTypeForAdapter.new.call(database_adapter, data_type)
- migration_code = migration_code(data_type, database_adapter)
+ def call(database_adapter, migration_path)
+ migration_code = migration_code(database_adapter)
path = build_path(migration_path)
write_to_file(migration_code, path)
path
- def migration_code(data_type, database_adapter)
- migration_template(template_root(database_adapter), "create_event_store_events").result_with_hash(migration_version: migration_version, data_type: data_type)
+ def migration_code(database_adapter)
+ migration_template(template_root(database_adapter), "create_event_store_events").result_with_hash(migration_version: migration_version, data_type: database_adapter.data_type)
end
def template_root(database_adapter)
- absolute_path("./templates/#{template_directory(database_adapter)}")
- end
- def template_directory(database_adapter)
- TemplateDirectory.for_adapter(database_adapter)
+ absolute_path("./templates/#{database_adapter.template_directory}")
end
RailsMigrationGenerator
class Error < Thor::Error
end
- DATA_TYPES = %w[binary json jsonb].freeze
namespace "rails_event_store_active_record:migration"
source_root File.expand_path(File.join(File.dirname(__FILE__), "../generators/templates"))
type: :string,
default: "binary",
desc:
- "Configure the data type for `data` and `meta data` fields in Postgres migration (options: #{DATA_TYPES.join("/")})"
+ "Configure the data type for `data` and `meta data` fields in migration (options: #{DatabaseAdapter::PostgreSQL.new.supported_data_types.join(", ")})"
)
def initialize(*args)
super
- if DATA_TYPES.exclude?(data_type)
- raise Error, "Invalid value for --data-type option. Supported for options are: #{DATA_TYPES.join(", ")}."
- end
- VerifyDataTypeForAdapter.new.call(adapter, data_type)
- rescue InvalidDataTypeForAdapter, UnsupportedAdapter => e
+ @database_adapter = DatabaseAdapter.from_string(adapter_name, data_type)
+ rescue UnsupportedAdapter => e
+ raise Error, e.message
+ rescue InvalidDataTypeForAdapter
+ raise Error,
+ "Invalid value for --data-type option. Supported for options are: #{DatabaseAdapter.from_string(adapter_name).supported_data_types.join(", ")}."
end
def create_migration
- template "#{template_directory}create_event_store_events_template.erb", "db/migrate/#{timestamp}_create_event_store_events.rb"
+ template "#{@database_adapter.template_directory}create_event_store_events_template.erb",
"db/migrate/#{timestamp}_create_event_store_events.rb"
end
private
- def template_directory
- TemplateDirectory.for_adapter(adapter)
- end
def data_type
options.fetch("data_type")
end
- def adapter
- ::ActiveRecord::Base.connection.adapter_name.downcase
+ def adapter_name
+ ::ActiveRecord::Base.connection.adapter_name
end
db:migrations:copy
タスクおよびdb:migrations:add_foreign_key_on_event_id
タスク
task "db:migrations:copy" do
data_type =
ENV["DATA_TYPE"] || raise("Specify data type (binary, json, jsonb): rake db:migrations:copy DATA_TYPE=json")
::ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"])
- database_adapter = ::ActiveRecord::Base.connection.adapter_name
+ database_adapter =
+ RubyEventStore::ActiveRecord::DatabaseAdapter.from_string(::ActiveRecord::Base.connection.adapter_name, data_type)
path =
- RubyEventStore::ActiveRecord::MigrationGenerator.new.call(
- data_type,
- database_adapter,
- ENV["MIGRATION_PATH"] || "db/migrate"
- )
+ RubyEventStore::ActiveRecord::MigrationGenerator.new.call(database_adapter, ENV["MIGRATION_PATH"] || "db/migrate")
puts "Migration file created #{path}"
end
@@ -30,7 +27,8 @@ desc "Generate migration for adding foreign key on event_store_events_in_streams
task "db:migrations:add_foreign_key_on_event_id" do
::ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"])
- path = RubyEventStore::ActiveRecord::ForeignKeyOnEventIdMigrationGenerator.new.call(ENV["MIGRATION_PATH"] || "db/migrate")
+ path =
+ RubyEventStore::ActiveRecord::ForeignKeyOnEventIdMigrationGenerator.new.call(ENV["MIGRATION_PATH"] || "db/migrate")
puts "Migration file created #{path}"
end
このように、条件分岐を削減して多数のprivateメソッドを削除できました。
上述のクラスのテストも大幅にシンプルなものになり、どのデータ型がどのアダプタと互換性があるかを個別にチェックする代わりに、クラスの中核となる責務のテストに専念できるようになりました。
🔗 アダプタが追加されても大丈夫
間もなくRails 7.1でTrilogyと呼ばれる新しいMySQLアダプタが追加される予定です(訳注: その後7.1でTrilogyが追加されました)。この追加にも対応できたら素晴らしいですよね。
Trilogyへの対応は、コードを1行変更し、テストを1行追加するだけですべて完了しました。私たちが既に優れた抽象化を手にしていたからこそ可能だったのです。
module RubyEventStore
module ActiveRecord
class DatabaseAdapter
def self.from_string(adapter_name, data_type = NONE)
raise NoMethodError unless eql?(DatabaseAdapter)
case adapter_name.to_s.downcase
when "postgresql", "postgis"
PostgreSQL.new(data_type)
- when "mysql2"
+ when "mysql2", "trilogy"
MySQL.new(data_type)
when "sqlite"
SQLite.new(data_type)
else
raise UnsupportedAdapter, "Unsupported adapter: #{adapter_name.inspect}"
end
end
end
end
end
+ expect(DatabaseAdapter.from_string("Trilogy")).to eql(DatabaseAdapter::MySQL.new)
TrilogyはMySQL向けのアダプタです。私たちにとって両者には何の違いもありませんし、そのように扱いたいと考えています。
🔗 まとめ
この作業全体に興味がおありの方は、RailsEventStoreのプルリク#1671でDatabaseAdapter
が導入された様子をご覧いただけます。mutant gemのおかげでミューテーションテストのカバレッジは100%になっています。
Value ObjectはRubyエコシステムでまだまだ活用されていないと私は思っています。そういうわけで別記事で別の例を皆さんに提供したいと考えました。この例は、皆さんがMoney
あたりで目にしているようなValue Objectとは一味違います。
不要な分岐や繰り返されがちな分岐を削減して複雑なコードをシンプルにするための、優れたツールなのです。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。