Add support for unique constraints (PostgreSQL-only).
```ruby add_unique_key :sections, [:position], deferrable: :deferred, name: "unique_section_position" remove_unique_key :sections, name: "unique_section_position" ``` See PostgreSQL's [Unique Constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS) documentation for more on unique constraints. By default, unique constraints in PostgreSQL are checked after each statement. This works for most use cases, but becomes a major limitation when replacing records with unique column by using multiple statements. An example of swapping unique columns between records. ```ruby old_item = Item.create!(position: 1) new_item = Item.create!(position: 2) Item.transaction do old_item.update!(position: 2) new_item.update!(position: 1) end ``` Using the default behavior, the transaction would fail when executing the first `UPDATE` statement. By passing the `:deferrable` option to the `add_unique_key` statement in migrations, it's possible to defer this check. ```ruby add_unique_key :items, [:position], deferrable: :immediate ``` Passing `deferrable: :immediate` does not change the behaviour of the previous example, but allows manually deferring the check using `SET CONSTRAINTS ALL DEFERRED` within a transaction. This will cause the unique constraints to be checked after the transaction. It's also possible to adjust the default behavior from an immediate check (after the statement), to a deferred check (after the transaction): ```ruby add_unique_key :items, [:position], deferrable: :deferred ``` PostgreSQL allows users to create a unique constraints on top of the unique index that cannot be deferred. In this case, even if users creates deferrable unique constraint, the existing unique index does not allow users to violate uniqueness within the transaction. If you want to change existing unique index to deferrable, you need execute `remove_index` before creating deferrable unique constraints. *Hiroyuki Ishii*
This commit is contained in:
parent
2eed4dc0af
commit
d849ee04c6
@ -1,3 +1,58 @@
|
||||
* Add support for unique constraints (PostgreSQL-only).
|
||||
|
||||
```ruby
|
||||
add_unique_key :sections, [:position], deferrable: :deferred, name: "unique_section_position"
|
||||
remove_unique_key :sections, name: "unique_section_position"
|
||||
```
|
||||
|
||||
See PostgreSQL's [Unique Constraints](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-UNIQUE-CONSTRAINTS) documentation for more on unique constraints.
|
||||
|
||||
By default, unique constraints in PostgreSQL are checked after each statement.
|
||||
This works for most use cases, but becomes a major limitation when replacing
|
||||
records with unique column by using multiple statements.
|
||||
|
||||
An example of swapping unique columns between records.
|
||||
|
||||
```ruby
|
||||
# position is unique column
|
||||
old_item = Item.create!(position: 1)
|
||||
new_item = Item.create!(position: 2)
|
||||
|
||||
Item.transaction do
|
||||
old_item.update!(position: 2)
|
||||
new_item.update!(position: 1)
|
||||
end
|
||||
```
|
||||
|
||||
Using the default behavior, the transaction would fail when executing the
|
||||
first `UPDATE` statement.
|
||||
|
||||
By passing the `:deferrable` option to the `add_unique_key` statement in
|
||||
migrations, it's possible to defer this check.
|
||||
|
||||
```ruby
|
||||
add_unique_key :items, [:position], deferrable: :immediate
|
||||
```
|
||||
|
||||
Passing `deferrable: :immediate` does not change the behaviour of the previous example,
|
||||
but allows manually deferring the check using `SET CONSTRAINTS ALL DEFERRED` within a transaction.
|
||||
This will cause the unique constraints to be checked after the transaction.
|
||||
|
||||
It's also possible to adjust the default behavior from an immediate
|
||||
check (after the statement), to a deferred check (after the transaction):
|
||||
|
||||
```ruby
|
||||
add_unique_key :items, [:position], deferrable: :deferred
|
||||
```
|
||||
|
||||
PostgreSQL allows users to create a unique constraints on top of the unique
|
||||
index that cannot be deferred. In this case, even if users creates deferrable
|
||||
unique constraint, the existing unique index does not allow users to violate uniqueness
|
||||
within the transaction. If you want to change existing unique index to deferrable,
|
||||
you need execute `remove_index` before creating deferrable unique constraints.
|
||||
|
||||
*Hiroyuki Ishii*
|
||||
|
||||
* Remove deprecated `Tasks::DatabaseTasks.schema_file_type`.
|
||||
|
||||
*Rafael Mendonça França*
|
||||
|
@ -16,7 +16,8 @@ def accept(o)
|
||||
delegate :quote_column_name, :quote_table_name, :quote_default_expression, :type_to_sql,
|
||||
:options_include_default?, :supports_indexes_in_create?, :use_foreign_keys?,
|
||||
:quoted_columns_for_index, :supports_partial_index?, :supports_check_constraints?,
|
||||
:supports_index_include?, :supports_exclusion_constraints?, to: :@conn, private: true
|
||||
:supports_index_include?, :supports_exclusion_constraints?, :supports_unique_keys?,
|
||||
to: :@conn, private: true
|
||||
|
||||
private
|
||||
def visit_AlterTable(o)
|
||||
@ -63,6 +64,10 @@ def visit_TableDefinition(o)
|
||||
statements.concat(o.exclusion_constraints.map { |exc| accept exc })
|
||||
end
|
||||
|
||||
if supports_unique_keys?
|
||||
statements.concat(o.unique_keys.map { |exc| accept exc })
|
||||
end
|
||||
|
||||
create_sql << "(#{statements.join(', ')})" if statements.present?
|
||||
add_table_options!(create_sql, o)
|
||||
create_sql << " AS #{to_sql(o.as)}" if o.as
|
||||
|
@ -500,6 +500,11 @@ def supports_exclusion_constraints?
|
||||
false
|
||||
end
|
||||
|
||||
# Does this adapter support creating unique constraints?
|
||||
def supports_unique_keys?
|
||||
false
|
||||
end
|
||||
|
||||
# Does this adapter support views?
|
||||
def supports_views?
|
||||
false
|
||||
|
@ -12,6 +12,8 @@ def visit_AlterTable(o)
|
||||
sql << o.constraint_validations.map { |fk| visit_ValidateConstraint fk }.join(" ")
|
||||
sql << o.exclusion_constraint_adds.map { |con| visit_AddExclusionConstraint con }.join(" ")
|
||||
sql << o.exclusion_constraint_drops.map { |con| visit_DropExclusionConstraint con }.join(" ")
|
||||
sql << o.unique_key_adds.map { |con| visit_AddUniqueKey con }.join(" ")
|
||||
sql << o.unique_key_drops.map { |con| visit_DropUniqueKey con }.join(" ")
|
||||
end
|
||||
|
||||
def visit_AddForeignKey(o)
|
||||
@ -44,6 +46,21 @@ def visit_ExclusionConstraintDefinition(o)
|
||||
sql.join(" ")
|
||||
end
|
||||
|
||||
def visit_UniqueKeyDefinition(o)
|
||||
column_name = Array(o.columns).map { |column| quote_column_name(column) }.join(", ")
|
||||
|
||||
sql = ["CONSTRAINT"]
|
||||
sql << quote_column_name(o.name)
|
||||
sql << "UNIQUE"
|
||||
sql << "(#{column_name})"
|
||||
|
||||
if o.deferrable
|
||||
sql << "DEFERRABLE INITIALLY #{o.deferrable.to_s.upcase}"
|
||||
end
|
||||
|
||||
sql.join(" ")
|
||||
end
|
||||
|
||||
def visit_AddExclusionConstraint(o)
|
||||
"ADD #{accept(o)}"
|
||||
end
|
||||
@ -52,6 +69,14 @@ def visit_DropExclusionConstraint(name)
|
||||
"DROP CONSTRAINT #{quote_column_name(name)}"
|
||||
end
|
||||
|
||||
def visit_AddUniqueKey(o)
|
||||
"ADD #{accept(o)}"
|
||||
end
|
||||
|
||||
def visit_DropUniqueKey(name)
|
||||
"DROP CONSTRAINT #{quote_column_name(name)}"
|
||||
end
|
||||
|
||||
def visit_ChangeColumnDefinition(o)
|
||||
column = o.column
|
||||
column.sql_type = type_to_sql(column.type, **column.options)
|
||||
|
@ -207,14 +207,29 @@ def export_name_on_schema_dump?
|
||||
end
|
||||
end
|
||||
|
||||
UniqueKeyDefinition = Struct.new(:table_name, :columns, :options) do
|
||||
def name
|
||||
options[:name]
|
||||
end
|
||||
|
||||
def deferrable
|
||||
options[:deferrable]
|
||||
end
|
||||
|
||||
def export_name_on_schema_dump?
|
||||
!ActiveRecord::SchemaDumper.unique_ignore_pattern.match?(name) if name
|
||||
end
|
||||
end
|
||||
|
||||
class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition
|
||||
include ColumnMethods
|
||||
|
||||
attr_reader :exclusion_constraints, :unlogged
|
||||
attr_reader :exclusion_constraints, :unique_keys, :unlogged
|
||||
|
||||
def initialize(*, **)
|
||||
super
|
||||
@exclusion_constraints = []
|
||||
@unique_keys = []
|
||||
@unlogged = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables
|
||||
end
|
||||
|
||||
@ -222,11 +237,20 @@ def exclusion_constraint(expression, **options)
|
||||
exclusion_constraints << new_exclusion_constraint_definition(expression, options)
|
||||
end
|
||||
|
||||
def unique_key(column_name, **options)
|
||||
unique_keys << new_unique_key_definition(column_name, options)
|
||||
end
|
||||
|
||||
def new_exclusion_constraint_definition(expression, options) # :nodoc:
|
||||
options = @conn.exclusion_constraint_options(name, expression, options)
|
||||
ExclusionConstraintDefinition.new(name, expression, options)
|
||||
end
|
||||
|
||||
def new_unique_key_definition(column_name, options) # :nodoc:
|
||||
options = @conn.unique_key_options(name, column_name, options)
|
||||
UniqueKeyDefinition.new(name, column_name, options)
|
||||
end
|
||||
|
||||
def new_column_definition(name, type, **options) # :nodoc:
|
||||
case type
|
||||
when :virtual
|
||||
@ -274,16 +298,36 @@ def exclusion_constraint(*args)
|
||||
def remove_exclusion_constraint(*args)
|
||||
@base.remove_exclusion_constraint(name, *args)
|
||||
end
|
||||
|
||||
# Adds an unique constraint.
|
||||
#
|
||||
# t.unique_key(:position, name: 'unique_position', deferrable: :deferred)
|
||||
#
|
||||
# See {connection.add_unique_key}[rdoc-ref:SchemaStatements#add_unique_key]
|
||||
def unique_key(*args)
|
||||
@base.add_unique_key(name, *args)
|
||||
end
|
||||
|
||||
# Removes the given unique constraint from the table.
|
||||
#
|
||||
# t.remove_unique_key(name: "unique_position")
|
||||
#
|
||||
# See {connection.remove_unique_key}[rdoc-ref:SchemaStatements#remove_unique_key]
|
||||
def remove_unique_key(*args)
|
||||
@base.remove_unique_key(name, *args)
|
||||
end
|
||||
end
|
||||
|
||||
class AlterTable < ActiveRecord::ConnectionAdapters::AlterTable
|
||||
attr_reader :constraint_validations, :exclusion_constraint_adds, :exclusion_constraint_drops
|
||||
attr_reader :constraint_validations, :exclusion_constraint_adds, :exclusion_constraint_drops, :unique_key_adds, :unique_key_drops
|
||||
|
||||
def initialize(td)
|
||||
super
|
||||
@constraint_validations = []
|
||||
@exclusion_constraint_adds = []
|
||||
@exclusion_constraint_drops = []
|
||||
@unique_key_adds = []
|
||||
@unique_key_drops = []
|
||||
end
|
||||
|
||||
def validate_constraint(name)
|
||||
@ -297,6 +341,14 @@ def add_exclusion_constraint(expression, options)
|
||||
def drop_exclusion_constraint(constraint_name)
|
||||
@exclusion_constraint_drops << constraint_name
|
||||
end
|
||||
|
||||
def add_unique_key(column_name, options)
|
||||
@unique_key_adds << @td.new_unique_key_definition(column_name, options)
|
||||
end
|
||||
|
||||
def drop_unique_key(unique_key_name)
|
||||
@unique_key_drops << unique_key_name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -49,6 +49,26 @@ def exclusion_constraints_in_create(table, stream)
|
||||
end
|
||||
end
|
||||
|
||||
def unique_keys_in_create(table, stream)
|
||||
if (unique_keys = @connection.unique_keys(table)).any?
|
||||
add_unique_key_statements = unique_keys.map do |unique_key|
|
||||
parts = [
|
||||
"t.unique_key #{unique_key.columns.inspect}"
|
||||
]
|
||||
|
||||
parts << "deferrable: #{unique_key.deferrable.inspect}" if unique_key.deferrable
|
||||
|
||||
if unique_key.export_name_on_schema_dump?
|
||||
parts << "name: #{unique_key.name.inspect}"
|
||||
end
|
||||
|
||||
" #{parts.join(', ')}"
|
||||
end
|
||||
|
||||
stream.puts add_unique_key_statements.sort.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_column_options(column)
|
||||
spec = super
|
||||
spec[:array] = "true" if column.array?
|
||||
|
@ -614,6 +614,40 @@ def exclusion_constraints(table_name)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns an array of unique constraints for the given table.
|
||||
# The unique constraints are represented as UniqueKeyDefinition objects.
|
||||
def unique_keys(table_name)
|
||||
scope = quoted_scope(table_name)
|
||||
|
||||
unique_info = exec_query(<<~SQL, "SCHEMA", allow_retry: true, uses_transaction: false)
|
||||
SELECT c.conname, c.conindid, c.condeferrable, c.condeferred
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON c.conrelid = t.oid
|
||||
JOIN pg_namespace n ON n.oid = c.connamespace
|
||||
WHERE c.contype = 'u'
|
||||
AND t.relname = #{scope[:name]}
|
||||
AND n.nspname = #{scope[:schema]}
|
||||
SQL
|
||||
|
||||
unique_info.map do |row|
|
||||
deferrable = extract_unique_key_deferrable(row["condeferrable"], row["condeferred"])
|
||||
|
||||
columns = query_values(<<~SQL, "SCHEMA")
|
||||
SELECT a.attname
|
||||
FROM pg_attribute a
|
||||
WHERE a.attrelid = #{row['conindid']}
|
||||
ORDER BY a.attnum
|
||||
SQL
|
||||
|
||||
options = {
|
||||
name: row["conname"],
|
||||
deferrable: deferrable
|
||||
}
|
||||
|
||||
UniqueKeyDefinition.new(table_name, columns, options)
|
||||
end
|
||||
end
|
||||
|
||||
# Adds a new exclusion constraint to the table. +expression+ is a String
|
||||
# representation of a list of exclusion elements and operators.
|
||||
#
|
||||
@ -656,6 +690,57 @@ def remove_exclusion_constraint(table_name, expression = nil, **options)
|
||||
execute schema_creation.accept(at)
|
||||
end
|
||||
|
||||
# Adds a new unique constraint to the table.
|
||||
#
|
||||
# PostgreSQL allows users to create a unique constraints on top of the unique index
|
||||
# that cannot be deferred. In this case, even if users creates deferrable unique constraint,
|
||||
# the existing unique index does not allow users to violate uniqueness within the transaction.
|
||||
# If you want to change existing unique index to deferrable, you need execute `remove_index`
|
||||
# before creating deferrable unique constraints.
|
||||
#
|
||||
# add_unique_key :sections, [:position], deferrable: :deferred, name: "unique_position"
|
||||
#
|
||||
# generates:
|
||||
#
|
||||
# ALTER TABLE "sections" ADD CONSTRAINT unique_position UNIQUE (position) DEFERRABLE INITIALLY DEFERRED
|
||||
#
|
||||
# The +options+ hash can include the following keys:
|
||||
# [<tt>:name</tt>]
|
||||
# The constraint name. Defaults to <tt>uniq_rails_<identifier></tt>.
|
||||
# [<tt>:deferrable</tt>]
|
||||
# Specify whether or not the unique constraint should be deferrable. Valid values are +false+ or +:immediate+ or +:deferred+ to specify the default behavior. Defaults to +false+.
|
||||
def add_unique_key(table_name, column_name, **options)
|
||||
options = unique_key_options(table_name, column_name, options)
|
||||
at = create_alter_table(table_name)
|
||||
at.add_unique_key(column_name, options)
|
||||
|
||||
execute schema_creation.accept(at)
|
||||
end
|
||||
|
||||
def unique_key_options(table_name, column_name, options) # :nodoc:
|
||||
assert_valid_deferrable(options[:deferrable])
|
||||
|
||||
options = options.dup
|
||||
options[:name] ||= unique_key_name(table_name, column_name: column_name, **options)
|
||||
options
|
||||
end
|
||||
|
||||
# Removes the given unique constraint from the table.
|
||||
#
|
||||
# remove_unique_key :sections, name: "unique_position"
|
||||
#
|
||||
# The +column_name+ parameter will be ignored if present. It can be helpful
|
||||
# to provide this in a migration's +change+ method so it can be reverted.
|
||||
# In that case, +column_name+ will be used by #add_unique_key.
|
||||
def remove_unique_key(table_name, column_name = nil, **options)
|
||||
unique_name_to_delete = unique_key_for!(table_name, column_name: column_name, **options).name
|
||||
|
||||
at = create_alter_table(table_name)
|
||||
at.drop_unique_key(unique_name_to_delete)
|
||||
|
||||
execute schema_creation.accept(at)
|
||||
end
|
||||
|
||||
# Maps logical Rails types to PostgreSQL-specific data types.
|
||||
def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, enum_type: nil, **) # :nodoc:
|
||||
sql = \
|
||||
@ -854,6 +939,16 @@ def extract_foreign_key_deferrable(deferrable, deferred)
|
||||
deferrable && (deferred ? :deferred : true)
|
||||
end
|
||||
|
||||
def extract_unique_key_deferrable(deferrable, deferred)
|
||||
deferrable && (deferred ? :deferred : :immediate)
|
||||
end
|
||||
|
||||
def assert_valid_deferrable(deferrable) # :nodoc:
|
||||
return if !deferrable || %i(immediate deferred).include?(deferrable)
|
||||
|
||||
raise ArgumentError, "deferrable must be `:immediate` or `:deferred`, got: `#{deferrable.inspect}`"
|
||||
end
|
||||
|
||||
def reference_name_for_table(table_name)
|
||||
_schema, table_name = extract_schema_qualified_name(table_name.to_s)
|
||||
table_name.singularize
|
||||
@ -911,6 +1006,26 @@ def exclusion_constraint_for!(table_name, expression: nil, **options)
|
||||
raise(ArgumentError, "Table '#{table_name}' has no exclusion constraint for #{expression || options}")
|
||||
end
|
||||
|
||||
def unique_key_name(table_name, **options)
|
||||
options.fetch(:name) do
|
||||
column_name = options.fetch(:column_name)
|
||||
identifier = "#{table_name}_#{column_name}_unique"
|
||||
hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
|
||||
|
||||
"uniq_rails_#{hashed_identifier}"
|
||||
end
|
||||
end
|
||||
|
||||
def unique_key_for(table_name, **options)
|
||||
unique_key_name = unique_key_name(table_name, **options)
|
||||
unique_keys(table_name).detect { |unique_key| unique_key.name == unique_key_name }
|
||||
end
|
||||
|
||||
def unique_key_for!(table_name, column_name: nil, **options)
|
||||
unique_key_for(table_name, column_name: column_name, **options) ||
|
||||
raise(ArgumentError, "Table '#{table_name}' has no unique constraint for #{column_name || options}")
|
||||
end
|
||||
|
||||
def data_source_sql(name = nil, type: nil)
|
||||
scope = quoted_scope(name, type: type)
|
||||
scope[:type] ||= "'r','v','m','p','f'" # (r)elation/table, (v)iew, (m)aterialized view, (p)artitioned table, (f)oreign table
|
||||
|
@ -224,6 +224,10 @@ def supports_exclusion_constraints?
|
||||
true
|
||||
end
|
||||
|
||||
def supports_unique_keys?
|
||||
true
|
||||
end
|
||||
|
||||
def supports_validate_constraints?
|
||||
true
|
||||
end
|
||||
|
@ -10,6 +10,7 @@ class Migration
|
||||
# * add_foreign_key
|
||||
# * add_check_constraint
|
||||
# * add_exclusion_constraint
|
||||
# * add_unique_key
|
||||
# * add_index
|
||||
# * add_reference
|
||||
# * add_timestamps
|
||||
@ -30,6 +31,7 @@ class Migration
|
||||
# * remove_foreign_key (must supply a second table)
|
||||
# * remove_check_constraint
|
||||
# * remove_exclusion_constraint
|
||||
# * remove_unique_key
|
||||
# * remove_index
|
||||
# * remove_reference
|
||||
# * remove_timestamps
|
||||
@ -47,6 +49,7 @@ class CommandRecorder
|
||||
:change_column_comment, :change_table_comment,
|
||||
:add_check_constraint, :remove_check_constraint,
|
||||
:add_exclusion_constraint, :remove_exclusion_constraint,
|
||||
:add_unique_key, :remove_unique_key,
|
||||
:create_enum, :drop_enum,
|
||||
]
|
||||
include JoinTable
|
||||
@ -154,6 +157,7 @@ module StraightReversions # :nodoc:
|
||||
add_foreign_key: :remove_foreign_key,
|
||||
add_check_constraint: :remove_check_constraint,
|
||||
add_exclusion_constraint: :remove_exclusion_constraint,
|
||||
add_unique_key: :remove_unique_key,
|
||||
enable_extension: :disable_extension,
|
||||
create_enum: :drop_enum
|
||||
}.each do |cmd, inv|
|
||||
@ -304,6 +308,13 @@ def invert_remove_exclusion_constraint(args)
|
||||
super
|
||||
end
|
||||
|
||||
def invert_remove_unique_key(args)
|
||||
_table, columns = args.dup.tap(&:extract_options!)
|
||||
|
||||
raise ActiveRecord::IrreversibleMigration, "remove_unique_key is only reversible if given an column_name." if columns.blank?
|
||||
super
|
||||
end
|
||||
|
||||
def invert_drop_enum(args)
|
||||
_enum, values = args.dup.tap(&:extract_options!)
|
||||
raise ActiveRecord::IrreversibleMigration, "drop_enum is only reversible if given a list of enum values." unless values
|
||||
|
@ -34,6 +34,12 @@ class SchemaDumper # :nodoc:
|
||||
# should not be dumped to db/schema.rb.
|
||||
cattr_accessor :excl_ignore_pattern, default: /^excl_rails_[0-9a-f]{10}$/
|
||||
|
||||
##
|
||||
# :singleton-method:
|
||||
# Specify a custom regular expression matching unique constraints which name
|
||||
# should not be dumped to db/schema.rb.
|
||||
cattr_accessor :unique_ignore_pattern, default: /^uniq_rails_[0-9a-f]{10}$/
|
||||
|
||||
class << self
|
||||
def dump(connection = ActiveRecord::Base.connection, stream = STDOUT, config = ActiveRecord::Base)
|
||||
connection.create_schema_dumper(generate_options(config)).dump(stream)
|
||||
@ -182,6 +188,7 @@ def table(table, stream)
|
||||
indexes_in_create(table, tbl)
|
||||
check_constraints_in_create(table, tbl) if @connection.supports_check_constraints?
|
||||
exclusion_constraints_in_create(table, tbl) if @connection.supports_exclusion_constraints?
|
||||
unique_keys_in_create(table, tbl) if @connection.supports_unique_keys?
|
||||
|
||||
tbl.puts " end"
|
||||
tbl.puts
|
||||
@ -217,6 +224,12 @@ def indexes_in_create(table, stream)
|
||||
indexes = indexes.reject { |index| exclusion_constraint_names.include?(index.name) }
|
||||
end
|
||||
|
||||
if @connection.supports_unique_keys? && (unique_keys = @connection.unique_keys(table)).any?
|
||||
unique_key_names = unique_keys.collect(&:name)
|
||||
|
||||
indexes = indexes.reject { |index| unique_key_names.include?(index.name) }
|
||||
end
|
||||
|
||||
index_statements = indexes.map do |index|
|
||||
" t.index #{index_parts(index).join(', ')}"
|
||||
end
|
||||
|
@ -176,6 +176,20 @@ def test_remove_exclusion_constraint_removes_exclusion_constraint
|
||||
t.remove_exclusion_constraint name: "date_overlap"
|
||||
end
|
||||
end
|
||||
|
||||
def test_unique_key_creates_unique_key
|
||||
with_change_table do |t|
|
||||
expect :add_unique_key, nil, [:delete_me, :foo, deferrable: :deferred, name: "unique_key"]
|
||||
t.unique_key :foo, deferrable: :deferred, name: "unique_key"
|
||||
end
|
||||
end
|
||||
|
||||
def test_remove_unique_key_removes_unique_key
|
||||
with_change_table do |t|
|
||||
expect :remove_unique_key, nil, [:delete_me, name: "unique_key"]
|
||||
t.remove_unique_key name: "unique_key"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_column_creates_column
|
||||
|
@ -457,6 +457,22 @@ def test_invert_remove_check_constraint_without_expression
|
||||
end
|
||||
end
|
||||
|
||||
def test_invert_remove_unique_key_constraint
|
||||
enable = @recorder.inverse_of :remove_unique_key, [:dogs, ["speed"], deferrable: :deferred, name: "uniq_speed"]
|
||||
assert_equal [:add_unique_key, [:dogs, ["speed"], deferrable: :deferred, name: "uniq_speed"], nil], enable
|
||||
end
|
||||
|
||||
def test_invert_remove_unique_key_constraint_without_options
|
||||
enable = @recorder.inverse_of :remove_unique_key, [:dogs, ["speed"]]
|
||||
assert_equal [:add_unique_key, [:dogs, ["speed"]], nil], enable
|
||||
end
|
||||
|
||||
def test_invert_remove_unique_key_constraint_without_columns
|
||||
assert_raises(ActiveRecord::IrreversibleMigration) do
|
||||
@recorder.inverse_of :remove_unique_key, [:dogs, name: "uniq_speed"]
|
||||
end
|
||||
end
|
||||
|
||||
def test_invert_create_enum
|
||||
drop = @recorder.inverse_of :create_enum, [:color, ["blue", "green"]]
|
||||
assert_equal [:drop_enum, [:color, ["blue", "green"]], nil], drop
|
||||
|
167
activerecord/test/cases/migration/unique_key_test.rb
Normal file
167
activerecord/test/cases/migration/unique_key_test.rb
Normal file
@ -0,0 +1,167 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "cases/helper"
|
||||
require "support/schema_dumping_helper"
|
||||
|
||||
if ActiveRecord::Base.connection.supports_unique_keys?
|
||||
module ActiveRecord
|
||||
class Migration
|
||||
class UniqueKeyTest < ActiveRecord::TestCase
|
||||
include SchemaDumpingHelper
|
||||
|
||||
class Section < ActiveRecord::Base
|
||||
end
|
||||
|
||||
setup do
|
||||
@connection = ActiveRecord::Base.connection
|
||||
@connection.create_table "sections", force: true do |t|
|
||||
t.integer "position", null: false
|
||||
end
|
||||
end
|
||||
|
||||
teardown do
|
||||
@connection.drop_table "sections", if_exists: true
|
||||
end
|
||||
|
||||
def test_unique_keys
|
||||
unique_keys = @connection.unique_keys("test_unique_keys")
|
||||
|
||||
expected_constraints = [
|
||||
{
|
||||
name: "test_unique_keys_position_deferrable_false",
|
||||
deferrable: false,
|
||||
columns: ["position_1"]
|
||||
}, {
|
||||
name: "test_unique_keys_position_deferrable_immediate",
|
||||
deferrable: :immediate,
|
||||
columns: ["position_2"]
|
||||
}, {
|
||||
name: "test_unique_keys_position_deferrable_deferred",
|
||||
deferrable: :deferred,
|
||||
columns: ["position_3"]
|
||||
}
|
||||
]
|
||||
|
||||
assert_equal expected_constraints.size, unique_keys.size
|
||||
|
||||
expected_constraints.each do |expected_constraint|
|
||||
constraint = unique_keys.find { |constraint| constraint.name == expected_constraint[:name] }
|
||||
assert_equal "test_unique_keys", constraint.table_name
|
||||
assert_equal expected_constraint[:name], constraint.name
|
||||
assert_equal expected_constraint[:columns], constraint.columns
|
||||
assert_equal expected_constraint[:deferrable], constraint.deferrable
|
||||
end
|
||||
end
|
||||
|
||||
def test_unique_keys_scoped_to_schemas
|
||||
@connection.add_unique_key :sections, [:position]
|
||||
|
||||
assert_no_changes -> { @connection.unique_keys("sections").size } do
|
||||
@connection.create_schema "test_schema"
|
||||
@connection.create_table "test_schema.sections" do |t|
|
||||
t.integer :position
|
||||
end
|
||||
@connection.add_unique_key "test_schema.sections", [:position]
|
||||
end
|
||||
ensure
|
||||
@connection.drop_schema "test_schema"
|
||||
end
|
||||
|
||||
def test_add_unique_key_without_deferrable
|
||||
@connection.add_unique_key :sections, [:position]
|
||||
|
||||
unique_keys = @connection.unique_keys("sections")
|
||||
assert_equal 1, unique_keys.size
|
||||
|
||||
constraint = unique_keys.first
|
||||
assert_equal "sections", constraint.table_name
|
||||
assert_equal "uniq_rails_3d89d7e853", constraint.name
|
||||
assert_equal false, constraint.deferrable
|
||||
end
|
||||
|
||||
def test_add_unique_key_with_deferrable_false
|
||||
@connection.add_unique_key :sections, [:position], deferrable: false
|
||||
|
||||
unique_keys = @connection.unique_keys("sections")
|
||||
assert_equal 1, unique_keys.size
|
||||
|
||||
constraint = unique_keys.first
|
||||
assert_equal "sections", constraint.table_name
|
||||
assert_equal "uniq_rails_3d89d7e853", constraint.name
|
||||
assert_equal false, constraint.deferrable
|
||||
end
|
||||
|
||||
def test_add_unique_key_with_deferrable_immediate
|
||||
@connection.add_unique_key :sections, [:position], deferrable: :immediate
|
||||
|
||||
unique_keys = @connection.unique_keys("sections")
|
||||
assert_equal 1, unique_keys.size
|
||||
|
||||
constraint = unique_keys.first
|
||||
assert_equal "sections", constraint.table_name
|
||||
assert_equal "uniq_rails_3d89d7e853", constraint.name
|
||||
assert_equal :immediate, constraint.deferrable
|
||||
end
|
||||
|
||||
def test_add_unique_key_with_deferrable_deferred
|
||||
@connection.add_unique_key :sections, [:position], deferrable: :deferred
|
||||
|
||||
unique_keys = @connection.unique_keys("sections")
|
||||
assert_equal 1, unique_keys.size
|
||||
|
||||
constraint = unique_keys.first
|
||||
assert_equal "sections", constraint.table_name
|
||||
assert_equal "uniq_rails_3d89d7e853", constraint.name
|
||||
assert_equal :deferred, constraint.deferrable
|
||||
end
|
||||
|
||||
def test_add_unique_key_with_deferrable_invalid
|
||||
error = assert_raises(ArgumentError) do
|
||||
@connection.add_unique_key :sections, [:position], deferrable: true
|
||||
end
|
||||
|
||||
assert_equal "deferrable must be `:immediate` or `:deferred`, got: `true`", error.message
|
||||
end
|
||||
|
||||
def test_added_deferrable_initially_immediate_unique_key
|
||||
@connection.add_unique_key :sections, [:position], deferrable: :immediate, name: "unique_section_position"
|
||||
|
||||
section = Section.create!(position: 1)
|
||||
|
||||
assert_raises(ActiveRecord::StatementInvalid) do
|
||||
Section.transaction(requires_new: true) do
|
||||
Section.create!(position: 1)
|
||||
section.update!(position: 2)
|
||||
end
|
||||
end
|
||||
|
||||
assert_nothing_raised do
|
||||
Section.transaction(requires_new: true) do
|
||||
Section.connection.exec_query("SET CONSTRAINTS unique_section_position DEFERRED")
|
||||
Section.create!(position: 1)
|
||||
section.update!(position: 2)
|
||||
|
||||
# NOTE: Clear `SET CONSTRAINTS` statement at the end of transaction.
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_remove_unique_key
|
||||
assert_equal 0, @connection.unique_keys("sections").size
|
||||
|
||||
@connection.add_unique_key :sections, [:position], name: "unique_section_position"
|
||||
assert_equal 1, @connection.unique_keys("sections").size
|
||||
@connection.remove_unique_key :sections, name: "unique_section_position"
|
||||
assert_equal 0, @connection.unique_keys("sections").size
|
||||
end
|
||||
|
||||
def test_remove_non_existing_unique_key
|
||||
assert_raises(ArgumentError) do
|
||||
@connection.remove_unique_key :sections, name: "nonexistent"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -227,6 +227,25 @@ def test_schema_dumps_exclusion_constraints
|
||||
end
|
||||
end
|
||||
|
||||
if ActiveRecord::Base.connection.supports_unique_keys?
|
||||
def test_schema_dumps_unique_keys
|
||||
output = dump_table_schema("test_unique_keys")
|
||||
constraint_definitions = output.split(/\n/).grep(/t\.unique_key/)
|
||||
|
||||
assert_equal 3, constraint_definitions.size
|
||||
assert_match 't.unique_key ["position_1"], name: "test_unique_keys_position_deferrable_false"', output
|
||||
assert_match 't.unique_key ["position_2"], deferrable: :immediate, name: "test_unique_keys_position_deferrable_immediate"', output
|
||||
assert_match 't.unique_key ["position_3"], deferrable: :deferred, name: "test_unique_keys_position_deferrable_deferred"', output
|
||||
end
|
||||
|
||||
def test_schema_does_not_dumps_unique_key_indexes
|
||||
output = dump_table_schema("test_unique_keys")
|
||||
unique_index_definitions = output.split(/\n/).grep(/t\.index.*unique: true/)
|
||||
|
||||
assert_equal 0, unique_index_definitions.size
|
||||
end
|
||||
end
|
||||
|
||||
def test_schema_dump_should_honor_nonstandard_primary_keys
|
||||
output = standard_dump
|
||||
match = output.match(%r{create_table "movies"(.*)do})
|
||||
|
@ -135,6 +135,16 @@
|
||||
t.exclusion_constraint "daterange(start_date, end_date) WITH &&", using: :gist, where: "start_date IS NOT NULL AND end_date IS NOT NULL", name: "test_exclusion_constraints_date_overlap"
|
||||
end
|
||||
|
||||
create_table :test_unique_keys, force: true do |t|
|
||||
t.integer :position_1
|
||||
t.integer :position_2
|
||||
t.integer :position_3
|
||||
|
||||
t.unique_key :position_1, name: "test_unique_keys_position_deferrable_false"
|
||||
t.unique_key :position_2, name: "test_unique_keys_position_deferrable_immediate", deferrable: :immediate
|
||||
t.unique_key :position_3, name: "test_unique_keys_position_deferrable_deferred", deferrable: :deferred
|
||||
end
|
||||
|
||||
if supports_partitioned_indexes?
|
||||
create_table(:measurements, id: false, force: true, options: "PARTITION BY LIST (city_id)") do |t|
|
||||
t.string :city_id, null: false
|
||||
|
Loading…
Reference in New Issue
Block a user