diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de52162d..ab932930 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: matrix: # https://www.cockroachlabs.com/docs/releases/release-support-policy crdb: [v23.2, v24.1, v24.2] - ruby: ["3.3"] + ruby: [3.4] name: Test (crdb=${{ matrix.crdb }} ruby=${{ matrix.ruby }}) steps: - name: Set Up Actions @@ -88,4 +88,7 @@ jobs: done cat ${{ github.workspace }}/setup.sql | cockroach sql --insecure - name: Test - run: bundle exec rake test TESTOPTS='--profile=5' + run: bundle exec rake test + env: + TESTOPTS: "--profile=5" + RAILS_MINITEST_PLUGIN: "1" # Make sure that we use the minitest plugin for profiling. diff --git a/CHANGELOG.md b/CHANGELOG.md index e1df39e9..c051e3d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog -## Ongoing +## 7.2.1 - 2025-03-26 + +- Fix transaction state on rollback ([#364](https://github.com/cockroachdb/activerecord-cockroachdb-adapter/pull/364)) ## 7.2.0 - 2024-09-24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63e744c3..dfbbebc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,7 +117,11 @@ This section intent to help you with a checklist. - Check for some important methods, some will change for sure: - [x] `def new_column_from_field(` - [x] `def column_definitions(` - - [x] `def pk_and_sequence_for(` + - [x] # `def pk_and_sequence_for(` + - [ ] `def new_column_from_field(` + - [ ] `def column_definitions(` + - [ ] `def pk_and_sequence_for(` + - [ ] `def foreign_keys(` and `def all_foreign_keys(` - [ ] ... - Check for setups containing `drop_table` in the test suite. Especially if you have tons of failure, this is likely the cause. diff --git a/bin/console b/bin/console index 4bf50179..54dfae40 100755 --- a/bin/console +++ b/bin/console @@ -12,20 +12,22 @@ require "activerecord-cockroachdb-adapter" # structure_load(Post.connection_db_config, "awesome-file.sql") require "active_record/connection_adapters/cockroachdb/database_tasks" +DB_NAME = "ar_crdb_console" + schema_kind = ENV.fetch("SCHEMA_KIND", ENV.fetch("SCHEMA", "default")) -system("cockroach sql --insecure --host=localhost:26257 --execute='drop database if exists ar_crdb_console'", +system("cockroach sql --insecure --host=localhost:26257 --execute='drop database if exists #{DB_NAME}'", exception: true) -system("cockroach sql --insecure --host=localhost:26257 --execute='create database ar_crdb_console'", +system("cockroach sql --insecure --host=localhost:26257 --execute='create database #{DB_NAME}'", exception: true) ActiveRecord::Base.establish_connection( - #Alternative version: "cockroachdb://root@localhost:26257/ar_crdb_console" + #Alternative version: "cockroachdb://root@localhost:26257/#{DB_NAME}" adapter: "cockroachdb", host: "localhost", port: 26257, user: "root", - database: "ar_crdb_console" + database: DB_NAME ) load "#{__dir__}/console_schemas/#{schema_kind}.rb" diff --git a/bin/console_schemas/default.rb b/bin/console_schemas/default.rb index ee3e8209..1e297b18 100644 --- a/bin/console_schemas/default.rb +++ b/bin/console_schemas/default.rb @@ -6,4 +6,6 @@ class Post < ActiveRecord::Base t.string :title t.text :body end + + add_index("posts", ["title"], name: "index_posts_on_title", unique: true) end diff --git a/lib/active_record/connection_adapters/cockroachdb/database_statements.rb b/lib/active_record/connection_adapters/cockroachdb/database_statements.rb index ec9c20cf..8a285a67 100644 --- a/lib/active_record/connection_adapters/cockroachdb/database_statements.rb +++ b/lib/active_record/connection_adapters/cockroachdb/database_statements.rb @@ -24,9 +24,17 @@ def insert_fixtures_set(fixture_set, tables_to_delete = []) table_deletes = tables_to_delete.map { |table| "DELETE FROM #{quote_table_name(table)}" } statements = table_deletes + fixture_inserts - with_multi_statements do - disable_referential_integrity do - execute_batch(statements, "Fixtures Load") + begin # much faster without disabling referential integrity, worth trying. + with_multi_statements do + transaction(requires_new: true) do + execute_batch(statements, "Fixtures Load") + end + end + rescue + with_multi_statements do + disable_referential_integrity do + execute_batch(statements, "Fixtures Load") + end end end end diff --git a/lib/active_record/connection_adapters/cockroachdb/referential_integrity.rb b/lib/active_record/connection_adapters/cockroachdb/referential_integrity.rb index 149e9ddf..b15beb60 100644 --- a/lib/active_record/connection_adapters/cockroachdb/referential_integrity.rb +++ b/lib/active_record/connection_adapters/cockroachdb/referential_integrity.rb @@ -34,11 +34,18 @@ def check_all_foreign_keys_valid! end def disable_referential_integrity - foreign_keys = tables.map { |table| foreign_keys(table) }.flatten + foreign_keys = all_foreign_keys - foreign_keys.each do |foreign_key| - remove_foreign_key(foreign_key.from_table, name: foreign_key.options[:name]) + statements = foreign_keys.map do |foreign_key| + # We do not use the `#remove_foreign_key` method here because it + # checks for foreign keys existance in the schema cache. This method + # is performance critical and we know the foreign key exist. + at = create_alter_table foreign_key.from_table + at.drop_foreign_key foreign_key.name + + schema_creation.accept(at) end + execute_batch(statements, "Disable referential integrity -> remove foreign keys") yield @@ -52,19 +59,88 @@ def disable_referential_integrity ActiveRecord::Base.table_name_suffix = "" begin - foreign_keys.each do |foreign_key| - # Avoid having PG:DuplicateObject error if a test is ran in transaction. - # TODO: verify that there is no cache issue related to running this (e.g: fk - # still in cache but not in db) - next if foreign_key_exists?(foreign_key.from_table, name: foreign_key.options[:name]) + # Avoid having PG:DuplicateObject error if a test is ran in transaction. + # TODO: verify that there is no cache issue related to running this (e.g: fk + # still in cache but not in db) + # + # We avoid using `foreign_key_exists?` here because it checks the schema cache + # for every key. This method is performance critical for the test suite, hence + # we use the `#all_foreign_keys` method that only make one query to the database. + already_inserted_foreign_keys = all_foreign_keys + statements = foreign_keys.map do |foreign_key| + next if already_inserted_foreign_keys.any? { |fk| fk.from_table == foreign_key.from_table && fk.options[:name] == foreign_key.options[:name] } + + options = foreign_key_options(foreign_key.from_table, foreign_key.to_table, foreign_key.options) + at = create_alter_table foreign_key.from_table + at.add_foreign_key foreign_key.to_table, options - add_foreign_key(foreign_key.from_table, foreign_key.to_table, **foreign_key.options) + schema_creation.accept(at) end + execute_batch(statements.compact, "Disable referential integrity -> add foreign keys") ensure ActiveRecord::Base.table_name_prefix = old_prefix ActiveRecord::Base.table_name_suffix = old_suffix end end + + private + + # Copy/paste of the `#foreign_keys(table)` method adapted to return every single + # foreign key in the database. + def all_foreign_keys + fk_info = exec_query(<<~SQL, "SCHEMA") + SELECT CASE + WHEN n1.nspname = current_schema() + THEN '' + ELSE n1.nspname || '.' + END || t1.relname AS from_table, + CASE + WHEN n2.nspname = current_schema() + THEN '' + ELSE n2.nspname || '.' + END || t2.relname AS to_table, + a1.attname AS column, a2.attname AS primary_key, c.conname AS name, c.confupdtype AS on_update, c.confdeltype AS on_delete, c.convalidated AS valid, c.condeferrable AS deferrable, c.condeferred AS deferred, + c.conkey, c.confkey, c.conrelid, c.confrelid + FROM pg_constraint c + JOIN pg_class t1 ON c.conrelid = t1.oid + JOIN pg_class t2 ON c.confrelid = t2.oid + JOIN pg_attribute a1 ON a1.attnum = c.conkey[1] AND a1.attrelid = t1.oid + JOIN pg_attribute a2 ON a2.attnum = c.confkey[1] AND a2.attrelid = t2.oid + JOIN pg_namespace t3 ON c.connamespace = t3.oid + JOIN pg_namespace n1 ON t1.relnamespace = n1.oid + JOIN pg_namespace n2 ON t2.relnamespace = n2.oid + WHERE c.contype = 'f' + ORDER BY c.conname + SQL + + fk_info.map do |row| + from_table = PostgreSQL::Utils.unquote_identifier(row["from_table"]) + to_table = PostgreSQL::Utils.unquote_identifier(row["to_table"]) + conkey = row["conkey"].scan(/\d+/).map(&:to_i) + confkey = row["confkey"].scan(/\d+/).map(&:to_i) + + if conkey.size > 1 + column = column_names_from_column_numbers(row["conrelid"], conkey) + primary_key = column_names_from_column_numbers(row["confrelid"], confkey) + else + column = PostgreSQL::Utils.unquote_identifier(row["column"]) + primary_key = row["primary_key"] + end + + options = { + column: column, + name: row["name"], + primary_key: primary_key + } + options[:on_delete] = extract_foreign_key_action(row["on_delete"]) + options[:on_update] = extract_foreign_key_action(row["on_update"]) + options[:deferrable] = extract_constraint_deferrable(row["deferrable"], row["deferred"]) + + options[:validate] = row["valid"] + + ForeignKeyDefinition.new(from_table, to_table, options) + end + end end end end diff --git a/lib/active_record/connection_adapters/cockroachdb/schema_statements.rb b/lib/active_record/connection_adapters/cockroachdb/schema_statements.rb index 8f059bb0..1ce78860 100644 --- a/lib/active_record/connection_adapters/cockroachdb/schema_statements.rb +++ b/lib/active_record/connection_adapters/cockroachdb/schema_statements.rb @@ -55,6 +55,37 @@ def primary_key(table_name) end end + def primary_keys(table_name) + return super unless database_version >= 24_02_02 + + query_values(<<~SQL, "SCHEMA") + SELECT a.attname + FROM ( + SELECT indrelid, indkey, generate_subscripts(indkey, 1) idx + FROM pg_index + WHERE indrelid = #{quote(quote_table_name(table_name))}::regclass + AND indisprimary + ) i + JOIN pg_attribute a + ON a.attrelid = i.indrelid + AND a.attnum = i.indkey[i.idx] + AND NOT a.attishidden + ORDER BY i.idx + SQL + end + + def column_names_from_column_numbers(table_oid, column_numbers) + return super unless database_version >= 24_02_02 + + Hash[query(<<~SQL, "SCHEMA")].values_at(*column_numbers).compact + SELECT a.attnum, a.attname + FROM pg_attribute a + WHERE a.attrelid = #{table_oid} + AND a.attnum IN (#{column_numbers.join(", ")}) + AND NOT a.attishidden + SQL + end + # OVERRIDE: CockroachDB does not support deferrable constraints. # See: https://go.crdb.dev/issue-v/31632/v23.1 def foreign_key_options(from_table, to_table, options) @@ -265,31 +296,6 @@ def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, **) # sql end - # This overrides the method from PostegreSQL adapter - # Resets the sequence of a table's primary key to the maximum value. - def reset_pk_sequence!(table, pk = nil, sequence = nil) - unless pk && sequence - default_pk, default_sequence = pk_and_sequence_for(table) - - pk ||= default_pk - sequence ||= default_sequence - end - - if @logger && pk && !sequence - @logger.warn "#{table} has primary key #{pk} with no default sequence." - end - - if pk && sequence - quoted_sequence = quote_table_name(sequence) - max_pk = query_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}", "SCHEMA") - if max_pk.nil? - minvalue = query_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass", "SCHEMA") - end - - query_value("SELECT setval(#{quote(quoted_sequence)}, #{max_pk ? max_pk : minvalue}, #{max_pk ? true : false})", "SCHEMA") - end - end - # override def native_database_types # Add spatial types diff --git a/lib/active_record/connection_adapters/cockroachdb/transaction_manager.rb b/lib/active_record/connection_adapters/cockroachdb/transaction_manager.rb index 8591f027..dd030ecb 100644 --- a/lib/active_record/connection_adapters/cockroachdb/transaction_manager.rb +++ b/lib/active_record/connection_adapters/cockroachdb/transaction_manager.rb @@ -45,6 +45,27 @@ def within_new_transaction(isolation: nil, joinable: true, attempts: 0) within_new_transaction(isolation: isolation, joinable: joinable, attempts: attempts + 1) { yield } end + # OVERRIDE: the `rescue ActiveRecord::StatementInvalid` block is new, see comment. + def rollback_transaction(transaction = nil) + @connection.lock.synchronize do + transaction ||= @stack.last + begin + transaction.rollback + rescue ActiveRecord::StatementInvalid => err + # This is important to make Active Record aware the record was not inserted/saved + # Otherwise Active Record will assume save was successful and it doesn't retry the transaction + # See this thread for more details: + # https://github.com/cockroachdb/activerecord-cockroachdb-adapter/issues/258#issuecomment-2256633329 + transaction.rollback_records if err.cause.is_a?(PG::NoActiveSqlTransaction) + + raise + ensure + @stack.pop if @stack.last == transaction + end + transaction.rollback_records + end + end + def retryable?(error) return true if serialization_error?(error) return true if error.is_a? ActiveRecord::SerializationFailure diff --git a/lib/version.rb b/lib/version.rb index 997d02d0..710ad09b 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -15,5 +15,5 @@ # limitations under the License. module ActiveRecord - COCKROACH_DB_ADAPTER_VERSION = "7.2.0" + COCKROACH_DB_ADAPTER_VERSION = "7.2.1" end diff --git a/test/cases/migration/hidden_column_test.rb b/test/cases/migration/hidden_column_test.rb index a0f85437..5d429e39 100644 --- a/test/cases/migration/hidden_column_test.rb +++ b/test/cases/migration/hidden_column_test.rb @@ -53,6 +53,17 @@ def test_add_hidden_column output = dump_table_schema "rockets" assert_match %r{t.uuid "new_col", hidden: true$}, output end + + # Since 24.2.2, hash sharded indexes add a hidden column to the table. + # This tests ensure that the user can still drop the index even if they + # call `#remove_index` with the column name rather than the index name. + def test_remove_index_with_a_hidden_column + @connection.execute <<-SQL + CREATE INDEX hash_idx ON rockets (name) USING HASH WITH (bucket_count=8); + SQL + @connection.remove_index :rockets, :name + assert :ok + end end end end diff --git a/test/cases/primary_keys_test.rb b/test/cases/primary_keys_test.rb index bd515bb3..fdf6ecc8 100644 --- a/test/cases/primary_keys_test.rb +++ b/test/cases/primary_keys_test.rb @@ -97,4 +97,38 @@ def test_schema_dump_primary_key_integer_with_default_nil assert_match %r{create_table "int_defaults", id: :bigint, default: nil}, schema end end + + class PrimaryKeyHiddenColumnTest < ActiveRecord::TestCase + class Rocket < ActiveRecord::Base + end + + def setup + connection = ActiveRecord::Base.connection + connection.execute <<-SQL + CREATE TABLE rockets( + id SERIAL PRIMARY KEY USING HASH WITH (bucket_count=4) + ) + SQL + end + + def teardown + ActiveRecord::Base.connection.drop_table :rockets + end + + def test_to_key_with_hidden_primary_key_part + rocket = Rocket.new + assert_nil rocket.to_key + rocket.save + assert_equal rocket.to_key, [rocket.id] + end + + def test_read_attribute_with_hidden_primary_key_part + rocket = Rocket.create! + id = assert_not_deprecated(ActiveRecord.deprecator) do + rocket.read_attribute(:id) + end + + assert_equal rocket.id, id + end + end end diff --git a/test/cases/schema_dumper_test.rb b/test/cases/schema_dumper_test.rb index 6c4110d9..0d798177 100644 --- a/test/cases/schema_dumper_test.rb +++ b/test/cases/schema_dumper_test.rb @@ -10,30 +10,6 @@ class SchemaDumperTest < ActiveRecord::TestCase include SchemaDumpingHelper self.use_transactional_tests = false - setup do - @schema_migration = ActiveRecord::Base.connection_pool.schema_migration - @schema_migration.create_table - end - - def standard_dump - @@standard_dump ||= perform_schema_dump - end - - def perform_schema_dump - dump_all_table_schema [] - end - - # OVERRIDE: we removed the 'deferrable' part in `assert_match` - def test_schema_dumps_unique_constraints - output = dump_table_schema("test_unique_constraints") - constraint_definitions = output.split(/\n/).grep(/t\.unique_constraint/) - - assert_equal 3, constraint_definitions.size - assert_match 't.unique_constraint ["position_1"], name: "test_unique_constraints_position_1"', output - assert_match 't.unique_constraint ["position_2"], name: "test_unique_constraints_position_2"', output - assert_match 't.unique_constraint ["position_3"], name: "test_unique_constraints_position_3"', output - end - def test_schema_dump_with_timestamptz_datetime_format migration, original, $stdout = nil, $stdout, StringIO.new @@ -53,7 +29,8 @@ def down end migration.migrate(:up) - output = perform_schema_dump + output = dump_table_schema "timestamps" + assert output.include?('t.datetime "this_should_remain_datetime"') assert output.include?('t.datetime "this_is_an_alias_of_datetime"') assert output.include?('t.timestamp "without_time_zone"') @@ -81,7 +58,7 @@ def down end migration.migrate(:up) - output = perform_schema_dump + output = dump_table_schema "timestamps" # Normally we'd write `t.datetime` here. But because you've changed the `datetime_type` # to something else, `t.datetime` now means `:timestamptz`. To ensure that old columns # are still created as a `:timestamp` we need to change what is written to the schema dump. @@ -115,7 +92,7 @@ def down end migration.migrate(:up) - output = perform_schema_dump + output = dump_table_schema "timestamps" # Normally we'd write `t.datetime` here. But because you've changed the `datetime_type` # to something else, `t.datetime` now means `:timestamptz`. To ensure that old columns # are still created as a `:timestamp` we need to change what is written to the schema dump. @@ -150,7 +127,7 @@ def down end migration.migrate(:up) - output = perform_schema_dump + output = dump_table_schema "timestamps" # Normally we'd write `t.datetime` here. But because you've changed the `datetime_type` # to something else, `t.datetime` now means `:timestamptz`. To ensure that old columns # are still created as a `:timestamp` we need to change what is written to the schema dump. @@ -184,7 +161,7 @@ def down end migration.migrate(:up) - output = perform_schema_dump + output = dump_table_schema "timestamps" assert output.include?('t.datetime "default_format"') assert output.include?('t.datetime "without_time_zone"') assert output.include?('t.timestamptz "with_time_zone"') @@ -192,7 +169,7 @@ def down datetime_type_was = ActiveRecord::ConnectionAdapters::CockroachDBAdapter.datetime_type ActiveRecord::ConnectionAdapters::CockroachDBAdapter.datetime_type = :timestamptz - output = perform_schema_dump + output = dump_table_schema "timestamps" assert output.include?('t.timestamp "default_format"') assert output.include?('t.timestamp "without_time_zone"') assert output.include?('t.datetime "with_time_zone"') @@ -202,7 +179,7 @@ def down $stdout = original end - if ActiveRecord::Base.lease_connection.supports_check_constraints? + if ActiveRecord::Base.connection.supports_check_constraints? def test_schema_dumps_check_constraints constraint_definition = dump_table_schema("products").split(/\n/).grep(/t.check_constraint.*products_price_check/).first.strip if current_adapter?(:Mysql2Adapter) diff --git a/test/cases/transactions_test.rb b/test/cases/transactions_test.rb new file mode 100644 index 00000000..6f3a39d6 --- /dev/null +++ b/test/cases/transactions_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "cases/helper_cockroachdb" + +module CockroachDB + class TransactionsTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class Avenger < ActiveRecord::Base + singleton_class.attr_accessor :cyclic_barrier + + validate :validate_unique_username + + def validate_unique_username + self.class.cyclic_barrier.wait + duplicate = self.class.where(name: name).any? + errors.add("Duplicate username!") if duplicate + end + end + + def test_concurrent_insert_with_processes + conn = ActiveRecord::Base.lease_connection + conn.create_table :avengers, force: true do |t| + t.string :name + end + ActiveRecord::Base.reset_column_information + + avengers = %w[Hulk Thor Loki] + Avenger.cyclic_barrier = Concurrent::CyclicBarrier.new(avengers.size - 1) + Thread.current[:name] = "Main" # For debug logs. + + assert_queries_match(/ROLLBACK/) do # Ensure we are properly testing the retry mechanism. + avengers.map do |name| + Thread.fork do + Thread.current[:name] = name # For debug logs. + Avenger.create!(name: name) + end + end.each(&:join) + end + + assert_equal avengers.size, Avenger.count + ensure + Thread.current[:name] = nil + conn = ActiveRecord::Base.lease_connection + conn.drop_table :avengers, if_exists: true + end + end +end diff --git a/test/excludes/ActiveRecord/ConnectionAdapters/PostgreSQLAdapterTest.rb b/test/excludes/ActiveRecord/ConnectionAdapters/PostgreSQLAdapterTest.rb index 386c0948..1703c99c 100644 --- a/test/excludes/ActiveRecord/ConnectionAdapters/PostgreSQLAdapterTest.rb +++ b/test/excludes/ActiveRecord/ConnectionAdapters/PostgreSQLAdapterTest.rb @@ -31,3 +31,5 @@ exclude :test_warnings_behaviour_can_be_customized_with_a_proc, plpgsql_needed exclude :test_allowlist_of_warnings_to_ignore, plpgsql_needed exclude :test_allowlist_of_warning_codes_to_ignore, plpgsql_needed + +exclude :test_translate_no_connection_exception_to_not_established, "CockroachDB does not support pg_terminate_backend()." diff --git a/test/excludes/AttributeMethodsTest.rb b/test/excludes/AttributeMethodsTest.rb deleted file mode 100644 index fec54a47..00000000 --- a/test/excludes/AttributeMethodsTest.rb +++ /dev/null @@ -1,2 +0,0 @@ -# TODO: Rails 7.2 remove this exclusion -exclude "test_#undefine_attribute_methods_undefines_alias_attribute_methods", "The test will be fixed in 7.2 (https://github.com/rails/rails/commit/a0993f81d0450a191da1ee35282f60fc2899135c)" diff --git a/test/excludes/UnloggedTablesTest.rb b/test/excludes/UnloggedTablesTest.rb index 6b0f7b13..dc25c6ba 100644 --- a/test/excludes/UnloggedTablesTest.rb +++ b/test/excludes/UnloggedTablesTest.rb @@ -1 +1,3 @@ -exclude :test_unlogged_in_test_environment_when_unlogged_setting_enabled, "Override because UNLOGGED cannot be specified in CockroachDB. Related https://github.com/cockroachdb/cockroach/issues/56827" +instance_methods.grep(/\Atest_\w+\z/).each do |method_name| + exclude method_name, "UNLOGGED has no effect in CockroachDB." +end diff --git a/test/support/sql_logger.rb b/test/support/sql_logger.rb new file mode 100644 index 00000000..e548e05e --- /dev/null +++ b/test/support/sql_logger.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module SQLLogger + module_function + + def stdout_log + ActiveRecord::Base.logger = Logger.new(STDOUT) + ActiveRecord::Base.logger.level = Logger::DEBUG + ActiveRecord::LogSubscriber::IGNORE_PAYLOAD_NAMES.clear + ActiveRecord::Base.logger.formatter = proc { |severity, time, progname, msg| + th = Thread.current[:name] + th = "THREAD=#{th}" if th + Logger::Formatter.new.call(severity, time, progname || th, msg) + } + end + + def summary_log + ActiveRecord::TotalTimeSubscriber.attach_to :active_record + Minitest.after_run { + detail = ActiveRecord::TotalTimeSubscriber.hash.map { |k,v| [k, [v.sum, v.sum / v.size, v.size]]}.sort_by { |_, (_total, avg, _)| -avg }.to_h + time = detail.values.sum { |(total, _, _)| total } / 1_000 + count = detail.values.sum { |(_, _, count)| count } + puts "Total time spent in SQL: #{time}s (#{count} queries)" + puts "Detail per query kind available in tmp/query_time.json (total time in ms, avg time in ms, query count). Sorted by avg time." + File.write( + "tmp/query_time.json", + JSON.pretty_generate(detail) + ) + } + end + + # Remove content between single quotes and double quotes from keys + # to have a clear idea of which queries are being executed. + def clean_sql(sql) + sql.gsub(/".*?"/m, "\"...\"").gsub("''", "").gsub(/'.*?'/m, "'...'") + end +end + +class ActiveRecord::TotalTimeSubscriber < ActiveRecord::LogSubscriber + def self.hash + @@hash + end + + def sql(event) + # NOTE: If you want to debug a specific query, you can use a 'binding.irb' here with + # a specific condition on 'event.payload[:sql]' content. + # + # binding.irb if event.payload[:sql].include?("attr.attname, nsp.nspname") + # + @@hash ||= {} + key = SQLLogger.clean_sql(event.payload[:sql]) + @@hash[key] ||= [] + @@hash[key].push event.duration + end +end