Friday, May 17, 2024
HomeRuby On RailsEssentially the most underused sample in Ruby

Essentially the most underused sample in Ruby


Just lately one of many RailsEventStore customers posted an subject that one needed to make use of RES on a Postgres database with PostGIS extension. Migration generator used to setup tables for occasions and streams was failing with UnsupportedAdapter error.

In RailsEventStore we supported thus far PostgreSQL, MySQL2 and SQLite adapters representing given database engines. However if you wish to make use of talked about PostGIS extension, you want set up further activerecord-postgis-adapter and set adapter: postgis in database.yml. Our code relied on worth returned by:

ActiveRecord::Base.connection.adapter_name.downcase
=> "postgis"

I assumed — Okay, that’s a simple repair, PostGIS is simply an extension, we have to deal with it like Postgres internally when producing migration. Similar information varieties ought to be allowed.

Straightforward repair, requiring a variety of modifications

This simple repair required me to alter 8 recordsdata (4 with implementation and 4 with assessments). One thing is just not okay right here — I assumed. So let’s look via every of them:

I had so as to add postgis to the checklist of SUPPORTED_ADAPTERS in VerifyAdapter class

  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 name(adapter)
          increase UnsupportedAdapter, "Unsupported adapter" until supported?(adapter)
        finish

        personal

        private_constant :SUPPORTED_ADAPTERS

        def supported?(adapter)
          SUPPORTED_ADAPTERS.embody?(adapter.downcase)
        finish
      finish
    finish
  finish

Then I needed to lengthen case assertion in ForeignKeyOnEventIdMigrationGenerator#each_migration methodology

  module RubyEventStore
    module ActiveRecord
      class ForeignKeyOnEventIdMigrationGenerator
        def name(database_adapter, migration_path)
          VerifyAdapter.new.name(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))
          finish
        finish

        personal

        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']
          finish.every(&block)
        finish

        def absolute_path(path)
          File.expand_path(path, __dir__)
        finish

        def migration_code(database_adapter, migration_name)
          migration_template(template_root(database_adapter), migration_name).result_with_hash(migration_version: migration_version)
        finish

        def migration_template(template_root, identify)
          ERB.new(File.learn(File.be part of(template_root, "#{identify}_template.erb")))
        finish

        def template_root(database_adapter)
          absolute_path("./templates/#{template_directory(database_adapter)}")
        finish

        def template_directory(database_adapter)
          TemplateDirectory.for_adapter(database_adapter)
        finish

        def migration_version
          ::ActiveRecord::Migration.current_version
        finish

        def timestamp
          Time.now.strftime("%YpercentmpercentdpercentHpercentMpercentS")
        finish

        def write_to_file(path, migration_code)
          File.write(path, migration_code)
        finish

        def build_path(migration_path, migration_name)
          File.be part of("#{migration_path}", "#{timestamp}_#{migration_name}.rb")
        finish
      finish
    finish
  finish

Similar goes for Rails model of migration generator

  start
    require "rails/mills"
  rescue LoadError
  finish

  if outlined?(Rails::Turbines::Base)
    module RubyEventStore
      module ActiveRecord
        class RailsForeignKeyOnEventIdMigrationGenerator < Rails::Turbines::Base
          class Error < Thor::Error
          finish

          namespace "rails_event_store_active_record:migration_for_foreign_key_on_event_id"

          source_root File.expand_path(File.be part of(File.dirname(__FILE__), "../mills/templates"))

          def initialize(*args)
            tremendous

            VerifyAdapter.new.name(adapter)
          rescue UnsupportedAdapter => e
            increase Error, e.message
          finish

          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"
            finish
          finish

          personal

          def adapter
            ::ActiveRecord::Base.connection.adapter_name.downcase
          finish

          def migration_version
            ::ActiveRecord::Migration.current_version
          finish

          def timestamp
            Time.now.strftime("%YpercentmpercentdpercentHpercentMpercentS")
          finish

          def template_directory
            TemplateDirectory.for_adapter(adapter)
          finish
        finish
      finish
    finish
  finish

What’s essential, each of the migrators used VerifyAdapter class (and two different migrators too).

TemplateDirectory class additionally suffered from primitive obsession and it was utilized by the entire migrators too.

  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/"
          finish
        finish
      finish
    finish
  finish

There was additionally another place — VerifyDataTypeForAdapter which was composed of VerifyAdapter, including verification of knowledge varieties obtainable to given database engine.

Right here we go once more, one other checks of string values, however in a extra particular context:

# 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 name(adapter, data_type)
        VerifyAdapter.new.name(adapter)
        increase InvalidDataTypeForAdapter, "MySQL2 does not help #{data_type}" if is_mysql2?(adapter) && !SUPPORTED_MYSQL_DATA_TYPES.embody?(data_type)
        increase InvalidDataTypeForAdapter, "sqlite does not help #{data_type}" if is_sqlite?(adapter) && supported_by_sqlite?(data_type)
        increase InvalidDataTypeForAdapter, "PostgreSQL does not help #{data_type}" until supported_by_postgres?(data_type)
      finish

      personal

      private_constant :SUPPORTED_POSTGRES_DATA_TYPES, :SUPPORTED_MYSQL_DATA_TYPES, :SUPPORTED_SQLITE_DATA_TYPES

      def is_sqlite?(adapter)
        adapter.downcase.eql?("sqlite")
      finish

      def is_mysql2?(adapter)
        adapter.downcase.eql?("mysql2")
      finish

      def supported_by_sqlite?(data_type)
        !SUPPORTED_SQLITE_DATA_TYPES.embody?(data_type)
      finish

      def supported_by_postgres?(data_type)
        SUPPORTED_POSTGRES_DATA_TYPES.embody?(data_type)
      finish
    finish
  finish
finish

I’ve observed the sample

  • at begin we have to examine whether or not given adapter is allowed (PostgreSQL, MySQL, SQLite)
  • we have to confirm sure information varieties for given adapters to be aligned with database engines
  • then we’ve to decided to generate particular migration for given information sort
  • migration template listing depends upon adapter sort

Let’s do it

Having all this inside a devoted Worth Object would permit decreasing variety of choice timber within the code, checking the identical primitives on and on.

One thing like:

DatabaseAdapter.from_string("postgres")
=> DatabaseAdapter::PostgreSQL.new

DatabaseAdapter.from_string("bazinga")
=> UnsupportedAdapter: "bazinga" (RubyEventStore::ActiveRecord::UnsupportedAdapter)

DatabaseAdapter.from_string("sqlite", "jsonb")
=> SQLite doesn't help "jsonb". Supported varieties are: binary. (RubyEventStore::ActiveRecord::InvalidDataTypeForAdapter)

After few iterations we ended up with the implementation under:

# frozen_string_literal: true

module RubyEventStore
  module ActiveRecord
    UnsupportedAdapter = Class.new(StandardError)
    InvalidDataTypeForAdapter = Class.new(StandardError)

    class DatabaseAdapter
      NOT_SET = Object.new.freeze

      class PostgreSQL < self
        SUPPORTED_DATA_TYPES = %w[binary json jsonb].freeze

        def initialize(data_type = NOT_SET)
          tremendous("postgresql", data_type)
        finish

        def template_directory
          "postgres/"
        finish
      finish

      class MySQL < self
        SUPPORTED_DATA_TYPES = %w[binary json].freeze

        def initialize(data_type = NOT_SET)
          tremendous("mysql2", data_type)
        finish

        def template_directory
          "mysql/"
        finish
      finish

      class SQLite < self
        SUPPORTED_DATA_TYPES = %w[binary].freeze

        def initialize(data_type = NOT_SET)
          tremendous("sqlite", data_type)
        finish
      finish

      def initialize(adapter_name, data_type)
        increase UnsupportedAdapter if instance_of?(DatabaseAdapter)

        validate_data_type!(data_type)

        @adapter_name = adapter_name
        @data_type = data_type
      finish

      attr_reader :adapter_name, :data_type

      def supported_data_types
        self.class::SUPPORTED_DATA_TYPES
      finish

      def eql?(different)
        different.is_a?(DatabaseAdapter) && adapter_name.eql?(different.adapter_name)
      finish

      alias == eql?

      def hash
        DatabaseAdapter.hash ^ adapter_name.hash
      finish

      def template_directory
      finish

      def self.from_string(adapter_name, data_type = NOT_SET)
        increase NoMethodError until 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
          increase UnsupportedAdapter, "Unsupported adapter: #{adapter_name.examine}"
        finish
      finish

      personal

      def validate_data_type!(data_type)
        if !data_type.eql?(NOT_SET) && !supported_data_types.embody?(data_type)
          increase InvalidDataTypeForAdapter,
                "#{class_name} does not help #{data_type.examine}. Supported varieties are: #{supported_data_types.be part of(", ")}."
        finish
      finish

      def class_name
        self.class.identify.break up("::").final
      finish
    finish
  finish
finish

DatabaseAdadpter acts like a guardian class to all the particular adapters.
Particular adapters comprise lists of supported_data_types to entry these by shopper courses and render informative error messages if chosen information is just not supported by given database engine.
They will additionally inform how the template_directory is known as for given adapter.

We now have a single entry with DatabaseAdapter.from_string which accepts adapter_name and optionally data_type that are each validated when creating an occasion of particular adapter.

What’s the result?

Three utility courses may very well be eliminated:

  • VerifyAdapter
  • VerifyDataTypeForAdapter
  • TemplateDirectory

4 courses and two rake duties had been simplified because the Worth Object carriers all the mandatory info for them to proceed:

  • ForeignKeyOnEventIdMigrationGenerator
  module RubyEventStore
    module ActiveRecord
      class ForeignKeyOnEventIdMigrationGenerator
-       def name(database_adapter, migration_path)
-         VerifyAdapter.new.name(database_adapter)
+       def name(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
  module RubyEventStore
    module ActiveRecord
      class MigrationGenerator
-       DATA_TYPES = %w[binary json jsonb].freeze
- 
-       def name(data_type, database_adapter, migration_path)
-         increase ArgumentError, "Invalid worth for information sort. Supported for choices are: #{DATA_TYPES.be part of(", ")}." until DATA_TYPES.embody?(data_type)
-         VerifyDataTypeForAdapter.new.name(database_adapter, data_type)
- 
-         migration_code = migration_code(data_type, database_adapter)
+       def name(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)
        finish

        def template_root(database_adapter)
-         absolute_path("./templates/#{template_directory(database_adapter)}")
-       finish
- 
-       def template_directory(database_adapter)
-         TemplateDirectory.for_adapter(database_adapter)
+         absolute_path("./templates/#{database_adapter.template_directory}")
        finish
      class Error < Thor::Error
      finish

-     DATA_TYPES = %w[binary json jsonb].freeze
-
      namespace "rails_event_store_active_record:migration"

      source_root File.expand_path(File.be part of(File.dirname(__FILE__), "../mills/templates"))


        sort: :string,
        default: "binary",
        desc:
-         "Configure the information sort for `information` and `meta information` fields in Postgres migration (choices: #{DATA_TYPES.be part of("https://weblog.arkency.com/")})"
+         "Configure the information sort for `information` and `meta information` fields in migration (choices: #{DatabaseAdapter::PostgreSQL.new.supported_data_types.be part of(", ")})"
      )

      def initialize(*args)
        tremendous

-       if DATA_TYPES.exclude?(data_type)
-         increase Error, "Invalid worth for --data-type possibility. Supported for choices are: #{DATA_TYPES.be part of(", ")}."
-       finish
-
-       VerifyDataTypeForAdapter.new.name(adapter, data_type)
-     rescue InvalidDataTypeForAdapter, UnsupportedAdapter => e
+       @database_adapter = DatabaseAdapter.from_string(adapter_name, data_type)
+     rescue UnsupportedAdapter => e
+       increase Error, e.message
+     rescue InvalidDataTypeForAdapter
+       increase Error,
+             "Invalid worth for --data-type possibility. Supported for choices are: #{DatabaseAdapter.from_string(adapter_name).supported_data_types.be part of(", ")}."
      finish

      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"
      finish

      personal

-     def template_directory
-       TemplateDirectory.for_adapter(adapter)
-     finish

      def data_type
        choices.fetch("data_type")
      finish

-     def adapter
-       ::ActiveRecord::Base.connection.adapter_name.downcase
+     def adapter_name
+       ::ActiveRecord::Base.connection.adapter_name
      finish
  • db:migrations:copy and db:migrations:add_foreign_key_on_event_id
  process "db:migrations:copy" do
    data_type =
      ENV["DATA_TYPE"] || increase("Specify information sort (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.name(
-       data_type,
-       database_adapter,
-       ENV["MIGRATION_PATH"] || "db/migrate"
-     )
+     RubyEventStore::ActiveRecord::MigrationGenerator.new.name(database_adapter, ENV["MIGRATION_PATH"] || "db/migrate")

    places "Migration file created #{path}"
  finish
  @@ -30,7 +27,8 @@ desc "Generate migration for including international key on event_store_events_in_streams
  process "db:migrations:add_foreign_key_on_event_id" do
    ::ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"])

-   path = RubyEventStore::ActiveRecord::ForeignKeyOnEventIdMigrationGenerator.new.name(ENV["MIGRATION_PATH"] || "db/migrate")
+   path =
+     RubyEventStore::ActiveRecord::ForeignKeyOnEventIdMigrationGenerator.new.name(ENV["MIGRATION_PATH"] || "db/migrate")

    places "Migration file created #{path}"
  finish

We might scale back branching and take away quite a few personal strategies in these.

The assessments of above courses simplified quite a bit and at the moment are targeted on core obligations of the courses reasonably than checking which information varieties are suitable with given adapter.

New adapter, not a giant deal

Quickly, there will likely be new default MySQL adapter in Rails 7.1 referred to as Trilogy. It could be cool to cowl this case already.

The one factor which we needed to do on this case, was to alter one line of code and add single line of check — since we already owned a very good abstraction:

  module RubyEventStore
    module ActiveRecord
      class DatabaseAdapter
        def self.from_string(adapter_name, data_type = NOT_SET)
          increase NoMethodError until 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
            increase UnsupportedAdapter, "Unsupported adapter: #{adapter_name.examine}"
          finish
        finish
      finish
    finish
  finish


+ anticipate(DatabaseAdapter.from_string("Trilogy")).to eql(DatabaseAdapter::MySQL.new)

Trilogy is an adapter for MySQL, there’s no distinction from our perspective, we wish to deal with it as such.

Abstract

In case you’re curious on the total course of, right here’s the PR with the introduction of DatabaseAdapter worth object. The code is 100% lined with mutation testing due to mutant.

I imagine that Worth Object is a completely underused sample in Ruby ecosystem. That’s why I needed to supply one more instance which differs from typical Cash one you often see.

It’s an excellent device to scale back complexity of your code by eradicating pointless or repeatable branching.



RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments