diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9acb3e94..f3bc8a33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ name: Test on: [push, pull_request] jobs: - test: + test_mysql: strategy: fail-fast: false matrix: @@ -36,6 +36,43 @@ jobs: run: sudo systemctl start mysql.service - run: bin/setup - run: bundle exec rake + + test_trilogy: + strategy: + fail-fast: false + matrix: + ruby: + - 3.2 + - 3.3 + - 3.4 + gemfile: + - gemfiles/rails_8_1.gemfile + env: + DB_ADAPTER: "trilogy" + PERCONA_DB_USER: root + PERCONA_DB_PASSWORD: root + BUNDLE_GEMFILE: ${{ matrix.gemfile }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: "Add Percona GPG key" + run: sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 9334A25F8507EFA5 + - name: "Add Percona APT repository" + run: echo "deb http://repo.percona.com/apt `lsb_release -cs` main" | sudo tee -a /etc/apt/sources.list + - run: sudo apt-get update -qq + - run: sudo apt-get install percona-toolkit + - name: Start MySQL server + run: sudo systemctl start mysql.service + - name: Configure MySQL for Trilogy + run: | + sudo mysql -u root -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root'; FLUSH PRIVILEGES;" + - run: bin/setup + - run: bundle exec rake + lint: strategy: fail-fast: false diff --git a/.rubocop.yml b/.rubocop.yml index 6a8b14af..7c8d1ead 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -28,6 +28,8 @@ Layout/LineLength: Exclude: - 'departure.gemspec' - 'test_database.rb' + - 'spec/*' + - 'spec/**/*' Metrics/MethodLength: Max: 30 diff --git a/Appraisals b/Appraisals index 164058de..2da80096 100644 --- a/Appraisals +++ b/Appraisals @@ -10,4 +10,5 @@ end appraise 'rails-8-1' do gem 'rails', '8.1.1' + gem 'trilogy' end diff --git a/README.md b/README.md index 913fc8e5..9bca27ff 100644 --- a/README.md +++ b/README.md @@ -151,9 +151,16 @@ It's strongly recommended to name it after this gems name, such as All configuration options are configurable from the `Departure.configure` block example below -|Option|Default|What it Controls| -|---|---|---| -|disable_rails_advisory_lock_patch|false|When truthy, disables a patch in at least rails 7.1 and 7.2 where rails throws ConcurrentMigrationErrors due to the inability to release the advisory lock in migrations| +| Option | Default | What it Controls | +|-----------------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| disable_rails_advisory_lock_patch | false | When truthy, disables a patch in at least rails 7.1 and 7.2 where rails throws ConcurrentMigrationErrors due to the inability to release the advisory lock in migrations | + +### Trilogy Adapter Support + +Starting in Rails 8.1 we add support for the use of the trilogy database adapter gem. Logic for selecting an adapter follows this logic + +1. If the database configuration specifies 'trilogy' use the trilogy adapter +2. Default to mysql2 ### Disable on per-migration basis @@ -226,7 +233,7 @@ adapters. %% Core Departure Components subgraph "Departure System" RailsAdapter["RailsAdapter
(Version Detection)"] - DepartureAdapter["Rails81DepartureAdapter
(Connection Adapter)"] + DepartureAdapter["Rails81MysqlAdapter
(Connection Adapter)"] Runner["Runner
(Query Interceptor)"] Command["Command
(Process Executor)"] CliGenerator["CliGenerator
(Command Builder)"] diff --git a/gemfiles/rails_7_2.gemfile b/gemfiles/rails_7_2.gemfile index a4240263..1264792b 100644 --- a/gemfiles/rails_7_2.gemfile +++ b/gemfiles/rails_7_2.gemfile @@ -1,16 +1,16 @@ # This file was generated by Appraisal -source "https://rubygems.org" +source 'https://rubygems.org' -gem "base64" -gem "codeclimate-test-reporter", "~> 1.0.3", group: :test, require: nil -gem "lhm" -gem "logger" -gem "mutex_m", require: false -gem "rubocop", "~> 1.74.0", require: false -gem "rubocop-performance", "~> 1.20.2", require: false -gem "zeitwerk", "< 2.7.0" -gem "bigdecimal" -gem "rails", "7.2.2.1" +gem 'base64' +gem 'bigdecimal' +gem 'codeclimate-test-reporter', '~> 1.0.3', group: :test, require: nil +gem 'lhm' +gem 'logger' +gem 'mutex_m', require: false +gem 'rails', '7.2.2.1' +gem 'rubocop', '~> 1.74.0', require: false +gem 'rubocop-performance', '~> 1.20.2', require: false +gem 'zeitwerk', '< 2.7.0' -gemspec path: "../" +gemspec path: '../' diff --git a/gemfiles/rails_8_0.gemfile b/gemfiles/rails_8_0.gemfile index 742c791d..79cd0c9b 100644 --- a/gemfiles/rails_8_0.gemfile +++ b/gemfiles/rails_8_0.gemfile @@ -1,16 +1,16 @@ # This file was generated by Appraisal -source "https://rubygems.org" +source 'https://rubygems.org' -gem "base64" -gem "codeclimate-test-reporter", "~> 1.0.3", group: :test, require: nil -gem "lhm" -gem "logger" -gem "mutex_m", require: false -gem "rubocop", "~> 1.74.0", require: false -gem "rubocop-performance", "~> 1.20.2", require: false -gem "zeitwerk", "< 2.7.0" -gem "bigdecimal" -gem "rails", "8.0.2.1" +gem 'base64' +gem 'bigdecimal' +gem 'codeclimate-test-reporter', '~> 1.0.3', group: :test, require: nil +gem 'lhm' +gem 'logger' +gem 'mutex_m', require: false +gem 'rails', '8.0.2.1' +gem 'rubocop', '~> 1.74.0', require: false +gem 'rubocop-performance', '~> 1.20.2', require: false +gem 'zeitwerk', '< 2.7.0' -gemspec path: "../" +gemspec path: '../' diff --git a/gemfiles/rails_8_1.gemfile b/gemfiles/rails_8_1.gemfile index 07e7cc31..21b127d7 100644 --- a/gemfiles/rails_8_1.gemfile +++ b/gemfiles/rails_8_1.gemfile @@ -1,15 +1,16 @@ # This file was generated by Appraisal -source "https://rubygems.org" +source 'https://rubygems.org' -gem "base64" -gem "codeclimate-test-reporter", "~> 1.0.3", group: :test, require: nil -gem "lhm" -gem "logger" -gem "mutex_m", require: false -gem "rubocop", "~> 1.74.0", require: false -gem "rubocop-performance", "~> 1.20.2", require: false -gem "zeitwerk", "< 2.7.0" -gem "rails", "8.1.1" +gem 'base64' +gem 'codeclimate-test-reporter', '~> 1.0.3', group: :test, require: nil +gem 'lhm' +gem 'logger' +gem 'mutex_m', require: false +gem 'rails', '8.1.1' +gem 'rubocop', '~> 1.74.0', require: false +gem 'rubocop-performance', '~> 1.20.2', require: false +gem 'trilogy' +gem 'zeitwerk', '< 2.7.0' -gemspec path: "../" +gemspec path: '../' diff --git a/gemfiles/rails_8_1.gemfile.lock b/gemfiles/rails_8_1.gemfile.lock index e0420011..2adfcf9a 100644 --- a/gemfiles/rails_8_1.gemfile.lock +++ b/gemfiles/rails_8_1.gemfile.lock @@ -270,6 +270,7 @@ GEM stringio (3.1.7) thor (1.4.0) timeout (0.4.4) + trilogy (2.9.0) tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -310,6 +311,7 @@ DEPENDENCIES rspec-its (~> 1.2) rubocop (~> 1.74.0) rubocop-performance (~> 1.20.2) + trilogy zeitwerk (< 2.7.0) BUNDLED WITH diff --git a/lib/active_record/connection_adapters/rails_8_1_departure_adapter.rb b/lib/active_record/connection_adapters/rails_8_1_mysql2_adapter.rb similarity index 95% rename from lib/active_record/connection_adapters/rails_8_1_departure_adapter.rb rename to lib/active_record/connection_adapters/rails_8_1_mysql2_adapter.rb index 886eff87..3fd94b77 100644 --- a/lib/active_record/connection_adapters/rails_8_1_departure_adapter.rb +++ b/lib/active_record/connection_adapters/rails_8_1_mysql2_adapter.rb @@ -6,12 +6,12 @@ module ActiveRecord module ConnectionAdapters - class Rails81DepartureAdapter < ActiveRecord::ConnectionAdapters::Mysql2Adapter + class Rails81Mysql2Adapter < ActiveRecord::ConnectionAdapters::Mysql2Adapter TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map(m) } if defined?(initialize_type_map) class Column < ActiveRecord::ConnectionAdapters::MySQL::Column def adapter - Rails81DepartureAdapter + Rails81Mysql2Adapter end end @@ -33,8 +33,6 @@ def visit_DropForeignKey(name) # rubocop:disable Naming/MethodName end end - extend Forwardable - include ForAlterStatements unless method_defined?(:change_column_for_alter) ADAPTER_NAME = 'Percona'.freeze diff --git a/lib/active_record/connection_adapters/rails_8_1_trilogy_adapter.rb b/lib/active_record/connection_adapters/rails_8_1_trilogy_adapter.rb new file mode 100644 index 00000000..a22670a1 --- /dev/null +++ b/lib/active_record/connection_adapters/rails_8_1_trilogy_adapter.rb @@ -0,0 +1,77 @@ +require 'active_record/connection_adapters/abstract_mysql_adapter' +require 'active_record/connection_adapters/trilogy_adapter' +require 'active_record/connection_adapters/patch_connection_handling' +require 'departure' +require 'forwardable' + +module ActiveRecord + module ConnectionAdapters + class Rails81TrilogyAdapter < ActiveRecord::ConnectionAdapters::TrilogyAdapter + class Column < ActiveRecord::ConnectionAdapters::MySQL::Column + def adapter + Rails81TrilogyAdapter + end + end + + # https://github.com/departurerb/departure/commit/f178ca26cd3befa1c68301d3b57810f8cdcff9eb + # For `DROP FOREIGN KEY constraint_name` with pt-online-schema-change requires specifying `_constraint_name` + # rather than the real constraint_name due to to a limitation in MySQL + # pt-online-schema-change adds a leading underscore to foreign key constraint names when creating the new table. + # https://www.percona.com/blog/2017/03/21/dropping-foreign-key-constraint-using-pt-online-schema-change-2/ + class SchemaCreation < ActiveRecord::ConnectionAdapters::MySQL::SchemaCreation + def visit_DropForeignKey(name) # rubocop:disable Naming/MethodName + fk_name = + if name =~ /^__(.+)/ + Regexp.last_match(1) + else + "_#{name}" + end + + "DROP FOREIGN KEY #{fk_name}" + end + end + + include ForAlterStatements unless method_defined?(:change_column_for_alter) + + ADAPTER_NAME = 'Percona'.freeze + + def self.new_client(config) + original_client = super + + Departure::DbClient.new(config, original_client) + end + + # add_index is modified from the underlying mysql adapter implementation to ensure we add ALTER TABLE to it + def add_index(table_name, column_name, options = {}) + index_definition, = add_index_options(table_name, column_name, **options) + execute <<-SQL.squish + ALTER TABLE #{quote_table_name(index_definition.table)} + ADD #{schema_creation.accept(index_definition)} + SQL + end + + # remove_index is modified from the underlying mysql adapter implementation to ensure we add ALTER TABLE to it + def remove_index(table_name, column_name = nil, **options) + return if options[:if_exists] && !index_exists?(table_name, column_name, **options) + + index_name = index_name_for_remove(table_name, column_name, options) + + execute "ALTER TABLE #{quote_table_name(table_name)} DROP INDEX #{quote_column_name(index_name)}" + end + + def schema_creation + SchemaCreation.new(self) + end + + private + + # rubocop:disable Metrics/ParameterLists + def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch: false) + return raw_connection.send_to_pt_online_schema_change(sql) if raw_connection.alter_statement?(sql) + + super + end + # rubocop:enable Metrics/ParameterLists + end + end +end diff --git a/lib/departure.rb b/lib/departure.rb index f75c2d36..cfff97bd 100644 --- a/lib/departure.rb +++ b/lib/departure.rb @@ -23,8 +23,6 @@ # We need the OS not to buffer the IO to see pt-osc's output while migrating $stdout.sync = true -Departure::RailsAdapter.for_current.register_integrations - module Departure class << self attr_accessor :configuration diff --git a/lib/departure/configuration.rb b/lib/departure/configuration.rb index a6d94830..356e9054 100644 --- a/lib/departure/configuration.rb +++ b/lib/departure/configuration.rb @@ -1,7 +1,7 @@ module Departure class Configuration attr_accessor :tmp_path, :global_percona_args, :enabled_by_default, :redirect_stderr, - :disable_rails_advisory_lock_patch + :disable_rails_advisory_lock_patch, :db_adapter_name def initialize @tmp_path = '.'.freeze @@ -9,6 +9,7 @@ def initialize @global_percona_args = nil @enabled_by_default = true @redirect_stderr = true + @db_adapter_name = nil end def error_log_path diff --git a/lib/departure/rails_adapter.rb b/lib/departure/rails_adapter.rb index 1fb7f4cb..a3a872f5 100644 --- a/lib/departure/rails_adapter.rb +++ b/lib/departure/rails_adapter.rb @@ -10,7 +10,13 @@ class UnsupportedRailsVersionError < StandardError; end class MustImplementError < StandardError; end class << self + def register_integrations(**args) + for_current(**args).register_integrations + end + def version_matches?(version_string, compatibility_string = current_version::STRING) + raise "Invalid Gem Version: '#{version_string}'" unless Gem::Version.correct?(version_string) + requirement = Gem::Requirement.new(compatibility_string) requirement.satisfied_by?(Gem::Version.new(version_string)) end @@ -19,13 +25,19 @@ def current_version ActiveRecord::VERSION end - def for_current - self.for(current_version) + def for_current(**args) + self.for(current_version, **args) end - def for(ar_version) + # rubocop:disable Metrics/PerceivedComplexity + def for(ar_version, db_connection_adapter: nil) + # rubocop:enable Metrics/PerceivedComplexity if ar_version::MAJOR == 8 && ar_version::MINOR.positive? - V8_1_Adapter + if db_connection_adapter == 'trilogy' + V8_1_TrilogyAdapter + else + V8_1_Mysql2Adapter + end elsif ar_version::MAJOR == 8 V8_0_Adapter elsif ar_version::MAJOR >= 7 && ar_version::MINOR >= 2 @@ -69,14 +81,12 @@ def register_integrations require 'active_record/connection_adapters/rails_7_2_departure_adapter' require 'departure/rails_patches/active_record_migrator_with_advisory_lock_patch' - ActiveSupport.on_load(:active_record) do - ActiveRecord::Migration.class_eval do - include Departure::Migration - end - - ActiveRecord::Migrator.prepend Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch + ActiveRecord::Migration.class_eval do + include Departure::Migration end + ActiveRecord::Migrator.prepend Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch + ActiveRecord::ConnectionAdapters.register 'percona', 'ActiveRecord::ConnectionAdapters::Rails72DepartureAdapter', 'active_record/connection_adapters/rails_7_2_departure_adapter' @@ -98,14 +108,12 @@ def register_integrations require 'active_record/connection_adapters/rails_8_0_departure_adapter' require 'departure/rails_patches/active_record_migrator_with_advisory_lock_patch' - ActiveSupport.on_load(:active_record) do - ActiveRecord::Migration.class_eval do - include Departure::Migration - end - - ActiveRecord::Migrator.prepend Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch + ActiveRecord::Migration.class_eval do + include Departure::Migration end + ActiveRecord::Migrator.prepend Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch + ActiveRecord::ConnectionAdapters.register 'percona', 'ActiveRecord::ConnectionAdapters::Rails80DepartureAdapter', 'active_record/connection_adapters/rails_8_0_departure_adapter' @@ -121,27 +129,25 @@ def sql_column end end - class V8_1_Adapter < BaseAdapter # rubocop:disable Naming/ClassAndModuleCamelCase + class V8_1_Mysql2Adapter < BaseAdapter # rubocop:disable Naming/ClassAndModuleCamelCase class << self def register_integrations - require 'active_record/connection_adapters/rails_8_1_departure_adapter' + require 'active_record/connection_adapters/rails_8_1_mysql2_adapter' require 'departure/rails_patches/active_record_migrator_with_advisory_lock_patch' - ActiveSupport.on_load(:active_record) do - ActiveRecord::Migration.class_eval do - include Departure::Migration - end - - ActiveRecord::Migrator.prepend Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch + ActiveRecord::Migration.class_eval do + include Departure::Migration end + ActiveRecord::Migrator.prepend Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch + ActiveRecord::ConnectionAdapters.register 'percona', - 'ActiveRecord::ConnectionAdapters::Rails81DepartureAdapter', - 'active_record/connection_adapters/rails_8_1_departure_adapter' + 'ActiveRecord::ConnectionAdapters::Rails81Mysql2Adapter', + 'active_record/connection_adapters/rails_8_1_mysql2_adapter' end def create_connection_adapter(**config) - ActiveRecord::ConnectionAdapters::Rails81DepartureAdapter.new(config) + ActiveRecord::ConnectionAdapters::Rails81Mysql2Adapter.new(config) end # rubocop:disable Metrics/ParameterLists @@ -162,5 +168,28 @@ def sql_column end end end + + class V8_1_TrilogyAdapter < V8_1_Mysql2Adapter # rubocop:disable Naming/ClassAndModuleCamelCase + class << self + def register_integrations + require 'active_record/connection_adapters/rails_8_1_trilogy_adapter' + require 'departure/rails_patches/active_record_migrator_with_advisory_lock_patch' + + ActiveRecord::Migration.class_eval do + include Departure::Migration + end + + ActiveRecord::Migrator.prepend Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch + + ActiveRecord::ConnectionAdapters.register 'percona', + 'ActiveRecord::ConnectionAdapters::Rails81TrilogyAdapter', + 'active_record/connection_adapters/rails_8_1_trilogy_adapter' + end + + def create_connection_adapter(**config) + ActiveRecord::ConnectionAdapters::Rails81TrilogyAdapter.new(config) + end + end + end end end diff --git a/lib/departure/railtie.rb b/lib/departure/railtie.rb index 2faec9af..0e9cd4bb 100644 --- a/lib/departure/railtie.rb +++ b/lib/departure/railtie.rb @@ -10,6 +10,8 @@ class Railtie < Rails::Railtie Departure.configure do |config| config.tmp_path = app.paths['tmp'].first end + + Departure::RailsAdapter.register_integrations end config.after_initialize do diff --git a/spec/active_record/connection_adapters/rails_8_0_departure_adapter_spec.rb b/spec/active_record/connection_adapters/rails_8_0_departure_adapter_spec.rb new file mode 100644 index 00000000..19335994 --- /dev/null +++ b/spec/active_record/connection_adapters/rails_8_0_departure_adapter_spec.rb @@ -0,0 +1,472 @@ +require 'spec_helper' + +if rails_version_under_test_matches?(RAILS_8_0, __FILE__) + require 'active_record/connection_adapters/rails_8_0_departure_adapter' + + describe ActiveRecord::ConnectionAdapters::Rails80DepartureAdapter, activerecord_compatibility: RAILS_8_0 do + describe ActiveRecord::ConnectionAdapters::Rails80DepartureAdapter::Column do + let(:field) { double(:field) } + let(:default) { double(:default) } + let(:cast_type) do + if defined?(ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::MysqlString) + ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::MysqlString.new + else + ActiveRecord::Type.lookup(:string, adapter: :mysql2) + end + end + let(:metadata) do + ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new( + type: cast_type.type, + sql_type: type, + limit: cast_type.limit + ) + end + let(:mysql_metadata) do + ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata.new(metadata) + end + let(:type) { 'VARCHAR' } + let(:null) { double(:null) } + let(:collation) { double(:collation) } + + let(:column) do + described_class.new('field', 'default', mysql_metadata, null, collation: 'collation') + end + + describe '#adapter' do + subject { column.adapter } + it do + is_expected.to eq( + ActiveRecord::ConnectionAdapters::Rails80DepartureAdapter + ) + end + end + end + + let(:config) do + { + prepared_statements: '', + username: 'root', + password: 'password', + database: 'some_test_db' + } + end + + let(:internal_added_config) do + { + adapter: 'mysql2', + flags: anything + } + end + + let(:database_version) { double(full_version_string: '8.0.01') } + let(:mysql_adapter) do + instance_double(ActiveRecord::ConnectionAdapters::Mysql2Adapter, get_database_version: database_version) + end + let(:logger) { double(:logger, puts: true) } + let(:query_options) { { database_timezone: :utc } } + let(:runner) do + instance_double(Departure::Runner).tap do |r| + allow(r).to receive(:database_adapter).and_return(mysql_adapter) + allow(r).to receive(:query_options).and_return(query_options) + allow(r).to receive(:close).and_return(true) + allow(r).to receive(:abandon_results!).and_return(true) + allow(r).to receive(:affected_rows).and_return(1) + allow(r).to receive(:query).and_return(nil) + allow(r).to receive(:execute).with('percona command').and_return(true) + end + end + let(:cli_generator) { instance_double(Departure::CliGenerator, generate: 'percona command') } + let(:adapter) { described_class.new(config).tap { |adapter| adapter.send(:connect) } } + let(:mysql_client) { double(:mysql_client) } + + before do + allow(mysql_client).to receive(:server_info).and_return(version: '8.0.19') + allow(mysql_adapter).to receive(:raw_connection).and_return(mysql_client) + allow(Departure::LoggerFactory).to receive(:build) { logger } + + # Add a default stub for Mysql2Adapter.new to handle any config + allow(ActiveRecord::ConnectionAdapters::Mysql2Adapter).to receive(:new).and_return(mysql_adapter) + + allow(Departure::CliGenerator).to( + receive(:new).and_return(cli_generator) + ) + allow(Departure::Runner).to( + receive(:new).with(logger, cli_generator, mysql_adapter) + ).and_return(runner) + end + + it '#supports_migrations?' do + expect(adapter.supports_migrations?).to eql(true) + end + + describe '#new_column' do + let(:field) { double(:field) } + let(:default) { double(:default) } + let(:type) { double(:type) } + let(:null) { double(:null) } + let(:collation) { double(:collation) } + let(:table_name) { double(:table_name) } + let(:default_function) { double(:default_function) } + let(:comment) { double(:comment) } + + it do + expect(ActiveRecord::ConnectionAdapters::Rails80DepartureAdapter::Column).to receive(:new) + adapter.new_column(field, default, type, null, table_name, default_function, collation, comment) + end + end + + describe 'schema statements' do + describe '#add_index' do + let(:table_name) { :foo } + let(:column_name) { :bar_id } + let(:index_name) { 'index_name' } + let(:options) { { type: 'index_type' } } + let(:index_type) { options[:type].upcase } + let(:sql) { 'ADD index_type INDEX `index_name` (bar_id)' } + let(:index_options) do + [ + ActiveRecord::ConnectionAdapters::IndexDefinition.new( + table_name, + index_name, + nil, + [column_name], + **options + ), + nil, + false + ] + end + + let(:expected_sql) do + "ALTER TABLE `#{table_name}` ADD #{index_type} INDEX `#{index_name}` (`#{column_name}`)" + end + + before do + allow(adapter).to( + receive(:add_index_options) + .with(table_name, column_name, options) + .and_return(index_options) + ) + end + + it 'passes the built SQL to #execute' do + allow(runner).to receive(:query).with(anything) + expect(runner).to receive(:close) + expect(adapter).to receive(:execute).with(expected_sql) + adapter.add_index(table_name, column_name, options) + end + end + + describe '#remove_index' do + let(:table_name) { :foo } + let(:options) { { column: :bar_id } } + let(:sql) { 'DROP INDEX `index_name`' } + + before do + allow(adapter).to( + receive(:index_name_for_remove) + .with(table_name, options) + .and_return('index_name') + ) + allow(adapter).to( + receive(:index_name_for_remove) + .with(table_name, nil, options) + .and_return('index_name') + ) + end + + it 'passes the built SQL to #execute' do + expect(adapter).to( + receive(:execute) + .with("ALTER TABLE `#{table_name}` DROP INDEX `index_name`") + ) + adapter.remove_index(table_name, **options) + end + end + end + + describe '#exec_delete' do + let(:sql) { 'DELETE FROM comments WHERE id = 1' } + let(:affected_rows) { 1 } + let(:name) { nil } + let(:binds) { nil } + + before do + allow(runner).to receive(:query).with(anything) + allow(mysql_client).to receive(:affected_rows).and_return(affected_rows) + end + + it 'executes the sql' do + expect(runner).to receive(:affected_rows) + expect(adapter).to(receive(:execute).with(sql, name)) + adapter.exec_delete(sql, name, binds) + end + + it 'returns the number of affected rows' do + expect(runner).to receive(:close) + expect(runner).to receive(:affected_rows) { affected_rows } + expect(adapter.exec_delete(sql, name, binds)).to eq(affected_rows) + end + end + + describe '#exec_insert' do + let(:sql) { 'INSERT INTO comments (id) VALUES (20)' } + let(:name) { nil } + let(:binds) { nil } + + it 'executes the sql' do + expect(adapter).to(receive(:execute).with(sql, name)) + adapter.exec_insert(sql, name, binds) + end + end + + describe '#exec_query' do + let(:sql) { 'SELECT * FROM comments' } + let(:name) { nil } + let(:binds) { nil } + + before do + allow(runner).to receive(:query).with(sql) + allow(adapter).to( + receive(:execute).with(sql, name).and_return(result_set) + ) + end + + context 'when the adapter returns results' do + let(:result_set) { double(fields: ['id'], to_a: [1]) } + + it 'executes the sql' do + expect(adapter).to( + receive(:execute).with(sql, name) + ).and_return(result_set) + + adapter.exec_query(sql, name, binds) + end + + it 'returns an ActiveRecord::Result' do + expect(ActiveRecord::Result).to( + receive(:new).with(result_set.fields, result_set.to_a) + ) + adapter.exec_query(sql, name, binds) + end + end + + context 'when the adapter returns nil' do + let(:result_set) { nil } + + it 'executes the sql' do + expect(adapter).to( + receive(:execute).with(sql, name) + ).and_return(result_set) + + adapter.exec_query(sql, name, binds) + end + + it 'returns an ActiveRecord::Result' do + expect(ActiveRecord::Result).to( + receive(:new).with([], []) + ) + adapter.exec_query(sql, name, binds) + end + end + end + + describe '#last_inserted_id' do + let(:result) { double(:result) } + + it 'delegates to the mysql adapter' do + expect(mysql_adapter).to( + receive(:last_inserted_id).with(result) + ) + adapter.last_inserted_id(result) + end + end + + describe '#select_rows' do + subject { adapter.select_rows(sql, name) } + + let(:sql) { 'SELECT id, body FROM comments' } + let(:name) { nil } + + let(:array_of_rows) { [%w[1 body], %w[2 body]] } + let(:mysql2_result) do + # rubocop:disable Style/WordArray + instance_double(Mysql2::Result, to_a: array_of_rows, fields: ['id', 'body']) + # rubocop:enable Style/WordArray + end + + before do + allow(adapter).to( + receive(:execute).with(sql, name) + ).and_return(mysql2_result) + end + + it { is_expected.to match_array(array_of_rows) } + end + + describe '#select' do + subject { adapter.select(sql, name) } + + let(:sql) { 'SELECT id, body FROM comments' } + let(:name) { nil } + + let(:array_of_rows) { [%w[1 body], %w[2 body]] } + let(:mysql2_result) do + instance_double(Mysql2::Result, fields: %w[id body], to_a: array_of_rows) + end + + before do + allow(adapter).to( + receive(:execute).with(sql, name) + ).and_return(mysql2_result) + end + + it do + is_expected.to match_array( + [ + { 'id' => '1', 'body' => 'body' }, + { 'id' => '2', 'body' => 'body' } + ] + ) + end + end + + describe '#write_query?' do + it 'identifies write queries correctly' do + expect(adapter.write_query?('INSERT INTO comments (id) VALUES (1)')).to be true + expect(adapter.write_query?('UPDATE comments SET body = "test"')).to be true + expect(adapter.write_query?('DELETE FROM comments WHERE id = 1')).to be true + expect(adapter.write_query?('ALTER TABLE comments ADD COLUMN test VARCHAR(255)')).to be true + end + + it 'identifies read queries correctly' do + expect(adapter.write_query?('SELECT * FROM comments')).to be false + expect(adapter.write_query?('SHOW TABLES')).to be false + expect(adapter.write_query?('DESCRIBE comments')).to be false + expect(adapter.write_query?('DESC comments')).to be false + end + end + + describe '#full_version' do + it 'returns the database version' do + expect(adapter.full_version).to eq('8.0.01') + end + end + + describe '#get_full_version' do + it 'caches the version after first call' do + version = adapter.get_full_version + expect(version).to eq('8.0.01') + + # Call again to test caching + expect(mysql_adapter).not_to receive(:get_database_version) + second_version = adapter.get_full_version + expect(second_version).to eq('8.0.01') + end + end + + describe '#schema_creation' do + it 'returns a SchemaCreation instance' do + expect(adapter.schema_creation).to be_a( + ActiveRecord::ConnectionAdapters::Rails80DepartureAdapter::SchemaCreation + ) + end + end + + describe ActiveRecord::ConnectionAdapters::Rails80DepartureAdapter::SchemaCreation do + let(:adapter) { instance_double(ActiveRecord::ConnectionAdapters::Rails80DepartureAdapter) } + let(:schema_creation) { described_class.new(adapter) } + + describe '#visit_DropForeignKey' do + context 'when the foreign key name has double underscore prefix' do + it 'removes the double underscore prefix' do + result = schema_creation.visit_DropForeignKey('__fk_constraint_name') + expect(result).to eq('DROP FOREIGN KEY fk_constraint_name') + end + end + + context 'when the foreign key name does not have double underscore prefix' do + it 'adds a single underscore prefix' do + result = schema_creation.visit_DropForeignKey('fk_constraint_name') + expect(result).to eq('DROP FOREIGN KEY _fk_constraint_name') + end + end + end + end + + describe '.new_client' do + before do + # For .new_client tests, we need to stub Mysql2Adapter without the flags requirement + # since new_client doesn't go through the initialization that adds flags + allow(ActiveRecord::ConnectionAdapters::Mysql2Adapter).to receive(:new) + .with(hash_including(adapter: 'mysql2')).and_return(mysql_adapter) + + # Stub ConnectionDetails which is used by new_client + allow(Departure::ConnectionDetails).to receive(:new).with(config).and_return( + double('ConnectionDetails', + password: 'password', + username: 'root', + hostname: 'localhost', + database: 'some_test_db', + port: 3306) + ) + end + + it 'creates a new Departure::Runner instance' do + # Allow real Runner creation for this test + allow(Departure::Runner).to receive(:new).and_call_original + + client = described_class.new_client(config) + expect(client).to be_a(Departure::Runner) + end + + it 'configures the runner with proper dependencies' do + expect(Departure::LoggerFactory).to receive(:build).and_return(logger) + expect(Departure::CliGenerator).to receive(:new).and_return(cli_generator) + expect(Departure::Runner).to receive(:new).with(logger, cli_generator, anything) + + described_class.new_client(config) + end + end + + describe '#change_table' do + let(:table_name) { :test_table } + let(:recorder) { instance_double(ActiveRecord::Migration::CommandRecorder, commands: []) } + + before do + allow(ActiveRecord::Migration::CommandRecorder).to receive(:new).and_return(recorder) + allow(adapter).to receive(:update_table_definition).and_return(double) + allow(adapter).to receive(:bulk_change_table) + end + + it 'uses a CommandRecorder to track changes' do + expect(ActiveRecord::Migration::CommandRecorder).to receive(:new).with(adapter) + adapter.change_table(table_name) { |_t| } + end + + it 'calls bulk_change_table with the recorded commands' do + expect(adapter).to receive(:bulk_change_table).with(table_name, []) + adapter.change_table(table_name) { |_t| } + end + end + + describe 'initialization' do + it 'sets prepared_statements to false' do + new_adapter = described_class.new(config) + expect(new_adapter.instance_variable_get(:@prepared_statements)).to be false + end + + it 'configures flags for FOUND_ROWS when flags is a number' do + config_with_flags = config.merge(flags: 0) + new_adapter = described_class.new(config_with_flags) + expect(new_adapter.instance_variable_get(:@config)[:flags]).to eq(Mysql2::Client::FOUND_ROWS) + end + + it 'adds FOUND_ROWS to flags array when flags is an array' do + config_with_flags = config.merge(flags: []) + new_adapter = described_class.new(config_with_flags) + expect(new_adapter.instance_variable_get(:@config)[:flags]).to include('FOUND_ROWS') + end + end + end +end diff --git a/spec/active_record/connection_adapters/rails_8_1_departure_adapter_spec.rb b/spec/active_record/connection_adapters/rails_8_1_departure_adapter_spec.rb deleted file mode 100644 index 26497d75..00000000 --- a/spec/active_record/connection_adapters/rails_8_1_departure_adapter_spec.rb +++ /dev/null @@ -1,14 +0,0 @@ -require 'spec_helper' -require 'active_record/connection_adapters/rails_8_1_departure_adapter' - -describe ActiveRecord::ConnectionAdapters::Rails81DepartureAdapter, activerecord_compatibility: RAILS_8_1 do - let(:adapter) { described_class.new(db_config_for(adapter: 'mysql2')) } - let(:client) { described_class.new_client(db_config_for(adapter: 'mysql2')) } - - describe '#new_client' do - it 'wraps the underlying db_client and exposes a mysql_client' do - expect(client).to be_a(Departure::DbClient) - expect(client.database_client).to be_a(Mysql2::Client) - end - end -end diff --git a/spec/active_record/connection_adapters/rails_8_1_mysql2_adapter_spec.rb b/spec/active_record/connection_adapters/rails_8_1_mysql2_adapter_spec.rb new file mode 100644 index 00000000..178b5087 --- /dev/null +++ b/spec/active_record/connection_adapters/rails_8_1_mysql2_adapter_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +if rails_version_under_test_matches?(RAILS_8_1, __FILE__) + require 'active_record/connection_adapters/rails_8_1_mysql2_adapter' + + describe ActiveRecord::ConnectionAdapters::Rails81Mysql2Adapter, activerecord_compatibility: RAILS_8_1 do + let(:adapter) { described_class.new(db_config_for(adapter: 'mysql2')) } + let(:client) { described_class.new_client(db_config_for(adapter: 'mysql2')) } + + describe '#new_client' do + it 'wraps the underlying db_client and exposes a mysql_client' do + expect(client).to be_a(Departure::DbClient) + expect(client.database_client).to be_a(Mysql2::Client) + end + end + + describe 'database_statements' do + let(:table_name) { :foo } + let(:column_name) { :bar_id } + let(:index_name) { 'index_name' } + let(:options) { { type: 'index_type' } } + + describe '#add_index' do + let(:index_definition) do + ActiveRecord::ConnectionAdapters::IndexDefinition.new( + table_name, + index_name, + nil, + [column_name], + **options + ) + end + + let(:index_options) { [index_definition, nil, false] } + let(:index_type) { options[:type].upcase } + let(:schema_creation_double) { instance_double(described_class::SchemaCreation) } + + it 'passes the built ALTER TABLE SQL to #execute' do + allow(adapter).to receive(:shard) { :default } + allow(adapter).to receive(:role) { :writing } + + expect(schema_creation_double).to receive(:accept).with(index_definition) { + "INDEX_TYPE INDEX `#{index_name}` (`#{column_name}`)" + } + expect(adapter).to receive(:schema_creation) { schema_creation_double } + + expect(adapter).to receive(:add_index_options).with(table_name, column_name, + options).and_return(index_options) + execute_sql = "ALTER TABLE `#{table_name}` ADD #{index_type} INDEX `#{index_name}` (`#{column_name}`)" + expect(adapter).to receive(:execute).with(execute_sql).and_return(true) + + adapter.add_index(table_name, column_name, options) + end + end + + describe '#remove_index' do + let(:options) { { column: column_name } } + let(:sql) { "DROP INDEX `#{index_name}`" } + + it 'passes the built ALTER TABLE SQL to #execute' do + allow(adapter).to receive(:shard) { :default } + allow(adapter).to receive(:role) { :writing } + expect(adapter).to receive(:index_name_for_remove).with(table_name, nil, options).and_return(index_name.to_s) + execute_sql = "ALTER TABLE `#{table_name}` DROP INDEX `#{index_name}`" + expect(adapter).to receive(:execute).with(execute_sql).and_return(true) + + adapter.remove_index(table_name, **options) + end + end + end + end +end diff --git a/spec/active_record/connection_adapters/rails_8_1_trilogy_adapter_spec.rb b/spec/active_record/connection_adapters/rails_8_1_trilogy_adapter_spec.rb new file mode 100644 index 00000000..ca1b5feb --- /dev/null +++ b/spec/active_record/connection_adapters/rails_8_1_trilogy_adapter_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe 'ActiveRecord::ConnectionAdapters::Rails81TrilogyAdapter', activerecord_compatibility: RAILS_8_1 do + let(:described_class) do + # has to be required here because trilogy doesn't exist in older versions of rails + require 'active_record/connection_adapters/rails_8_1_trilogy_adapter' + + ActiveRecord::ConnectionAdapters::Rails81TrilogyAdapter + end + + let(:adapter) { described_class.new(db_config_for(adapter: 'trilogy')) } + let(:client) { described_class.new_client(db_config_for(adapter: 'trilogy')) } + let(:trilogy_double) { instance_double(::Trilogy) } + + describe '#new_client' do + it 'wraps the underlying db_client and exposes a mysql_client' do + expect_any_instance_of(::Trilogy).to receive(:_connect) { trilogy_double } + + expect(client).to be_a(Departure::DbClient) + expect(client.database_client).to be_a(::Trilogy) + end + end + + describe 'database_statements' do + let(:table_name) { :foo } + let(:column_name) { :bar_id } + let(:index_name) { 'index_name' } + let(:options) { { type: 'index_type' } } + + describe '#add_index' do + let(:index_definition) do + ActiveRecord::ConnectionAdapters::IndexDefinition.new( + table_name, + index_name, + nil, + [column_name], + **options + ) + end + + let(:index_options) { [index_definition, nil, false] } + let(:index_type) { options[:type].upcase } + let(:schema_creation_double) { instance_double(described_class::SchemaCreation) } + + it 'passes the built ALTER TABLE SQL to #execute' do + allow(adapter).to receive(:shard) { :default } + allow(adapter).to receive(:role) { :writing } + + expect(schema_creation_double).to receive(:accept).with(index_definition) { + "INDEX_TYPE INDEX `#{index_name}` (`#{column_name}`)" + } + expect(adapter).to receive(:schema_creation) { schema_creation_double } + + expect(adapter).to receive(:add_index_options).with(table_name, column_name, options).and_return(index_options) + execute_sql = "ALTER TABLE `#{table_name}` ADD #{index_type} INDEX `#{index_name}` (`#{column_name}`)" + expect(adapter).to receive(:execute).with(execute_sql).and_return(true) + + adapter.add_index(table_name, column_name, options) + end + end + + describe '#remove_index' do + let(:options) { { column: column_name } } + let(:sql) { "DROP INDEX `#{index_name}`" } + + it 'passes the built ALTER TABLE SQL to #execute' do + allow(adapter).to receive(:shard) { :default } + allow(adapter).to receive(:role) { :writing } + expect(adapter).to receive(:index_name_for_remove).with(table_name, nil, options).and_return(index_name.to_s) + execute_sql = "ALTER TABLE `#{table_name}` DROP INDEX `#{index_name}`" + expect(adapter).to receive(:execute).with(execute_sql).and_return(true) + + adapter.remove_index(table_name, **options) + end + end + end +end diff --git a/spec/departure/ci_context.rb b/spec/departure/ci_context.rb new file mode 100644 index 00000000..378c31ce --- /dev/null +++ b/spec/departure/ci_context.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe 'CI Context' do + it 'uses the proper runner in integration specs', integration: true, activerecord_compatibility: RAILS_8_1 do + establish_default_database_connection + + case ENV['DB_ADAPTER'] + when 'trilogy' + expect(ActiveRecord::Base.connection.adapter_name).to eql('Trilogy') + when 'mysql2' + expect(ActiveRecord::Base.connection.adapter_name).to eql('Mysql2') + else + raise StandardError, 'Your test is not specifying a DB_ADAPTER of mysql2 or trilogy' + end + end +end diff --git a/spec/departure/configuration_spec.rb b/spec/departure/configuration_spec.rb index 984c279c..db084e8a 100644 --- a/spec/departure/configuration_spec.rb +++ b/spec/departure/configuration_spec.rb @@ -4,6 +4,12 @@ describe '#initialize' do its(:tmp_path) { is_expected.to eq('.') } its(:error_log_filename) { is_expected.to eq('departure_error.log') } + its(:db_adapter_name) { is_expected.to be_nil } + end + + describe '#db_adapter_name=' do + subject { described_class.new.tmp_path = 'trilogy' } + it { is_expected.to eq('trilogy') } end describe '#tmp_path' do diff --git a/spec/departure/migration_spec.rb b/spec/departure/migration_spec.rb index 122f5b15..8d3aa351 100644 --- a/spec/departure/migration_spec.rb +++ b/spec/departure/migration_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Departure::Migration do + before { setup_departure_integrations } + let(:base) do Class.new do attr_accessor :migrated_direction diff --git a/spec/integration/indexes_spec.rb b/spec/integration/indexes_spec.rb index 271137f5..e2256bd5 100644 --- a/spec/integration/indexes_spec.rb +++ b/spec/integration/indexes_spec.rb @@ -64,7 +64,7 @@ class Comment < ActiveRecord::Base; end it 'executes the percona command' do if ActiveRecord::Base.connection.send(:supports_rename_index?) - expect_percona_command('RENAME INDEX `index_comments_on_some_id_field` TO `new_index_comments_on_some_id_field`') # rubocop:disable Metrics/LineLength + expect_percona_command('RENAME INDEX `index_comments_on_some_id_field` TO `new_index_comments_on_some_id_field`') else expect_percona_command('ADD INDEX `new_index_comments_on_some_id_field` (`some_id_field`)') expect_percona_command('DROP INDEX `index_comments_on_some_id_field`') diff --git a/spec/integration/rails_adapter_spec.rb b/spec/integration/rails_adapter_spec.rb index 97b5b79f..4588ead9 100644 --- a/spec/integration/rails_adapter_spec.rb +++ b/spec/integration/rails_adapter_spec.rb @@ -14,21 +14,42 @@ def gem_version_for(string) end end + def instance_for(version, db_connection_adapter = 'mysql2') + described_class.for(gem_version_for(version), db_connection_adapter:) + end + + context 'rails 8.1 adapter' do + describe 'returns trilogy adapter' do + it 'when the config specifies an adapter of trilogy' do + expect(instance_for('8.1.0', 'trilogy')).to be(Departure::RailsAdapter::V8_1_TrilogyAdapter) + end + end + + describe 'returns mysql2 adapter' do + it 'by default' do + expect(instance_for('8.1.0')).to be(Departure::RailsAdapter::V8_1_Mysql2Adapter) + expect(instance_for('8.1.0.beta1')).to be(Departure::RailsAdapter::V8_1_Mysql2Adapter) + end + + it 'when the config specifies an adapter of mysql2' do + expect(instance_for('8.1.0', 'mysql2')).to be(Departure::RailsAdapter::V8_1_Mysql2Adapter) + end + + it 'when the config specifies anything else' do + expect(instance_for('8.1.0', 'percona')).to be(Departure::RailsAdapter::V8_1_Mysql2Adapter) + end + end + end + it 'returns the correct adapater based on the gem version' do - expect(described_class.for(gem_version_for('8.1.0'))).to be(Departure::RailsAdapter::V8_1_Adapter) - expect(described_class.for(gem_version_for('8.1.0.beta1'))).to be(Departure::RailsAdapter::V8_1_Adapter) - expect(described_class.for(gem_version_for('8.0.1'))).to be(Departure::RailsAdapter::V8_0_Adapter) - expect(described_class.for(gem_version_for('8.0.0'))).to be(Departure::RailsAdapter::V8_0_Adapter) - expect(described_class.for(gem_version_for('7.2.0'))).to be(Departure::RailsAdapter::V7_2_Adapter) + expect(instance_for('8.0.1')).to be(Departure::RailsAdapter::V8_0_Adapter) + expect(instance_for('8.0.0')).to be(Departure::RailsAdapter::V8_0_Adapter) + expect(instance_for('7.2.0')).to be(Departure::RailsAdapter::V7_2_Adapter) end - it 'raises an exception on before 7.2' do - expect do - described_class.for(gem_version_for('7.1.0')) - end.to raise_error(described_class::UnsupportedRailsVersionError) - expect do - described_class.for(gem_version_for('6.1.0')) - end.to raise_error(described_class::UnsupportedRailsVersionError) + it 'raises an exception for older versiosn of rails' do + expect { instance_for('7.1.0') }.to raise_error(Departure::RailsAdapter::UnsupportedRailsVersionError) + expect { instance_for('6.1.0') }.to raise_error(Departure::RailsAdapter::UnsupportedRailsVersionError) end end @@ -66,6 +87,82 @@ def gem_version_for(string) end end + describe '.register_integrations' do + it 'delegates to the adapter for the current Rails version' do + adapter_class = described_class.for_current + expect(adapter_class).to receive(:register_integrations) + + described_class.register_integrations + end + + it 'passes through keyword arguments to the adapter' do + expect(described_class).to receive(:for_current).with(db_connection_adapter: 'trilogy').and_call_original + adapter_class = described_class.for_current(db_connection_adapter: 'trilogy') + allow(described_class).to receive(:for_current).with(db_connection_adapter: 'trilogy').and_return(adapter_class) + expect(adapter_class).to receive(:register_integrations) + + described_class.register_integrations(db_connection_adapter: 'trilogy') + end + + context 'when called on specific adapters' do + it 'requires the correct adapter file and registers components for V7_2_Adapter' do + expect(described_class::V7_2_Adapter).to receive(:require).with('active_record/connection_adapters/rails_7_2_departure_adapter') + expect(described_class::V7_2_Adapter).to receive(:require).with('departure/rails_patches/active_record_migrator_with_advisory_lock_patch') + expect(ActiveRecord::Migration).to receive(:class_eval) + expect(ActiveRecord::Migrator).to receive(:prepend).with(Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch) + expect(ActiveRecord::ConnectionAdapters).to receive(:register).with( + 'percona', + 'ActiveRecord::ConnectionAdapters::Rails72DepartureAdapter', + 'active_record/connection_adapters/rails_7_2_departure_adapter' + ) + + described_class::V7_2_Adapter.register_integrations + end + + it 'requires the correct adapter file and registers components for V8_0_Adapter' do + expect(described_class::V8_0_Adapter).to receive(:require).with('active_record/connection_adapters/rails_8_0_departure_adapter') + expect(described_class::V8_0_Adapter).to receive(:require).with('departure/rails_patches/active_record_migrator_with_advisory_lock_patch') + expect(ActiveRecord::Migration).to receive(:class_eval) + expect(ActiveRecord::Migrator).to receive(:prepend).with(Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch) + expect(ActiveRecord::ConnectionAdapters).to receive(:register).with( + 'percona', + 'ActiveRecord::ConnectionAdapters::Rails80DepartureAdapter', + 'active_record/connection_adapters/rails_8_0_departure_adapter' + ) + + described_class::V8_0_Adapter.register_integrations + end + + it 'requires the correct adapter file and registers components for V8_1_Mysql2Adapter' do + expect(described_class::V8_1_Mysql2Adapter).to receive(:require).with('active_record/connection_adapters/rails_8_1_mysql2_adapter') + expect(described_class::V8_1_Mysql2Adapter).to receive(:require).with('departure/rails_patches/active_record_migrator_with_advisory_lock_patch') + expect(ActiveRecord::Migration).to receive(:class_eval) + expect(ActiveRecord::Migrator).to receive(:prepend).with(Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch) + expect(ActiveRecord::ConnectionAdapters).to receive(:register).with( + 'percona', + 'ActiveRecord::ConnectionAdapters::Rails81Mysql2Adapter', + 'active_record/connection_adapters/rails_8_1_mysql2_adapter' + ) + + described_class::V8_1_Mysql2Adapter.register_integrations + end + + it 'requires the correct adapter file and registers components for V8_1_TrilogyAdapter' do + expect(described_class::V8_1_TrilogyAdapter).to receive(:require).with('active_record/connection_adapters/rails_8_1_trilogy_adapter') + expect(described_class::V8_1_TrilogyAdapter).to receive(:require).with('departure/rails_patches/active_record_migrator_with_advisory_lock_patch') + expect(ActiveRecord::Migration).to receive(:class_eval) + expect(ActiveRecord::Migrator).to receive(:prepend).with(Departure::RailsPatches::ActiveRecordMigratorWithAdvisoryLockPatch) + expect(ActiveRecord::ConnectionAdapters).to receive(:register).with( + 'percona', + 'ActiveRecord::ConnectionAdapters::Rails81TrilogyAdapter', + 'active_record/connection_adapters/rails_8_1_trilogy_adapter' + ) + + described_class::V8_1_TrilogyAdapter.register_integrations + end + end + end + describe 'advisory_lock patch' do it 'runs migrations without throwing an ActiveRecord::ConcurrentMigration Error' do expect { run_a_migration(:up, 1) }.not_to raise_error diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8065619c..a0930343 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -29,8 +29,6 @@ test_database = TestDatabase.new(db_config) -Departure::RailsAdapter.for_current.register_integrations - RSpec.configure do |config| config.include TableMethods config.filter_run_when_matching :focus @@ -52,9 +50,13 @@ # Cleans up the database before each example, so the current example doesn't # see the state of the previous one config.before(:each) do |example| - establish_mysql_connection + establish_default_database_connection + + if example.metadata[:integration] + test_database.setup - test_database.setup if example.metadata[:integration] + Departure::RailsAdapter.for_current.register_integrations + end end config.order = :random @@ -74,3 +76,16 @@ def initialize(migrations_paths, schema_migration = nil) if ActiveRecord::VERSION::STRING >= '7.1' ActiveRecord::MigrationContext.send :prepend, Rails7Compatibility::MigrationContext end + +def rails_version_under_test_matches?(version_string, file) + Departure::RailsAdapter.version_matches?(ActiveRecord::VERSION::STRING, version_string).tap do |result| + unless result + error = "Skip #{file} test - current '#{version_string}' not matching version #{ActiveRecord::VERSION::STRING}" + + puts '' + puts '-- *** INFO ****' + puts "-- #{error}" + puts '' + end + end +end diff --git a/spec/support/database_helpers.rb b/spec/support/database_helpers.rb index 887f3f22..130a5af6 100644 --- a/spec/support/database_helpers.rb +++ b/spec/support/database_helpers.rb @@ -1,21 +1,55 @@ MIGRATION_FIXTURES = File.expand_path('../dummy/db/migrate', __dir__) -def db_config_for(adapter:, **kwargs) +def db_config_for(adapter:) db_config = Configuration.new - { adapter:, - **db_config.config, - **kwargs + **db_config.config + } +end + +def establish_default_database_connection(**config, &block) + case ENV['DB_ADAPTER'] + when 'trilogy' + establish_trilogy_connection(**config, &block) + when 'percona' + establish_percona_connection(**config, &block) + else + establish_mysql_connection(**config, &block) + end +end + +def build_connection_config(adapter, **config) + c = { + **db_config_for(adapter: adapter), + **config } + + yield c if block_given? + + c +end + +def establish_trilogy_connection(**config, &block) + c = build_connection_config('trilogy', **config, &block) + + ActiveRecord::Base.establish_connection(c) end -def establish_percona_connection(**kwargs) - ActiveRecord::Base.establish_connection(**db_config_for(adapter: 'percona', **kwargs)) +def establish_percona_connection(**config, &block) + c = build_connection_config('percona', **config, &block) + + ActiveRecord::Base.establish_connection(c) +end + +def establish_mysql_connection(**config, &block) + c = build_connection_config('mysql2', **config, &block) + + ActiveRecord::Base.establish_connection(c) end -def establish_mysql_connection(**kwargs) - ActiveRecord::Base.establish_connection(**db_config_for(adapter: 'mysql2', **kwargs)) +def setup_departure_integrations + Departure::RailsAdapter.for_current.register_integrations end def disable_departure_rails_advisory_lock_patch