Skip to content

Commit 8daf4fb

Browse files
BuonOmorafiss
authored andcommitted
chore: faster referential integrity for tests
1 parent b7edaea commit 8daf4fb

File tree

6 files changed

+147
-45
lines changed

6 files changed

+147
-45
lines changed

CONTRIBUTING.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,10 @@ This section intent to help you with a checklist.
115115
- Verify the written text at the beginning of the test suite, there are likely
116116
some changes in excluded tests.
117117
- Check for some important methods, some will change for sure:
118-
- [x] `def new_column_from_field(`
119-
- [x] `def column_definitions(`
120-
- [x] `def pk_and_sequence_for(`
118+
- [ ] `def new_column_from_field(`
119+
- [ ] `def column_definitions(`
120+
- [ ] `def pk_and_sequence_for(`
121+
- [ ] `def foreign_keys(` and `def all_foreign_keys(`
121122
- [ ] ...
122123
- Check for setups containing `drop_table` in the test suite.
123124
Especially if you have tons of failure, this is likely the cause.

lib/active_record/connection_adapters/cockroachdb/referential_integrity.rb

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,16 @@ def check_all_foreign_keys_valid!
3434
end
3535

3636
def disable_referential_integrity
37-
foreign_keys = tables.map { |table| foreign_keys(table) }.flatten
37+
foreign_keys = all_foreign_keys
3838

3939
foreign_keys.each do |foreign_key|
40-
remove_foreign_key(foreign_key.from_table, name: foreign_key.options[:name])
40+
# We do not use the `#remove_foreign_key` method here because it
41+
# checks for foreign keys existance in the schema cache. This method
42+
# is performance critical and we know the foreign key exist.
43+
at = create_alter_table foreign_key.from_table
44+
at.drop_foreign_key foreign_key.name
45+
46+
execute schema_creation.accept(at)
4147
end
4248

4349
yield
@@ -52,11 +58,16 @@ def disable_referential_integrity
5258
ActiveRecord::Base.table_name_suffix = ""
5359

5460
begin
61+
# Avoid having PG:DuplicateObject error if a test is ran in transaction.
62+
# TODO: verify that there is no cache issue related to running this (e.g: fk
63+
# still in cache but not in db)
64+
#
65+
# We avoid using `foreign_key_exists?` here because it checks the schema cache
66+
# for every key. This method is performance critical for the test suite, hence
67+
# we use the `#all_foreign_keys` method that only make one query to the database.
68+
already_inserted_foreign_keys = all_foreign_keys
5569
foreign_keys.each do |foreign_key|
56-
# Avoid having PG:DuplicateObject error if a test is ran in transaction.
57-
# TODO: verify that there is no cache issue related to running this (e.g: fk
58-
# still in cache but not in db)
59-
next if foreign_key_exists?(foreign_key.from_table, name: foreign_key.options[:name])
70+
next if already_inserted_foreign_keys.any? { |fk| fk.from_table == foreign_key.from_table && fk.options[:name] == foreign_key.options[:name] }
6071

6172
add_foreign_key(foreign_key.from_table, foreign_key.to_table, **foreign_key.options)
6273
end
@@ -65,6 +76,65 @@ def disable_referential_integrity
6576
ActiveRecord::Base.table_name_suffix = old_suffix
6677
end
6778
end
79+
80+
private
81+
82+
# Copy/paste of the `#foreign_keys(table)` method adapted to return every single
83+
# foreign key in the database.
84+
def all_foreign_keys
85+
fk_info = internal_exec_query(<<~SQL, "SCHEMA")
86+
SELECT CASE
87+
WHEN n1.nspname = current_schema()
88+
THEN ''
89+
ELSE n1.nspname || '.'
90+
END || t1.relname AS from_table,
91+
CASE
92+
WHEN n2.nspname = current_schema()
93+
THEN ''
94+
ELSE n2.nspname || '.'
95+
END || t2.relname AS to_table,
96+
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,
97+
c.conkey, c.confkey, c.conrelid, c.confrelid
98+
FROM pg_constraint c
99+
JOIN pg_class t1 ON c.conrelid = t1.oid
100+
JOIN pg_class t2 ON c.confrelid = t2.oid
101+
JOIN pg_attribute a1 ON a1.attnum = c.conkey[1] AND a1.attrelid = t1.oid
102+
JOIN pg_attribute a2 ON a2.attnum = c.confkey[1] AND a2.attrelid = t2.oid
103+
JOIN pg_namespace t3 ON c.connamespace = t3.oid
104+
JOIN pg_namespace n1 ON t1.relnamespace = n1.oid
105+
JOIN pg_namespace n2 ON t2.relnamespace = n2.oid
106+
WHERE c.contype = 'f'
107+
ORDER BY c.conname
108+
SQL
109+
110+
fk_info.map do |row|
111+
from_table = PostgreSQL::Utils.unquote_identifier(row["from_table"])
112+
to_table = PostgreSQL::Utils.unquote_identifier(row["to_table"])
113+
conkey = row["conkey"].scan(/\d+/).map(&:to_i)
114+
confkey = row["confkey"].scan(/\d+/).map(&:to_i)
115+
116+
if conkey.size > 1
117+
column = column_names_from_column_numbers(row["conrelid"], conkey)
118+
primary_key = column_names_from_column_numbers(row["confrelid"], confkey)
119+
else
120+
column = PostgreSQL::Utils.unquote_identifier(row["column"])
121+
primary_key = row["primary_key"]
122+
end
123+
124+
options = {
125+
column: column,
126+
name: row["name"],
127+
primary_key: primary_key
128+
}
129+
options[:on_delete] = extract_foreign_key_action(row["on_delete"])
130+
options[:on_update] = extract_foreign_key_action(row["on_update"])
131+
options[:deferrable] = extract_constraint_deferrable(row["deferrable"], row["deferred"])
132+
133+
options[:validate] = row["valid"]
134+
135+
ForeignKeyDefinition.new(from_table, to_table, options)
136+
end
137+
end
68138
end
69139
end
70140
end

lib/active_record/connection_adapters/cockroachdb/schema_statements.rb

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,6 @@ def foreign_keys(table_name)
216216
options[:deferrable] = extract_constraint_deferrable(row["deferrable"], row["deferred"])
217217

218218
options[:validate] = row["valid"]
219-
to_table = PostgreSQL::Utils.unquote_identifier(row["to_table"])
220219

221220
ForeignKeyDefinition.new(table_name, to_table, options)
222221
end
@@ -296,31 +295,6 @@ def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, **) #
296295
sql
297296
end
298297

299-
# This overrides the method from PostegreSQL adapter
300-
# Resets the sequence of a table's primary key to the maximum value.
301-
def reset_pk_sequence!(table, pk = nil, sequence = nil)
302-
unless pk && sequence
303-
default_pk, default_sequence = pk_and_sequence_for(table)
304-
305-
pk ||= default_pk
306-
sequence ||= default_sequence
307-
end
308-
309-
if @logger && pk && !sequence
310-
@logger.warn "#{table} has primary key #{pk} with no default sequence."
311-
end
312-
313-
if pk && sequence
314-
quoted_sequence = quote_table_name(sequence)
315-
max_pk = query_value("SELECT MAX(#{quote_column_name pk}) FROM #{quote_table_name(table)}", "SCHEMA")
316-
if max_pk.nil?
317-
minvalue = query_value("SELECT seqmin FROM pg_sequence WHERE seqrelid = #{quote(quoted_sequence)}::regclass", "SCHEMA")
318-
end
319-
320-
query_value("SELECT setval(#{quote(quoted_sequence)}, #{max_pk ? max_pk : minvalue}, #{max_pk ? true : false})", "SCHEMA")
321-
end
322-
end
323-
324298
# override
325299
def native_database_types
326300
# Add spatial types

test/cases/fixtures_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ def self.name
205205
# under test will have primary key sequences, and the connection is from ActiveRecord::Base.
206206
# Normally, the primary keys would use CockroachDB's unique_rowid().
207207
def test_create_symbol_fixtures
208-
fixtures = ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, :collections, collections: Course) { Course.lease_connection }
208+
fixtures = ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, :collections, collections: Course)
209209

210210
assert Course.find_by_name("Collection"), "course is not in the database"
211211
assert fixtures.detect { |f| f.name == "collections" }, "no fixtures named 'collections' in #{fixtures.map(&:name).inspect}"

test/cases/helper_cockroachdb.rb

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,15 +192,17 @@ def before_teardown
192192
MiniTest::Test.include(TraceLibPlugin)
193193
end
194194

195-
if ENV['AR_LOG']
196-
ActiveRecord::Base.logger = Logger.new(STDOUT)
197-
ActiveRecord::Base.logger.level = Logger::DEBUG
198-
ActiveRecord::LogSubscriber::IGNORE_PAYLOAD_NAMES.clear
199-
ActiveRecord::Base.logger.formatter = proc { |severity, time, progname, msg|
200-
th = Thread.current[:name]
201-
th = "THREAD=#{th}" if th
202-
Logger::Formatter.new.call(severity, time, progname || th, msg)
203-
}
195+
# Log all SQL queries and print total time spent in SQL.
196+
if ENV["AR_LOG"]
197+
require "support/sql_logger"
198+
case ENV["AR_LOG"].strip
199+
when "stdout" then SQLLogger.stdout_log
200+
when "summary" then SQLLogger.summary_log
201+
else
202+
SQLLogger.stdout_log
203+
SQLLogger.summary_log
204+
end
205+
204206
end
205207

206208
# Remove the header from the schema dump to clarify tests outputs.

test/support/sql_logger.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
module SQLLogger
4+
module_function
5+
6+
def stdout_log
7+
ActiveRecord::Base.logger = Logger.new(STDOUT)
8+
ActiveRecord::Base.logger.level = Logger::DEBUG
9+
ActiveRecord::LogSubscriber::IGNORE_PAYLOAD_NAMES.clear
10+
ActiveRecord::Base.logger.formatter = proc { |severity, time, progname, msg|
11+
th = Thread.current[:name]
12+
th = "THREAD=#{th}" if th
13+
Logger::Formatter.new.call(severity, time, progname || th, msg)
14+
}
15+
end
16+
17+
def summary_log
18+
ActiveRecord::TotalTimeSubscriber.attach_to :active_record
19+
Minitest.after_run {
20+
detail = ActiveRecord::TotalTimeSubscriber.hash.map { |k,v| [k, [v.sum, v.sum / v.size, v.size]]}.sort_by { |_, (_total, avg, _)| -avg }.to_h
21+
time = detail.values.sum { |(total, _, _)| total } / 1_000
22+
count = detail.values.sum { |(_, _, count)| count }
23+
puts "Total time spent in SQL: #{time}s (#{count} queries)"
24+
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."
25+
File.write(
26+
"tmp/query_time.json",
27+
JSON.pretty_generate(detail)
28+
)
29+
}
30+
end
31+
32+
# Remove content between single quotes and double quotes from keys
33+
# to have a clear idea of which queries are being executed.
34+
def clean_sql(sql)
35+
sql.gsub(/".*?"/m, "\"...\"").gsub("''", "").gsub(/'.*?'/m, "'...'")
36+
end
37+
end
38+
39+
class ActiveRecord::TotalTimeSubscriber < ActiveRecord::LogSubscriber
40+
def self.hash
41+
@@hash
42+
end
43+
44+
def sql(event)
45+
# NOTE: If you want to debug a specific query, you can use a 'binding.irb' here with
46+
# a specific condition on 'event.payload[:sql]' content.
47+
#
48+
# binding.irb if event.payload[:sql].include?("attr.attname, nsp.nspname")
49+
#
50+
@@hash ||= {}
51+
key = SQLLogger.clean_sql(event.payload[:sql])
52+
@@hash[key] ||= []
53+
@@hash[key].push event.duration
54+
end
55+
end

0 commit comments

Comments
 (0)