diff --git a/lib/nandi.rb b/lib/nandi.rb index 57cf901..14f20b0 100644 --- a/lib/nandi.rb +++ b/lib/nandi.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "nandi/config" -require "nandi/renderers" require "nandi/compiled_migration" require "active_support/core_ext/string/inflections" require "active_support/deprecation" diff --git a/lib/nandi/config.rb b/lib/nandi/config.rb index 14ed4db..95f8ec0 100644 --- a/lib/nandi/config.rb +++ b/lib/nandi/config.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "nandi/migration_modifiers" -require "nandi/renderers" +require "nandi/renderers/renderer" require "nandi/lockfile" require "nandi/multi_database" @@ -10,12 +10,6 @@ class Config DEFAULT_COMPILE_FILES = "all" DEFAULT_LOCKFILE_DIRECTORY = File.join(Dir.pwd, "db") - # The rendering backend used to produce output. The only supported option - # at current is Nandi::Renderers::ActiveRecord, which produces ActiveRecord - # migrations. - # @return [Class] - attr_accessor :renderer - # The files to compile when the compile generator is run. Default: `all` # May be one of the following: # - 'all' compiles all files @@ -33,8 +27,7 @@ class Config # @api private attr_reader :post_processor, :custom_methods, :migration_modifiers - def initialize(renderer: Renderers::ActiveRecord) - @renderer = renderer + def initialize @custom_methods = {} @compile_files = DEFAULT_COMPILE_FILES @lockfile_directory = DEFAULT_LOCKFILE_DIRECTORY @@ -76,6 +69,7 @@ def lockfile_path(database_name = nil) # Explicitly define getters for backwards compatibility when the database isnt specified. # rubocop:disable Layout/LineLength + def renderer(database_name = nil) = config(database_name).renderer def migration_directory(database_name = nil) = config(database_name).migration_directory def output_directory(database_name = nil) = config(database_name).output_directory def access_exclusive_lock_timeout(database_name = nil) = config(database_name).access_exclusive_lock_timeout @@ -89,7 +83,8 @@ def concurrent_statement_timeout(database_name = nil) = config(database_name).co # rubocop:enable Layout/LineLength # Delegate setter methods to the default database for backwards compatibility - delegate :migration_directory=, + delegate :renderer=, + :migration_directory=, :output_directory=, :access_exclusive_lock_timeout=, :access_exclusive_lock_timeout_max=, diff --git a/lib/nandi/instructions.rb b/lib/nandi/instructions.rb index 7669fad..5a4b05f 100644 --- a/lib/nandi/instructions.rb +++ b/lib/nandi/instructions.rb @@ -15,6 +15,7 @@ require "nandi/instructions/validate_constraint" require "nandi/instructions/add_check_constraint" require "nandi/instructions/irreversible_migration" +require "nandi/yugabyte/instructions/add_index_yb" module Nandi module Instructions; end diff --git a/lib/nandi/instructions/add_column.rb b/lib/nandi/instructions/add_column.rb index a1b76db..872e34e 100644 --- a/lib/nandi/instructions/add_column.rb +++ b/lib/nandi/instructions/add_column.rb @@ -19,6 +19,10 @@ def procedure def lock Nandi::Migration::LockWeights::ACCESS_EXCLUSIVE end + + def validator + Validation::AddColumnValidator + end end end end diff --git a/lib/nandi/instructions/add_index.rb b/lib/nandi/instructions/add_index.rb index 5faee2c..b7ab8f2 100644 --- a/lib/nandi/instructions/add_index.rb +++ b/lib/nandi/instructions/add_index.rb @@ -32,6 +32,10 @@ def lock Nandi::Migration::LockWeights::SHARE end + def validator + Validation::AddIndexValidator + end + attr_reader :table, :fields private diff --git a/lib/nandi/instructions/add_reference.rb b/lib/nandi/instructions/add_reference.rb index be25abb..aac297e 100644 --- a/lib/nandi/instructions/add_reference.rb +++ b/lib/nandi/instructions/add_reference.rb @@ -19,6 +19,10 @@ def procedure def lock Nandi::Migration::LockWeights::ACCESS_EXCLUSIVE end + + def validator + Validation::AddReferenceValidator + end end end end diff --git a/lib/nandi/instructions/remove_index.rb b/lib/nandi/instructions/remove_index.rb index 5612734..92380eb 100644 --- a/lib/nandi/instructions/remove_index.rb +++ b/lib/nandi/instructions/remove_index.rb @@ -24,6 +24,10 @@ def lock Nandi::Migration::LockWeights::SHARE end + def validator + Validation::RemoveIndexValidator + end + attr_reader :table private diff --git a/lib/nandi/migration.rb b/lib/nandi/migration.rb index 92e40fe..423379b 100644 --- a/lib/nandi/migration.rb +++ b/lib/nandi/migration.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true +require "nandi/migration/abstract_base" require "nandi/instructions" -require "nandi/validator" -require "nandi/validation/failure_helpers" module Nandi # @abstract A migration must implement #up (the forward migration), and may @@ -25,93 +24,13 @@ module Nandi # drop_table :widgets # end # end - class Migration - include Nandi::Validation::FailureHelpers + class Migration < AbstractBaseMigration + # Avoids a circular dependency as we want Nandi::Migration to inherit from Nandi::Migration::AbstractBase + AbstractBase = AbstractBaseMigration - module LockWeights - ACCESS_EXCLUSIVE = 1 - SHARE = 0 - end - - class InstructionSet < SimpleDelegator - def strictest_lock - return LockWeights::SHARE if empty? - - map { |i| i.respond_to?(:lock) ? i.lock : LockWeights::ACCESS_EXCLUSIVE }.max - end - end - - class << self - attr_reader :lock_timeout, :statement_timeout - - # For sake both of correspondence with Postgres syntax and familiarity - # with activerecord-safe_migrations's identically named macros, we - # disable this cop. - - # rubocop:disable Naming/AccessorMethodName - - # Override the default lock timeout for the duration of the migration. - # This may be helpful when making changes to very busy tables, when a - # lock is less likely to be immediately available. - # @param timeout [Integer] New lock timeout in ms - def set_lock_timeout(timeout) - @lock_timeout = timeout - end - - # Override the default statement timeout for the duration of the migration. - # This may be helpful when making changes that are likely to take a lot - # of time, like adding a new index on a large table. - # @param timeout [Integer] New lock timeout in ms - def set_statement_timeout(timeout) - @statement_timeout = timeout - end - # rubocop:enable Naming/AccessorMethodName - end - - # @param validator [Nandi::Validator] - # @param database_name [Symbol, nil] The database this migration is being compiled - # for. Used to resolve per-database config. Defaults to the default database. - def initialize(validator, database_name: nil) - @validator = validator - @database_name = database_name - @instructions = Hash.new { |h, k| h[k] = InstructionSet.new([]) } - validate - end - - # @api private - attr_reader :database_name - - # @api private - def up_instructions - compile_instructions(:up) - end - - # @api private - def down_instructions - compile_instructions(:down) - end - - # The current lock timeout. - def lock_timeout - self.class.lock_timeout || default_lock_timeout - end - - # The current statement timeout. - def statement_timeout - self.class.statement_timeout || default_statement_timeout - end - - # @api private - def strictest_lock - @instructions.values.map(&:strictest_lock).max - end - - # @abstract - def up - raise NotImplementedError - end - - def down; end + # Use this declaration instead of Migration::Postgres so existing migrations default to using the Postgres adapter + # without naming changes + Postgres = self # rubocop:disable Naming/ConstantName # Adds a new index to the database. # @@ -310,97 +229,5 @@ def change_column_default(table, column, value) value: value, ) end - - # Raises an `ActiveRecord::IrreversibleMigration` error for use in - # irreversible migrations - def irreversible_migration - current_instructions << Instructions::IrreversibleMigration.new - end - - # @api private - def compile_instructions(direction) - @direction = direction - - public_send(direction) unless current_instructions.any? - - Nandi.config.migration_modifiers.each { |mod| mod.public_send(direction, current_instructions) } - - current_instructions - end - - # @api private - def validate - validator.call(self) - rescue NotImplementedError => e - Validation::Result.new << failure(e.message) - end - - def disable_lock_timeout? - if self.class.lock_timeout.nil? - strictest_lock == LockWeights::SHARE && Nandi.config.concurrent_lock_timeout(database_name).nil? - else - false - end - end - - def disable_statement_timeout? - if self.class.statement_timeout.nil? - strictest_lock == LockWeights::SHARE && Nandi.config.concurrent_statement_timeout(database_name).nil? - else - false - end - end - - def name - self.class.name - end - - def respond_to_missing?(name) - Nandi.config.custom_methods.key?(name) || super - end - - def mixins - (up_instructions + down_instructions).inject([]) do |mixins, i| - i.respond_to?(:mixins) ? [*mixins, *i.mixins] : mixins - end.uniq - end - - def method_missing(name, ...) - if Nandi.config.custom_methods.key?(name) - invoke_custom_method(name, ...) - else - super - end - end - - private - - attr_reader :validator - - def current_instructions - @instructions[@direction] - end - - def default_statement_timeout - if strictest_lock == LockWeights::SHARE - Nandi.config.concurrent_statement_timeout(database_name) || - Nandi.config.access_exclusive_statement_timeout(database_name) - else - Nandi.config.access_exclusive_statement_timeout(database_name) - end - end - - def default_lock_timeout - if strictest_lock == LockWeights::SHARE - Nandi.config.concurrent_lock_timeout(database_name) || Nandi.config.access_exclusive_lock_timeout(database_name) - else - Nandi.config.access_exclusive_lock_timeout(database_name) - end - end - - def invoke_custom_method(name, ...) - klass = Nandi.config.custom_methods[name] - current_instructions << klass.new(...) - end end end diff --git a/lib/nandi/migration/abstract_base.rb b/lib/nandi/migration/abstract_base.rb new file mode 100644 index 0000000..3b30e9f --- /dev/null +++ b/lib/nandi/migration/abstract_base.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require "nandi/instructions" +require "nandi/validator" +require "nandi/validation/failure_helpers" + +module Nandi + # @abstract A migration must implement #up (the forward migration), and may + # also implement #down (the rollback sequence). + # The base class for migrations; Nandi's equivalent of ActiveRecord::Migration. + # All the statements in the migration are statically analysed together to rule + # out migrations with a high risk of causing availability issues. Additionally, + # our implementations of some statements will rule out certain common footguns + # (for example, creating an index without using the `CONCURRENTLY` parameter.) + # @example + # class CreateWidgetsTable < Nandi::Migration + # def up + # create_table :widgets do |t| + # t.column :weight, :number + # t.column :name, :text, default: "Unknown widget" + # end + # end + # + # def down + # drop_table :widgets + # end + # end + class AbstractBaseMigration + include Nandi::Validation::FailureHelpers + + module LockWeights + ACCESS_EXCLUSIVE = 1 + SHARE = 0 + end + + class InstructionSet < SimpleDelegator + def strictest_lock + return LockWeights::SHARE if empty? + + map { |i| i.respond_to?(:lock) ? i.lock : LockWeights::ACCESS_EXCLUSIVE }.max + end + end + + class << self + attr_reader :lock_timeout, :statement_timeout + + # For sake both of correspondence with Postgres syntax and familiarity + # with activerecord-safe_migrations's identically named macros, we + # disable this cop. + + # rubocop:disable Naming/AccessorMethodName + + # Override the default lock timeout for the duration of the migration. + # This may be helpful when making changes to very busy tables, when a + # lock is less likely to be immediately available. + # @param timeout [Integer] New lock timeout in ms + def set_lock_timeout(timeout) + @lock_timeout = timeout + end + + # Override the default statement timeout for the duration of the migration. + # This may be helpful when making changes that are likely to take a lot + # of time, like adding a new index on a large table. + # @param timeout [Integer] New lock timeout in ms + def set_statement_timeout(timeout) + @statement_timeout = timeout + end + # rubocop:enable Naming/AccessorMethodName + end + + # @param validator [Nandi::Validator] + def initialize(validator, database_name: nil) + @validator = validator + @database_name = database_name + @instructions = Hash.new { |h, k| h[k] = InstructionSet.new([]) } + validate + end + + # @api private + attr_reader :database_name + + # @api private + def up_instructions + compile_instructions(:up) + end + + # @api private + def down_instructions + compile_instructions(:down) + end + + # The current lock timeout. + def lock_timeout + self.class.lock_timeout || default_lock_timeout + end + + # The current statement timeout. + def statement_timeout + self.class.statement_timeout || default_statement_timeout + end + + # @api private + def strictest_lock + @instructions.values.map(&:strictest_lock).max + end + + # @abstract + def up + raise NotImplementedError + end + + def down; end + + # Raises an `ActiveRecord::IrreversibleMigration` error for use in + # irreversible migrations + def irreversible_migration + current_instructions << Instructions::IrreversibleMigration.new + end + + # @api private + def compile_instructions(direction) + @direction = direction + + public_send(direction) unless current_instructions.any? + + Nandi.config.migration_modifiers.each { |mod| mod.public_send(direction, current_instructions) } + + current_instructions + end + + # @api private + def validate + validator.call(self, database_name) + rescue NotImplementedError => e + Validation::Result.new << failure(e.message) + end + + def disable_lock_timeout? + if self.class.lock_timeout.nil? + strictest_lock == LockWeights::SHARE && Nandi.config.concurrent_lock_timeout(database_name).nil? + else + false + end + end + + def disable_statement_timeout? + if self.class.statement_timeout.nil? + strictest_lock == LockWeights::SHARE && Nandi.config.concurrent_statement_timeout(database_name).nil? + else + false + end + end + + def name + self.class.name + end + + def respond_to_missing?(name) + Nandi.config.custom_methods.key?(name) || super + end + + def mixins + (up_instructions + down_instructions).inject([]) do |mixins, i| + i.respond_to?(:mixins) ? [*mixins, *i.mixins] : mixins + end.uniq + end + + def method_missing(name, ...) + if Nandi.config.custom_methods.key?(name) + invoke_custom_method(name, ...) + else + super + end + end + + private + + attr_reader :validator + + def current_instructions + @instructions[@direction] + end + + def default_statement_timeout + if strictest_lock == LockWeights::SHARE + Nandi.config.concurrent_statement_timeout(database_name) || + Nandi.config.access_exclusive_statement_timeout(database_name) + else + Nandi.config.access_exclusive_statement_timeout(database_name) + end + end + + def default_lock_timeout + if strictest_lock == LockWeights::SHARE + Nandi.config.concurrent_lock_timeout(database_name) || Nandi.config.access_exclusive_lock_timeout(database_name) + else + Nandi.config.access_exclusive_lock_timeout(database_name) + end + end + + def invoke_custom_method(name, ...) + klass = Nandi.config.custom_methods[name] + current_instructions << klass.new(...) + end + end +end diff --git a/lib/nandi/multi_database.rb b/lib/nandi/multi_database.rb index 6385aad..96f30d8 100644 --- a/lib/nandi/multi_database.rb +++ b/lib/nandi/multi_database.rb @@ -18,6 +18,9 @@ class Database DEFAULT_MIGRATION_DIRECTORY = "db/safe_migrations" DEFAULT_OUTPUT_DIRECTORY = "db/migrate" + DEFAULT_DATABASE_TYPE = :postgres + + attr_accessor :renderer # The default lock timeout for migrations that take ACCESS EXCLUSIVE # locks. Can be overridden by way of the `set_lock_timeout` class @@ -83,6 +86,7 @@ def initialize(name:, config:) @name = name @raw_config = config @default = @name == :primary || config[:default] == true + @renderer = Renderers::Renderer.for_database(config[:database_type] || DEFAULT_DATABASE_TYPE) # Paths and files @migration_directory = config[:migration_directory] || "db/#{path_prefix(name, default)}safe_migrations" diff --git a/lib/nandi/renderers.rb b/lib/nandi/renderers.rb deleted file mode 100644 index a573fbe..0000000 --- a/lib/nandi/renderers.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -require "nandi/renderers/active_record" - -module Nandi - module Renderers; end -end diff --git a/lib/nandi/renderers/abstract_generate.rb b/lib/nandi/renderers/abstract_generate.rb new file mode 100644 index 0000000..a6c7552 --- /dev/null +++ b/lib/nandi/renderers/abstract_generate.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "cell" +require "tilt" + +module Nandi + module Renderers + class AbstractGenerate < ::Cell::ViewModel + def self.call(*args) + super.call + end + + def partials_base + raise NotImplementedError + end + + def template_options_for(_options) + { + suffix: "rb.erb", + template_class: Tilt, + } + end + + self.view_paths = [ + File.expand_path("../../templates", __dir__), + ] + + def should_disable_ddl_transaction? + [*up_instructions, *down_instructions]. + any? { |i| i.procedure.to_s.include?("index") } + end + + def render_partial(instruction) + if instruction.respond_to?(:template) + cell(instruction.template, instruction) + else + cell("#{partials_base}/#{instruction.procedure}", instruction) + end + end + + property :up_instructions + property :down_instructions + property :name + property :mixins + property :disable_lock_timeout? + property :disable_statement_timeout? + property :lock_timeout + property :statement_timeout + end + end +end diff --git a/lib/nandi/renderers/active_record.rb b/lib/nandi/renderers/active_record.rb deleted file mode 100644 index 52a8081..0000000 --- a/lib/nandi/renderers/active_record.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -require "nandi/renderers/active_record/generate" - -module Nandi - module Renderers - module ActiveRecord - def self.generate(migration) - Generate.call(migration) - end - end - end -end diff --git a/lib/nandi/renderers/active_record/generate.rb b/lib/nandi/renderers/active_record/generate.rb index 87d0c81..82aba7f 100644 --- a/lib/nandi/renderers/active_record/generate.rb +++ b/lib/nandi/renderers/active_record/generate.rb @@ -1,58 +1,20 @@ # frozen_string_literal: true +require "nandi/renderers/abstract_generate" require "active_record" -require "cell" -require "tilt" require "nandi/renderers/active_record/instructions" module Nandi module Renderers module ActiveRecord - class Generate < ::Cell::ViewModel - def self.call(*args) - super.call - end - + class Generate < Nandi::Renderers::AbstractGenerate def partials_base "nandi/renderers/active_record/instructions" end - def template_options_for(_options) - { - suffix: "rb.erb", - template_class: Tilt, - } - end - - self.view_paths = [ - File.expand_path("../../../templates", __dir__), - ] - - def should_disable_ddl_transaction? - [*up_instructions, *down_instructions]. - any? { |i| i.procedure.to_s.include?("index") } - end - def activerecord_version ::ActiveRecord::Migration.current_version end - - def render_partial(instruction) - if instruction.respond_to?(:template) - cell(instruction.template, instruction) - else - cell("#{partials_base}/#{instruction.procedure}", instruction) - end - end - - property :up_instructions - property :down_instructions - property :name - property :mixins - property :disable_lock_timeout? - property :disable_statement_timeout? - property :lock_timeout - property :statement_timeout end end end diff --git a/lib/nandi/renderers/renderer.rb b/lib/nandi/renderers/renderer.rb new file mode 100644 index 0000000..d46e2b1 --- /dev/null +++ b/lib/nandi/renderers/renderer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "nandi/renderers/active_record/generate" +require "nandi/yugabyte/renderers/active_record_yugabyte/generate" + +module Nandi + module Renderers + class Renderer + def self.for_database(database_type) + case database_type + when :postgres + POSTGRES + when :yugabyte + YUGABYTE + else + raise "Unsupported database type #{database_type}" + end + end + + def initialize(generator:) + @generator = generator + end + + def generate(migration) + @generator.call(migration) + end + + POSTGRES = new(generator: Nandi::Renderers::ActiveRecord::Generate) + YUGABYTE = new(generator: Nandi::Renderers::ActiveRecordYugabyte::Generate) + end + end +end diff --git a/lib/nandi/validation/add_column_validator.rb b/lib/nandi/validation/add_column_validator.rb index c00e34a..1fbe618 100644 --- a/lib/nandi/validation/add_column_validator.rb +++ b/lib/nandi/validation/add_column_validator.rb @@ -1,20 +1,10 @@ # frozen_string_literal: true -require "nandi/validation/failure_helpers" +require "nandi/validation/instruction_validator" module Nandi module Validation - class AddColumnValidator - include Nandi::Validation::FailureHelpers - - def self.call(instruction) - new(instruction).call - end - - def initialize(instruction) - @instruction = instruction - end - + class AddColumnValidator < InstructionValidator def call collect_errors( assert(nullable? || default_value?, @@ -23,8 +13,6 @@ def call ) end - attr_reader :instruction - private def default_value? diff --git a/lib/nandi/validation/add_index_validator.rb b/lib/nandi/validation/add_index_validator.rb index 788f6a6..a479c19 100644 --- a/lib/nandi/validation/add_index_validator.rb +++ b/lib/nandi/validation/add_index_validator.rb @@ -1,20 +1,10 @@ # frozen_string_literal: true -require "nandi/validation/failure_helpers" +require "nandi/validation/instruction_validator" module Nandi module Validation - class AddIndexValidator - include Nandi::Validation::FailureHelpers - - def self.call(instruction) - new(instruction).call - end - - def initialize(instruction) - @instruction = instruction - end - + class AddIndexValidator < InstructionValidator def call assert( not_using_hash_index?, @@ -24,8 +14,6 @@ def call ) end - attr_reader :instruction - private def not_using_hash_index? diff --git a/lib/nandi/validation/add_reference_validator.rb b/lib/nandi/validation/add_reference_validator.rb index 98157d3..0c6fc3c 100644 --- a/lib/nandi/validation/add_reference_validator.rb +++ b/lib/nandi/validation/add_reference_validator.rb @@ -1,20 +1,10 @@ # frozen_string_literal: true -require "nandi/validation/failure_helpers" +require "nandi/validation/instruction_validator" module Nandi module Validation - class AddReferenceValidator - include Nandi::Validation::FailureHelpers - - def self.call(instruction) - new(instruction).call - end - - def initialize(instruction) - @instruction = instruction - end - + class AddReferenceValidator < InstructionValidator def call foreign_key = instruction.extra_args.fetch(:foreign_key, false) index = instruction.extra_args.fetch(:index, false) @@ -43,8 +33,6 @@ def foreign_key_message "Use the `add_foreign_key` and `validate_foreign_key` methods, or the " \ "nandi:foreign_key generator, to do this." end - - attr_reader :instruction end end end diff --git a/lib/nandi/validation/each_validator.rb b/lib/nandi/validation/each_validator.rb index a346242..85a6889 100644 --- a/lib/nandi/validation/each_validator.rb +++ b/lib/nandi/validation/each_validator.rb @@ -1,36 +1,15 @@ # frozen_string_literal: true -require "nandi/validation/failure_helpers" +require "nandi/validation/instruction_validator" module Nandi module Validation - class EachValidator - include Nandi::Validation::FailureHelpers - - def self.call(instruction) - new(instruction).call - end - - def initialize(instruction) - @instruction = instruction - end - + class EachValidator < InstructionValidator def call - case instruction.procedure - when :add_index - AddIndexValidator.call(instruction) - when :remove_index - RemoveIndexValidator.call(instruction) - when :add_column - AddColumnValidator.call(instruction) - when :add_reference - AddReferenceValidator.call(instruction) - else - success - end - end + return success unless instruction.respond_to?(:validator) - attr_reader :instruction + instruction.validator.call(instruction, db_config.name) + end end end end diff --git a/lib/nandi/validation/failure_helpers.rb b/lib/nandi/validation/failure_helpers.rb index 8a5ee16..cb26739 100644 --- a/lib/nandi/validation/failure_helpers.rb +++ b/lib/nandi/validation/failure_helpers.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "dry/monads/result" +require "dry/monads" module Nandi module Validation diff --git a/lib/nandi/validation/instruction_validator.rb b/lib/nandi/validation/instruction_validator.rb new file mode 100644 index 0000000..b40dfe9 --- /dev/null +++ b/lib/nandi/validation/instruction_validator.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "nandi/validation/failure_helpers" + +module Nandi + module Validation + class InstructionValidator + include Nandi::Validation::FailureHelpers + + def self.call(instruction, db_name = nil) + new(instruction, db_name).call + end + + def initialize(instruction, db_name) + @instruction = instruction + @db_name = db_name + end + + def call + raise NotImplementedError + end + + def db_config + @db_config ||= Nandi.config.database(@db_name) + end + + attr_reader :instruction + end + end +end diff --git a/lib/nandi/validation/remove_index_validator.rb b/lib/nandi/validation/remove_index_validator.rb index cd5cd99..9217f9e 100644 --- a/lib/nandi/validation/remove_index_validator.rb +++ b/lib/nandi/validation/remove_index_validator.rb @@ -1,20 +1,10 @@ # frozen_string_literal: true -require "nandi/validation/failure_helpers" +require "nandi/validation/instruction_validator" module Nandi module Validation - class RemoveIndexValidator - include Nandi::Validation::FailureHelpers - - def self.call(instruction) - new(instruction).call - end - - def initialize(instruction) - @instruction = instruction - end - + class RemoveIndexValidator < InstructionValidator def call opts = instruction.extra_args @@ -23,8 +13,6 @@ def call "remove_index: requires a `name` or `column` argument", ) end - - attr_reader :instruction end end end diff --git a/lib/nandi/validator.rb b/lib/nandi/validator.rb index 0258473..9a8cca9 100644 --- a/lib/nandi/validator.rb +++ b/lib/nandi/validator.rb @@ -8,28 +8,13 @@ module Nandi class Validator include Nandi::Validation::FailureHelpers - class InstructionValidator - def self.call(instruction) - new(instruction).call - end - - def initialize(instruction) - @instruction = instruction - end - - def call - raise NotImplementedError - end - - attr_reader :instruction - end - - def self.call(migration) - new(migration).call + def self.call(migration, db_name = nil) + new(migration, db_name).call end - def initialize(migration) + def initialize(migration, db_name = nil) @migration = migration + @db_name = db_name end def call @@ -85,7 +70,7 @@ def lock_timeout_is_within_acceptable_bounds def each_instruction_validation instructions.inject(success) do |result, instruction| - collect_errors(Validation::EachValidator.call(instruction), result) + collect_errors(Validation::EachValidator.call(instruction, db_name), result) end end @@ -97,6 +82,6 @@ def instructions [*migration.up_instructions, *migration.down_instructions] end - attr_reader :migration + attr_reader :migration, :db_name end end diff --git a/lib/nandi/yugabyte/instructions/add_index_yb.rb b/lib/nandi/yugabyte/instructions/add_index_yb.rb new file mode 100644 index 0000000..2fac307 --- /dev/null +++ b/lib/nandi/yugabyte/instructions/add_index_yb.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Nandi + module Instructions + module Yugabyte + class AddIndexYb < Nandi::Instructions::AddIndex + def procedure + :add_index_yb + end + + def template + Nandi::Renderers::ActiveRecordYugabyte::Instructions::AddIndexYbCell + end + end + end + end +end diff --git a/lib/nandi/yugabyte/migration/yugabyte.rb b/lib/nandi/yugabyte/migration/yugabyte.rb new file mode 100644 index 0000000..a40fa3d --- /dev/null +++ b/lib/nandi/yugabyte/migration/yugabyte.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Nandi + class Migration::Yugabyte < Nandi::Migration + def add_index(table, fields, **kwargs) + current_instructions << Instructions::Yugabyte::AddIndexYb.new( + **kwargs, + table: table, + fields: fields, + ) + end + end +end diff --git a/lib/nandi/yugabyte/renderers/active_record_yugabyte/generate.rb b/lib/nandi/yugabyte/renderers/active_record_yugabyte/generate.rb new file mode 100644 index 0000000..ae18cc1 --- /dev/null +++ b/lib/nandi/yugabyte/renderers/active_record_yugabyte/generate.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "nandi/renderers/active_record/generate" +require "nandi/yugabyte/renderers/active_record_yugabyte/instructions" + +module Nandi + module Renderers + module ActiveRecordYugabyte + class Generate < Nandi::Renderers::ActiveRecord::Generate + def partials_base + "nandi/renderers/active_record/instructions" + end + end + end + end +end diff --git a/lib/nandi/yugabyte/renderers/active_record_yugabyte/instructions.rb b/lib/nandi/yugabyte/renderers/active_record_yugabyte/instructions.rb new file mode 100644 index 0000000..9b24644 --- /dev/null +++ b/lib/nandi/yugabyte/renderers/active_record_yugabyte/instructions.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Nandi + module Renderers + module ActiveRecordYugabyte + module Instructions + include Nandi::Renderers::ActiveRecord::Instructions + + class AddIndexYbCell < Nandi::Renderers::ActiveRecord::Instructions::Base + self.view_paths = [ + File.expand_path("../../templates", __dir__), + ] + + def self.controller_path + "instructions/add_index_yb" + end + + # Because all this stuff goes into a SQL string, we don't need to format + # the values. + property :table + property :fields + property :extra_args + + def unique? + model.extra_args[:unique] + end + + def name + model.extra_args[:name] + end + + def fields + if model.extra_args[:bucket_on].present? + bucket_field = "(yb_hash_code(#{model.extra_args[:bucket_on]}) % #{model.extra_args[:bucket_count]})" + end + [bucket_field, *model.fields].compact.join(", ") + end + end + end + end + end +end diff --git a/lib/nandi/yugabyte/templates/instructions/add_index_yb/show.rb.erb b/lib/nandi/yugabyte/templates/instructions/add_index_yb/show.rb.erb new file mode 100644 index 0000000..caf5b90 --- /dev/null +++ b/lib/nandi/yugabyte/templates/instructions/add_index_yb/show.rb.erb @@ -0,0 +1,3 @@ +execute <<-SQL + CREATE <%= 'UNIQUE ' if unique? %>INDEX <%= name %> ON <%= table %> (<%= fields %>) +SQL diff --git a/spec/nandi/fixtures/rendered/active_record/create_and_drop_index_new.rb b/spec/nandi/fixtures/rendered/active_record/create_and_drop_index_new.rb new file mode 100644 index 0000000..1bd0a6c --- /dev/null +++ b/spec/nandi/fixtures/rendered/active_record/create_and_drop_index_new.rb @@ -0,0 +1,33 @@ +class MyAwesomeMigration < ActiveRecord::Migration[8.0] + + + disable_lock_timeout! + + + disable_statement_timeout! + + + disable_ddl_transaction! + def up + + execute <<-SQL + CREATE INDEX idx_payments_on_foo_bar ON payments ((yb_hash_code(id) % 16), foo, bar) +SQL + + + end + + def down + + remove_index( + :payments, + **{ + column: [:foo, :bar], + algorithm: :concurrently +} +) + + + end + +end diff --git a/spec/nandi/renderers/active_record_spec.rb b/spec/nandi/renderers/active_record/generate_spec.rb similarity index 98% rename from spec/nandi/renderers/active_record_spec.rb rename to spec/nandi/renderers/active_record/generate_spec.rb index 98028e3..4185467 100644 --- a/spec/nandi/renderers/active_record_spec.rb +++ b/spec/nandi/renderers/active_record/generate_spec.rb @@ -1,20 +1,20 @@ # frozen_string_literal: true require "spec_helper" -require "nandi/renderers/active_record" +require "nandi/renderers/renderer" require "nandi/migration" require "nandi/validator" -RSpec.describe Nandi::Renderers::ActiveRecord do - describe "::generate" do +RSpec.describe Nandi::Renderers::ActiveRecord::Generate do + describe "#generate" do subject(:migration) do - described_class.generate(safe_migration.new(Nandi::Validator)) + described_class.call(safe_migration.new(Nandi::Validator)) end let(:fixture_root) do File.join( File.dirname(__FILE__), - "../fixtures/rendered/active_record", + "../../fixtures/rendered/active_record", ) end diff --git a/spec/nandi/validation/each_validator_spec.rb b/spec/nandi/validation/each_validator_spec.rb index 365625c..ff7f1ec 100644 --- a/spec/nandi/validation/each_validator_spec.rb +++ b/spec/nandi/validation/each_validator_spec.rb @@ -11,70 +11,50 @@ describe "#call" do context "when the given instruction is to remove an index" do - let(:instruction) { instance_double(Nandi::Instructions::RemoveIndex) } - - before do - allow(instruction).to receive(:procedure).and_return(:remove_index) - end + let(:instruction) { Nandi::Instructions::RemoveIndex.new(table: :payments, field: :foo) } it "calls RemoveIndexValidator" do expect(Nandi::Validation::RemoveIndexValidator).to receive(:call). - with(instruction) + with(instruction, :primary) call end end context "when the given instruction is to add a column" do - let(:instruction) { instance_double(Nandi::Instructions::AddColumn) } - - before do - allow(instruction).to receive(:procedure).and_return(:add_column) - end + let(:instruction) { Nandi::Instructions::AddColumn.new(table: :payments, name: :foo, type: :text) } it "calls AddColumnValidator" do - expect(Nandi::Validation::AddColumnValidator).to receive(:call).with(instruction) + expect(Nandi::Validation::AddColumnValidator).to receive(:call).with(instruction, :primary) call end end context "when the given instruction is to add a reference" do - let(:instruction) { instance_double(Nandi::Instructions::AddReference) } - - before do - allow(instruction).to receive(:procedure).and_return(:add_reference) - end + let(:instruction) { Nandi::Instructions::AddReference.new(table: :payments, ref_name: :user) } it "calls AddReferenceValidator" do expect(Nandi::Validation::AddReferenceValidator).to receive(:call). - with(instruction) + with(instruction, :primary) call end end context "when the given instruction is to add an index" do - let(:instruction) { instance_double(Nandi::Instructions::AddIndex) } - - before do - allow(instruction).to receive(:procedure).and_return(:add_index) - end + let(:instruction) { Nandi::Instructions::AddIndex.new(table: :payments, fields: [:foo]) } it "calls AddIndexValidator" do expect(Nandi::Validation::AddIndexValidator).to receive(:call). - with(instruction) + with(instruction, :primary) call end end context "when the given instruction isn't explicitly validated" do - let(:instruction) { instance_double(Nandi::Instructions::AddForeignKey) } - - before do - allow(instruction).to receive(:procedure).and_return(:add_foreign_key) - end + let(:instruction) { Nandi::Instructions::AddForeignKey.new(table: :payments, target: :users) } it "returns successful" do expect(call).to eq(Dry::Monads::Result::Success.new(nil)) diff --git a/spec/nandi/yugabyte/renderers/active_record_yugabyte/generate_spec.rb b/spec/nandi/yugabyte/renderers/active_record_yugabyte/generate_spec.rb new file mode 100644 index 0000000..fbfe452 --- /dev/null +++ b/spec/nandi/yugabyte/renderers/active_record_yugabyte/generate_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "spec_helper" +require "nandi/renderers/renderer" +require "nandi/migration" +require "nandi/yugabyte/migration/yugabyte" +require "nandi/validator" + +RSpec.describe Nandi::Renderers::ActiveRecordYugabyte::Generate do + describe "#generate" do + subject(:migration) do + described_class.call(safe_migration.new(Nandi::Validator)) + end + + let(:fixture_root) do + File.join( + File.dirname(__FILE__), + "../../fixtures/rendered/active_record", + ) + end + + let(:current_rails_version) do + ActiveRecord::Migration.current_version + end + + def normalize_fixture(content) + content.gsub(/ActiveRecord::Migration\[\d+\.\d+\]/, "ActiveRecord::Migration[#{current_rails_version}]") + end + + describe "adding a yugabyte-specific index" do + let(:fixture) do + normalize_fixture(File.read(File.join(fixture_root, "create_and_drop_index_new.rb"))) + end + + let(:safe_migration) do + Class.new(Nandi::Migration::Yugabyte) do + def self.name + "MyAwesomeMigration" + end + + def up + add_index :payments, %i[foo bar], bucket_on: :id, bucket_count: 16 + end + + def down + remove_index :payments, %i[foo bar] + end + end + end + + it { is_expected.to eq(fixture) } + end + end +end