From ea17941e39225eca81880c770644652c4612dc69 Mon Sep 17 00:00:00 2001 From: Stephen Margheim Date: Sun, 24 Sep 2023 23:14:02 +0200 Subject: [PATCH] The SQLite3 adapter now implements the `supports_deferrable_constraints?` contract Implementing the full `supports_deferrable_constraints?` contract allows foreign keys to be deferred by adding the `:deferrable` key to the `foreign_key` options in the `add_reference` and `add_foreign_key` methods. ```ruby add_reference :person, :alias, foreign_key: { deferrable: :deferred } add_reference :alias, :person, foreign_key: { deferrable: :deferred } ``` --- activerecord/CHANGELOG.md | 11 ++++ .../sqlite3/schema_creation.rb | 12 ++++ .../sqlite3/schema_statements.rb | 16 +++++ .../connection_adapters/sqlite3_adapter.rb | 63 ++++++++++++++----- .../migration/references_foreign_key_test.rb | 2 +- 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 33bae88f420b7..0f0eade5feb98 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,14 @@ +* The SQLite3 adapter now implements the `supports_deferrable_constraints?` contract + + Allows foreign keys to be deferred by adding the `:deferrable` key to the `foreign_key` options. + + ```ruby + add_reference :person, :alias, foreign_key: { deferrable: :deferred } + add_reference :alias, :person, foreign_key: { deferrable: :deferred } + ``` + + *Stephen Margheim* + * Add `set_constraints` helper for PostgreSQL ```ruby diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_creation.rb index c461eeed5d2fc..c4382576b3bc7 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_creation.rb @@ -5,6 +5,18 @@ module ConnectionAdapters module SQLite3 class SchemaCreation < SchemaCreation # :nodoc: private + def visit_AddForeignKey(o) + super.dup.tap do |sql| + sql << " DEFERRABLE INITIALLY #{o.options[:deferrable].to_s.upcase}" if o.deferrable + end + end + + def visit_ForeignKeyDefinition(o) + super.dup.tap do |sql| + sql << " DEFERRABLE INITIALLY #{o.deferrable.to_s.upcase}" if o.deferrable + end + end + def supports_index_using? false end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb index 286cedd155044..b4b5b1b3bf6fb 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb @@ -53,6 +53,16 @@ def indexes(table_name) end def add_foreign_key(from_table, to_table, **options) + if options[:deferrable] == true + ActiveRecord.deprecator.warn(<<~MSG) + `deferrable: true` is deprecated in favor of `deferrable: :immediate`, and will be removed in Rails 7.2. + MSG + + options[:deferrable] = :immediate + end + + assert_valid_deferrable(options[:deferrable]) + alter_table(from_table) do |definition| to_table = strip_table_name_prefix_and_suffix(to_table) definition.foreign_key(to_table, **options) @@ -185,6 +195,12 @@ def quoted_scope(name = nil, type: nil) scope[:type] = type if type scope end + + def assert_valid_deferrable(deferrable) + return if !deferrable || %i(immediate deferred).include?(deferrable) + + raise ArgumentError, "deferrable must be `:immediate` or `:deferred`, got: `#{deferrable.inspect}`" + end end end end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index 2296316b70caf..856baceffeeaa 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -238,6 +238,10 @@ def supports_lazy_transactions? true end + def supports_deferrable_constraints? + true + end + # REFERENTIAL INTEGRITY ==================================== def disable_referential_integrity # :nodoc: @@ -367,15 +371,31 @@ def add_reference(table_name, ref_name, **options) # :nodoc: end alias :add_belongs_to :add_reference + FK_REGEX = /.*FOREIGN KEY\s+\("(\w+)"\)\s+REFERENCES\s+"(\w+)"\s+\("(\w+)"\)/ + DEFERRABLE_REGEX = /DEFERRABLE INITIALLY (\w+)/ def foreign_keys(table_name) # SQLite returns 1 row for each column of composite foreign keys. fk_info = internal_exec_query("PRAGMA foreign_key_list(#{quote(table_name)})", "SCHEMA") + # Deferred or immediate foreign keys can only be seen in the CREATE TABLE sql + fk_defs = table_structure_sql(table_name) + .select do |column_string| + column_string.start_with?("CONSTRAINT") && + column_string.include?("FOREIGN KEY") + end + .to_h do |fk_string| + _, from, table, to = fk_string.match(FK_REGEX).to_a + _, mode = fk_string.match(DEFERRABLE_REGEX).to_a + deferred = mode&.downcase&.to_sym || false + [[table, from, to], deferred] + end + grouped_fk = fk_info.group_by { |row| row["id"] }.values.each { |group| group.sort_by! { |row| row["seq"] } } grouped_fk.map do |group| row = group.first options = { on_delete: extract_foreign_key_action(row["on_delete"]), - on_update: extract_foreign_key_action(row["on_update"]) + on_update: extract_foreign_key_action(row["on_update"]), + deferrable: fk_defs[[row["table"], row["from"], row["to"]]] } if group.one? @@ -649,24 +669,11 @@ def translate_exception(exception, message:, sql:, binds:) def table_structure_with_collation(table_name, basic_structure) collation_hash = {} auto_increments = {} - sql = <<~SQL - SELECT sql FROM - (SELECT * FROM sqlite_master UNION ALL - SELECT * FROM sqlite_temp_master) - WHERE type = 'table' AND name = #{quote(table_name)} - SQL - - # Result will have following sample string - # CREATE TABLE "users" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - # "password_digest" varchar COLLATE "NOCASE"); - result = query_value(sql, "SCHEMA") - if result - # Splitting with left parentheses and discarding the first part will return all - # columns separated with comma(,). - columns_string = result.split("(", 2).last + column_strings = table_structure_sql(table_name) - columns_string.split(",").each do |column_string| + if column_strings.any? + column_strings.each do |column_string| # This regex will match the column name and collation type and will save # the value in $1 and $2 respectively. collation_hash[$1] = $2 if COLLATE_REGEX =~ column_string @@ -691,6 +698,28 @@ def table_structure_with_collation(table_name, basic_structure) end end + def table_structure_sql(table_name) + sql = <<~SQL + SELECT sql FROM + (SELECT * FROM sqlite_master UNION ALL + SELECT * FROM sqlite_temp_master) + WHERE type = 'table' AND name = #{quote(table_name)} + SQL + + # Result will have following sample string + # CREATE TABLE "users" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + # "password_digest" varchar COLLATE "NOCASE"); + result = query_value(sql, "SCHEMA") + + return [] unless result + + # Splitting with left parentheses and discarding the first part will return all + # columns separated with comma(,). + columns_string = result.split("(", 2).last + + columns_string.split(",").map(&:strip) + end + def arel_visitor Arel::Visitors::SQLite.new(self) end diff --git a/activerecord/test/cases/migration/references_foreign_key_test.rb b/activerecord/test/cases/migration/references_foreign_key_test.rb index 27f6399718674..6ccd64ba3c00d 100644 --- a/activerecord/test/cases/migration/references_foreign_key_test.rb +++ b/activerecord/test/cases/migration/references_foreign_key_test.rb @@ -63,7 +63,7 @@ class ReferencesForeignKeyInCreateTest < ActiveRecord::TestCase fks.map { |fk| [fk.from_table, fk.to_table, fk.column] }) end - if current_adapter?(:PostgreSQLAdapter) + if ActiveRecord::Base.connection.supports_deferrable_constraints? test "deferrable: false option can be passed" do @connection.create_table :testings do |t| t.references :testing_parent, foreign_key: { deferrable: false }