Skip to content

Commit a561dd4

Browse files
authored
Merge pull request rails#49376 from fractaledmind/ar-sqlite-deferred-fks
The SQLite3 adapter now implements the `supports_deferrable_constraints?` contract
2 parents 2a986b7 + ea17941 commit a561dd4

File tree

5 files changed

+86
-18
lines changed

5 files changed

+86
-18
lines changed

activerecord/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
* The SQLite3 adapter now implements the `supports_deferrable_constraints?` contract
2+
3+
Allows foreign keys to be deferred by adding the `:deferrable` key to the `foreign_key` options.
4+
5+
```ruby
6+
add_reference :person, :alias, foreign_key: { deferrable: :deferred }
7+
add_reference :alias, :person, foreign_key: { deferrable: :deferred }
8+
```
9+
10+
*Stephen Margheim*
11+
112
* Add `set_constraints` helper for PostgreSQL
213

314
```ruby

activerecord/lib/active_record/connection_adapters/sqlite3/schema_creation.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ module ConnectionAdapters
55
module SQLite3
66
class SchemaCreation < SchemaCreation # :nodoc:
77
private
8+
def visit_AddForeignKey(o)
9+
super.dup.tap do |sql|
10+
sql << " DEFERRABLE INITIALLY #{o.options[:deferrable].to_s.upcase}" if o.deferrable
11+
end
12+
end
13+
14+
def visit_ForeignKeyDefinition(o)
15+
super.dup.tap do |sql|
16+
sql << " DEFERRABLE INITIALLY #{o.deferrable.to_s.upcase}" if o.deferrable
17+
end
18+
end
19+
820
def supports_index_using?
921
false
1022
end

activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ def indexes(table_name)
5353
end
5454

5555
def add_foreign_key(from_table, to_table, **options)
56+
if options[:deferrable] == true
57+
ActiveRecord.deprecator.warn(<<~MSG)
58+
`deferrable: true` is deprecated in favor of `deferrable: :immediate`, and will be removed in Rails 7.2.
59+
MSG
60+
61+
options[:deferrable] = :immediate
62+
end
63+
64+
assert_valid_deferrable(options[:deferrable])
65+
5666
alter_table(from_table) do |definition|
5767
to_table = strip_table_name_prefix_and_suffix(to_table)
5868
definition.foreign_key(to_table, **options)
@@ -185,6 +195,12 @@ def quoted_scope(name = nil, type: nil)
185195
scope[:type] = type if type
186196
scope
187197
end
198+
199+
def assert_valid_deferrable(deferrable)
200+
return if !deferrable || %i(immediate deferred).include?(deferrable)
201+
202+
raise ArgumentError, "deferrable must be `:immediate` or `:deferred`, got: `#{deferrable.inspect}`"
203+
end
188204
end
189205
end
190206
end

activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,10 @@ def supports_lazy_transactions?
238238
true
239239
end
240240

241+
def supports_deferrable_constraints?
242+
true
243+
end
244+
241245
# REFERENTIAL INTEGRITY ====================================
242246

243247
def disable_referential_integrity # :nodoc:
@@ -367,15 +371,31 @@ def add_reference(table_name, ref_name, **options) # :nodoc:
367371
end
368372
alias :add_belongs_to :add_reference
369373

374+
FK_REGEX = /.*FOREIGN KEY\s+\("(\w+)"\)\s+REFERENCES\s+"(\w+)"\s+\("(\w+)"\)/
375+
DEFERRABLE_REGEX = /DEFERRABLE INITIALLY (\w+)/
370376
def foreign_keys(table_name)
371377
# SQLite returns 1 row for each column of composite foreign keys.
372378
fk_info = internal_exec_query("PRAGMA foreign_key_list(#{quote(table_name)})", "SCHEMA")
379+
# Deferred or immediate foreign keys can only be seen in the CREATE TABLE sql
380+
fk_defs = table_structure_sql(table_name)
381+
.select do |column_string|
382+
column_string.start_with?("CONSTRAINT") &&
383+
column_string.include?("FOREIGN KEY")
384+
end
385+
.to_h do |fk_string|
386+
_, from, table, to = fk_string.match(FK_REGEX).to_a
387+
_, mode = fk_string.match(DEFERRABLE_REGEX).to_a
388+
deferred = mode&.downcase&.to_sym || false
389+
[[table, from, to], deferred]
390+
end
391+
373392
grouped_fk = fk_info.group_by { |row| row["id"] }.values.each { |group| group.sort_by! { |row| row["seq"] } }
374393
grouped_fk.map do |group|
375394
row = group.first
376395
options = {
377396
on_delete: extract_foreign_key_action(row["on_delete"]),
378-
on_update: extract_foreign_key_action(row["on_update"])
397+
on_update: extract_foreign_key_action(row["on_update"]),
398+
deferrable: fk_defs[[row["table"], row["from"], row["to"]]]
379399
}
380400

381401
if group.one?
@@ -649,24 +669,11 @@ def translate_exception(exception, message:, sql:, binds:)
649669
def table_structure_with_collation(table_name, basic_structure)
650670
collation_hash = {}
651671
auto_increments = {}
652-
sql = <<~SQL
653-
SELECT sql FROM
654-
(SELECT * FROM sqlite_master UNION ALL
655-
SELECT * FROM sqlite_temp_master)
656-
WHERE type = 'table' AND name = #{quote(table_name)}
657-
SQL
658-
659-
# Result will have following sample string
660-
# CREATE TABLE "users" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
661-
# "password_digest" varchar COLLATE "NOCASE");
662-
result = query_value(sql, "SCHEMA")
663672

664-
if result
665-
# Splitting with left parentheses and discarding the first part will return all
666-
# columns separated with comma(,).
667-
columns_string = result.split("(", 2).last
673+
column_strings = table_structure_sql(table_name)
668674

669-
columns_string.split(",").each do |column_string|
675+
if column_strings.any?
676+
column_strings.each do |column_string|
670677
# This regex will match the column name and collation type and will save
671678
# the value in $1 and $2 respectively.
672679
collation_hash[$1] = $2 if COLLATE_REGEX =~ column_string
@@ -691,6 +698,28 @@ def table_structure_with_collation(table_name, basic_structure)
691698
end
692699
end
693700

701+
def table_structure_sql(table_name)
702+
sql = <<~SQL
703+
SELECT sql FROM
704+
(SELECT * FROM sqlite_master UNION ALL
705+
SELECT * FROM sqlite_temp_master)
706+
WHERE type = 'table' AND name = #{quote(table_name)}
707+
SQL
708+
709+
# Result will have following sample string
710+
# CREATE TABLE "users" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
711+
# "password_digest" varchar COLLATE "NOCASE");
712+
result = query_value(sql, "SCHEMA")
713+
714+
return [] unless result
715+
716+
# Splitting with left parentheses and discarding the first part will return all
717+
# columns separated with comma(,).
718+
columns_string = result.split("(", 2).last
719+
720+
columns_string.split(",").map(&:strip)
721+
end
722+
694723
def arel_visitor
695724
Arel::Visitors::SQLite.new(self)
696725
end

activerecord/test/cases/migration/references_foreign_key_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class ReferencesForeignKeyInCreateTest < ActiveRecord::TestCase
6363
fks.map { |fk| [fk.from_table, fk.to_table, fk.column] })
6464
end
6565

66-
if current_adapter?(:PostgreSQLAdapter)
66+
if ActiveRecord::Base.connection.supports_deferrable_constraints?
6767
test "deferrable: false option can be passed" do
6868
@connection.create_table :testings do |t|
6969
t.references :testing_parent, foreign_key: { deferrable: false }

0 commit comments

Comments
 (0)