diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md
index 3a7001b1ed..69aac5fa7c 100644
--- a/activerecord/CHANGELOG.md
+++ b/activerecord/CHANGELOG.md
@@ -1,3 +1,14 @@
+* Add basic support for CHECK constraints to database migrations.
+
+ Usage:
+
+ ```ruby
+ add_check_constraint :products, "price > 0", name: "price_check"
+ remove_check_constraint :products, name: "price_check"
+ ```
+
+ *fatkodima*
+
* Add `ActiveRecord::Base.strict_loading_by_default` and `ActiveRecord::Base.strict_loading_by_default=`
to enable/disable strict_loading mode by default for a model. The configuration's value is
inheritable by subclasses, but they can override that value and it will not impact parent class.
diff --git a/activerecord/lib/active_record/connection_adapters.rb b/activerecord/lib/active_record/connection_adapters.rb
index 0272ed72b2..cec86b4399 100644
--- a/activerecord/lib/active_record/connection_adapters.rb
+++ b/activerecord/lib/active_record/connection_adapters.rb
@@ -17,6 +17,7 @@ module ActiveRecord
autoload :ColumnDefinition
autoload :ChangeColumnDefinition
autoload :ForeignKeyDefinition
+ autoload :CheckConstraintDefinition
autoload :TableDefinition
autoload :Table
autoload :AlterTable
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb
index 0d3cffd586..216c924a01 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_creation.rb
@@ -15,7 +15,8 @@ module ActiveRecord
delegate :quote_column_name, :quote_table_name, :quote_default_expression, :type_to_sql,
:options_include_default?, :supports_indexes_in_create?, :supports_foreign_keys?, :foreign_key_options,
- :quoted_columns_for_index, :supports_partial_index?, to: :@conn, private: true
+ :quoted_columns_for_index, :supports_partial_index?, :supports_check_constraints?, :check_constraint_options,
+ to: :@conn, private: true
private
def visit_AlterTable(o)
@@ -23,6 +24,8 @@ module ActiveRecord
sql << o.adds.map { |col| accept col }.join(" ")
sql << o.foreign_key_adds.map { |fk| visit_AddForeignKey fk }.join(" ")
sql << o.foreign_key_drops.map { |fk| visit_DropForeignKey fk }.join(" ")
+ sql << o.check_constraint_adds.map { |con| visit_AddCheckConstraint con }.join(" ")
+ sql << o.check_constraint_drops.map { |con| visit_DropCheckConstraint con }.join(" ")
end
def visit_ColumnDefinition(o)
@@ -52,6 +55,10 @@ module ActiveRecord
statements.concat(o.foreign_keys.map { |to_table, options| foreign_key_in_create(o.name, to_table, options) })
end
+ if supports_check_constraints?
+ statements.concat(o.check_constraints.map { |expression, options| check_constraint_in_create(o.name, expression, options) })
+ end
+
create_sql << "(#{statements.join(', ')})" if statements.present?
add_table_options!(create_sql, o)
create_sql << " AS #{to_sql(o.as)}" if o.as
@@ -98,6 +105,18 @@ module ActiveRecord
sql.join(" ")
end
+ def visit_CheckConstraintDefinition(o)
+ "CONSTRAINT #{o.name} CHECK (#{o.expression})"
+ end
+
+ def visit_AddCheckConstraint(o)
+ "ADD #{accept(o)}"
+ end
+
+ def visit_DropCheckConstraint(name)
+ "DROP CONSTRAINT #{quote_column_name(name)}"
+ end
+
def quoted_columns(o)
String === o.columns ? o.columns : quoted_columns_for_index(o.columns, o.column_options)
end
@@ -148,6 +167,11 @@ module ActiveRecord
accept ForeignKeyDefinition.new(from_table, to_table, options)
end
+ def check_constraint_in_create(table_name, expression, options)
+ options = check_constraint_options(table_name, expression, options)
+ accept CheckConstraintDefinition.new(table_name, expression, options)
+ end
+
def action_sql(action, dependency)
case dependency
when :nullify then "ON #{action} SET NULL"
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
index 7b00364b9e..ddd1b6567a 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb
@@ -127,6 +127,16 @@ module ActiveRecord
end
end
+ CheckConstraintDefinition = Struct.new(:table_name, :expression, :options) do
+ def name
+ options[:name]
+ end
+
+ def export_name_on_schema_dump?
+ !ActiveRecord::SchemaDumper.chk_ignore_pattern.match?(name) if name
+ end
+ end
+
class ReferenceDefinition # :nodoc:
def initialize(
name,
@@ -267,7 +277,7 @@ module ActiveRecord
class TableDefinition
include ColumnMethods
- attr_reader :name, :temporary, :if_not_exists, :options, :as, :comment, :indexes, :foreign_keys
+ attr_reader :name, :temporary, :if_not_exists, :options, :as, :comment, :indexes, :foreign_keys, :check_constraints
def initialize(
conn,
@@ -284,6 +294,7 @@ module ActiveRecord
@indexes = []
@foreign_keys = []
@primary_keys = nil
+ @check_constraints = []
@temporary = temporary
@if_not_exists = if_not_exists
@options = options
@@ -412,6 +423,10 @@ module ActiveRecord
foreign_keys << [table_name, options]
end
+ def check_constraint(expression, **options)
+ check_constraints << [expression, options]
+ end
+
# Appends :datetime columns :created_at and
# :updated_at to the table. See {connection.add_timestamps}[rdoc-ref:SchemaStatements#add_timestamps]
#
@@ -471,14 +486,16 @@ module ActiveRecord
class AlterTable # :nodoc:
attr_reader :adds
- attr_reader :foreign_key_adds
- attr_reader :foreign_key_drops
+ attr_reader :foreign_key_adds, :foreign_key_drops
+ attr_reader :check_constraint_adds, :check_constraint_drops
def initialize(td)
@td = td
@adds = []
@foreign_key_adds = []
@foreign_key_drops = []
+ @check_constraint_adds = []
+ @check_constraint_drops = []
end
def name; @td.name; end
@@ -491,6 +508,14 @@ module ActiveRecord
@foreign_key_drops << name
end
+ def add_check_constraint(expression, options)
+ @check_constraint_adds << CheckConstraintDefinition.new(name, expression, options)
+ end
+
+ def drop_check_constraint(constraint_name)
+ @check_constraint_drops << constraint_name
+ end
+
def add_column(name, type, **options)
name = name.to_s
type = type.to_sym
@@ -515,6 +540,7 @@ module ActiveRecord
# t.rename
# t.references
# t.belongs_to
+ # t.check_constraint
# t.string
# t.text
# t.integer
@@ -536,6 +562,7 @@ module ActiveRecord
# t.remove_references
# t.remove_belongs_to
# t.remove_index
+ # t.remove_check_constraint
# t.remove_timestamps
# end
#
@@ -737,6 +764,24 @@ module ActiveRecord
def foreign_key_exists?(*args, **options)
@base.foreign_key_exists?(name, *args, **options)
end
+
+ # Adds a check constraint.
+ #
+ # t.check_constraint("price > 0", name: "price_check")
+ #
+ # See {connection.add_check_constraint}[rdoc-ref:SchemaStatements#add_check_constraint]
+ def check_constraint(*args)
+ @base.add_check_constraint(name, *args)
+ end
+
+ # Removes the given check constraint from the table.
+ #
+ # t.remove_check_constraint(name: "price_check")
+ #
+ # See {connection.remove_check_constraint}[rdoc-ref:SchemaStatements#remove_check_constraint]
+ def remove_check_constraint(*args)
+ @base.remove_check_constraint(name, *args)
+ end
end
end
end
diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
index ea3f7b5728..ee558b87df 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
@@ -1128,6 +1128,55 @@ module ActiveRecord
options
end
+ # Returns an array of check constraints for the given table.
+ # The check constraints are represented as CheckConstraintDefinition objects.
+ def check_constraints(table_name)
+ raise NotImplementedError
+ end
+
+ # Adds a new check constraint to the table. +expression+ is a String
+ # representation of verifiable boolean condition.
+ #
+ # add_check_constraint :products, "price > 0", name: "price_check"
+ #
+ # generates:
+ #
+ # ALTER TABLE "products" ADD CONSTRAINT price_check CHECK (price > 0)
+ #
+ def add_check_constraint(table_name, expression, **options)
+ return unless supports_check_constraints?
+
+ options = check_constraint_options(table_name, expression, options)
+ at = create_alter_table(table_name)
+ at.add_check_constraint(expression, options)
+
+ execute schema_creation.accept(at)
+ end
+
+ def check_constraint_options(table_name, expression, options) # :nodoc:
+ options = options.dup
+ options[:name] ||= check_constraint_name(table_name, expression: expression, **options)
+ options
+ end
+
+ # Removes the given check constraint from the table.
+ #
+ # remove_check_constraint :products, name: "price_check"
+ #
+ # The +expression+ 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, +expression+ will be used by #add_check_constraint.
+ def remove_check_constraint(table_name, expression = nil, **options)
+ return unless supports_check_constraints?
+
+ chk_name_to_delete = check_constraint_for!(table_name, expression: expression, **options).name
+
+ at = create_alter_table(table_name)
+ at.drop_check_constraint(chk_name_to_delete)
+
+ execute schema_creation.accept(at)
+ end
+
def dump_schema_information # :nodoc:
versions = schema_migration.all_versions
insert_versions_sql(versions) if versions.any?
@@ -1457,6 +1506,27 @@ module ActiveRecord
end
end
+ def check_constraint_name(table_name, **options)
+ options.fetch(:name) do
+ expression = options.fetch(:expression)
+ identifier = "#{table_name}_#{expression}_chk"
+ hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)
+
+ "chk_rails_#{hashed_identifier}"
+ end
+ end
+
+ def check_constraint_for(table_name, **options)
+ return unless supports_check_constraints?
+ chk_name = check_constraint_name(table_name, **options)
+ check_constraints(table_name).detect { |chk| chk.name == chk_name }
+ end
+
+ def check_constraint_for!(table_name, expression: nil, **options)
+ check_constraint_for(table_name, expression: expression, **options) ||
+ raise(ArgumentError, "Table '#{table_name}' has no check constraint for #{expression || options}")
+ end
+
def validate_index_length!(table_name, new_name, internal = false)
if new_name.length > index_name_length
raise ArgumentError, "Index name '#{new_name}' on table '#{table_name}' is too long; the limit is #{index_name_length} characters"
diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
index 95d6cb24ba..7d3f3a4a64 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
@@ -338,6 +338,11 @@ module ActiveRecord
end
deprecate :supports_foreign_keys_in_create?
+ # Does this adapter support creating check constraints?
+ def supports_check_constraints?
+ false
+ end
+
# Does this adapter support views?
def supports_views?
false
diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
index 13867e2fed..4495fadee7 100644
--- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
@@ -92,6 +92,14 @@ module ActiveRecord
true
end
+ def supports_check_constraints?
+ if mariadb?
+ database_version >= "10.2.1"
+ else
+ database_version >= "8.0.16"
+ end
+ end
+
def supports_views?
true
end
@@ -415,6 +423,30 @@ module ActiveRecord
end
end
+ def check_constraints(table_name)
+ scope = quoted_scope(table_name)
+
+ chk_info = exec_query(<<~SQL, "SCHEMA")
+ SELECT cc.constraint_name AS 'name',
+ cc.check_clause AS 'expression'
+ FROM information_schema.check_constraints cc
+ JOIN information_schema.table_constraints tc
+ USING (constraint_schema, constraint_name)
+ WHERE tc.table_schema = #{scope[:schema]}
+ AND tc.table_name = #{scope[:name]}
+ AND cc.constraint_schema = #{scope[:schema]}
+ SQL
+
+ chk_info.map do |row|
+ options = {
+ name: row["name"]
+ }
+ expression = row["expression"]
+ expression = expression[1..-2] unless mariadb? # remove parentheses added by mysql
+ CheckConstraintDefinition.new(table_name, expression, options)
+ end
+ end
+
def table_options(table_name) # :nodoc:
create_table_info = create_table_info(table_name)
diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb
index 3b91e15b2c..f7044b39b4 100644
--- a/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb
+++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb
@@ -11,6 +11,10 @@ module ActiveRecord
"DROP FOREIGN KEY #{name}"
end
+ def visit_DropCheckConstraint(name)
+ "DROP #{mariadb? ? 'CONSTRAINT' : 'CHECK'} #{name}"
+ end
+
def visit_AddColumnDefinition(o)
add_column_position!(super, column_options(o.column))
end
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb
index 84a1d2502f..c07d1fdc40 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb
@@ -519,6 +519,27 @@ module ActiveRecord
query_values(data_source_sql(table_name, type: "FOREIGN TABLE"), "SCHEMA").any? if table_name.present?
end
+ def check_constraints(table_name) # :nodoc:
+ scope = quoted_scope(table_name)
+
+ check_info = exec_query(<<-SQL, "SCHEMA")
+ SELECT conname, pg_get_constraintdef(c.oid) AS constraintdef
+ FROM pg_constraint c
+ JOIN pg_class t ON c.conrelid = t.oid
+ WHERE c.contype = 'c'
+ AND t.relname = #{scope[:name]}
+ SQL
+
+ check_info.map do |row|
+ options = {
+ name: row["conname"]
+ }
+ expression = row["constraintdef"][/CHECK \({2}(.+)\){2}/, 1]
+
+ CheckConstraintDefinition.new(table_name, expression, options)
+ end
+ end
+
# Maps logical Rails types to PostgreSQL-specific data types.
def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, **) # :nodoc:
sql = \
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
index 1f6e2e6bde..e6d59cb92c 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
@@ -166,6 +166,10 @@ module ActiveRecord
true
end
+ def supports_check_constraints?
+ true
+ end
+
def supports_validate_constraints?
true
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 558cd649ef..6aec25f6e0 100644
--- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb
@@ -78,6 +78,35 @@ module ActiveRecord
alter_table(from_table, foreign_keys)
end
+ def check_constraints(table_name)
+ table_sql = query_value(<<-SQL, "SCHEMA")
+ SELECT sql
+ FROM sqlite_master
+ WHERE name = #{quote_table_name(table_name)} AND type = 'table'
+ UNION ALL
+ SELECT sql
+ FROM sqlite_temp_master
+ WHERE name = #{quote_table_name(table_name)} AND type = 'table'
+ SQL
+
+ table_sql.scan(/CONSTRAINT\s+(?\w+)\s+CHECK\s+\((?(:?[^()]|\(\g\))+)\)/i).map do |name, expression|
+ CheckConstraintDefinition.new(table_name, expression, name: name)
+ end
+ end
+
+ def add_check_constraint(table_name, expression, **options)
+ alter_table(table_name) do |definition|
+ definition.check_constraint(expression, **options)
+ end
+ end
+
+ def remove_check_constraint(table_name, expression = nil, **options)
+ check_constraints = check_constraints(table_name)
+ chk_name_to_delete = check_constraint_for!(table_name, expression: expression, **options).name
+ check_constraints.delete_if { |chk| chk.name == chk_name_to_delete }
+ alter_table(table_name, foreign_keys(table_name), check_constraints)
+ end
+
def create_schema_dumper(options)
SQLite3::SchemaDumper.create(self, options)
end
diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb
index 4047a61bf6..7f7e723419 100644
--- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb
@@ -136,6 +136,10 @@ module ActiveRecord
true
end
+ def supports_check_constraints?
+ true
+ end
+
def supports_views?
true
end
@@ -361,7 +365,12 @@ module ActiveRecord
options[:null] == false && options[:default].nil?
end
- def alter_table(table_name, foreign_keys = foreign_keys(table_name), **options)
+ def alter_table(
+ table_name,
+ foreign_keys = foreign_keys(table_name),
+ check_constraints = check_constraints(table_name),
+ **options
+ )
altered_table_name = "a#{table_name}"
caller = lambda do |definition|
@@ -374,6 +383,10 @@ module ActiveRecord
definition.foreign_key(to_table, **fk.options)
end
+ check_constraints.each do |chk|
+ definition.check_constraint(chk.expression, **chk.options)
+ end
+
yield definition if block_given?
end
diff --git a/activerecord/lib/active_record/migration/command_recorder.rb b/activerecord/lib/active_record/migration/command_recorder.rb
index 012268cd22..dd7035d8ee 100644
--- a/activerecord/lib/active_record/migration/command_recorder.rb
+++ b/activerecord/lib/active_record/migration/command_recorder.rb
@@ -8,6 +8,7 @@ module ActiveRecord
#
# * add_column
# * add_foreign_key
+ # * add_check_constraint
# * add_index
# * add_reference
# * add_timestamps
@@ -25,6 +26,7 @@ module ActiveRecord
# * remove_column (must supply a type)
# * remove_columns (must specify at least one column name or more)
# * remove_foreign_key (must supply a second table)
+ # * remove_check_constraint
# * remove_index
# * remove_reference
# * remove_timestamps
@@ -39,7 +41,8 @@ module ActiveRecord
:drop_join_table, :drop_table, :execute_block, :enable_extension, :disable_extension,
:change_column, :execute, :remove_columns, :change_column_null,
:add_foreign_key, :remove_foreign_key,
- :change_column_comment, :change_table_comment
+ :change_column_comment, :change_table_comment,
+ :add_check_constraint, :remove_check_constraint
]
include JoinTable
@@ -136,6 +139,7 @@ module ActiveRecord
add_timestamps: :remove_timestamps,
add_reference: :remove_reference,
add_foreign_key: :remove_foreign_key,
+ add_check_constraint: :remove_check_constraint,
enable_extension: :disable_extension
}.each do |cmd, inv|
[[inv, cmd], [cmd, inv]].uniq.each do |method, inverse|
@@ -265,6 +269,11 @@ module ActiveRecord
[:change_table_comment, [table, from: options[:to], to: options[:from]]]
end
+ def invert_remove_check_constraint(args)
+ raise ActiveRecord::IrreversibleMigration, "remove_check_constraint is only reversible if given an expression." if args.size < 2
+ super
+ end
+
def respond_to_missing?(method, _)
super || delegate.respond_to?(method)
end
diff --git a/activerecord/lib/active_record/schema_dumper.rb b/activerecord/lib/active_record/schema_dumper.rb
index 7a68022553..ecd0c5f0af 100644
--- a/activerecord/lib/active_record/schema_dumper.rb
+++ b/activerecord/lib/active_record/schema_dumper.rb
@@ -23,6 +23,12 @@ module ActiveRecord
# should not be dumped to db/schema.rb.
cattr_accessor :fk_ignore_pattern, default: /^fk_rails_[0-9a-f]{10}$/
+ ##
+ # :singleton-method:
+ # Specify a custom regular expression matching check constraints which name
+ # should not be dumped to db/schema.rb.
+ cattr_accessor :chk_ignore_pattern, default: /^chk_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)
@@ -159,6 +165,7 @@ HEADER
end
indexes_in_create(table, tbl)
+ check_constraints_in_create(table, tbl) if @connection.supports_check_constraints?
tbl.puts " end"
tbl.puts
@@ -212,6 +219,24 @@ HEADER
index_parts
end
+ def check_constraints_in_create(table, stream)
+ if (check_constraints = @connection.check_constraints(table)).any?
+ add_check_constraint_statements = check_constraints.map do |check_constraint|
+ parts = [
+ "t.check_constraint #{check_constraint.expression.inspect}"
+ ]
+
+ if check_constraint.export_name_on_schema_dump?
+ parts << "name: #{check_constraint.name.inspect}"
+ end
+
+ " #{parts.join(', ')}"
+ end
+
+ stream.puts add_check_constraint_statements.sort.join("\n")
+ end
+ end
+
def foreign_keys(table, stream)
if (foreign_keys = @connection.foreign_keys(table)).any?
add_foreign_key_statements = foreign_keys.map do |foreign_key|
diff --git a/activerecord/test/cases/migration/change_table_test.rb b/activerecord/test/cases/migration/change_table_test.rb
index d5fd1e1c73..ce646bfc25 100644
--- a/activerecord/test/cases/migration/change_table_test.rb
+++ b/activerecord/test/cases/migration/change_table_test.rb
@@ -345,6 +345,20 @@ module ActiveRecord
assert_equal :delete_me, t.name
end
end
+
+ def test_check_constraint_creates_check_constraint
+ with_change_table do |t|
+ @connection.expect :add_check_constraint, nil, [:delete_me, "price > discounted_price", name: "price_check"]
+ t.check_constraint "price > discounted_price", name: "price_check"
+ end
+ end
+
+ def test_remove_check_constraint_removes_check_constraint
+ with_change_table do |t|
+ @connection.expect :remove_check_constraint, nil, [:delete_me, name: "price_check"]
+ t.remove_check_constraint name: "price_check"
+ end
+ end
end
end
end
diff --git a/activerecord/test/cases/migration/check_constraint_test.rb b/activerecord/test/cases/migration/check_constraint_test.rb
new file mode 100644
index 0000000000..dfe23fa2ea
--- /dev/null
+++ b/activerecord/test/cases/migration/check_constraint_test.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require "cases/helper"
+require "support/schema_dumping_helper"
+
+if ActiveRecord::Base.connection.supports_check_constraints?
+ module ActiveRecord
+ class Migration
+ class CheckConstraintTest < ActiveRecord::TestCase
+ include SchemaDumpingHelper
+
+ class Trade < ActiveRecord::Base
+ end
+
+ setup do
+ @connection = ActiveRecord::Base.connection
+ @connection.create_table "trades", force: true do |t|
+ t.integer :price
+ t.integer :quantity
+ end
+ end
+
+ teardown do
+ @connection.drop_table "trades", if_exists: true
+ end
+
+ def test_check_constraints
+ check_constraints = @connection.check_constraints("products")
+ assert_equal 1, check_constraints.size
+
+ constraint = check_constraints.first
+ assert_equal "products", constraint.table_name
+ assert_equal "products_price_check", constraint.name
+
+ if current_adapter?(:Mysql2Adapter)
+ assert_equal "`price` > `discounted_price`", constraint.expression
+ else
+ assert_equal "price > discounted_price", constraint.expression
+ end
+ end
+
+ def test_add_check_constraint
+ @connection.add_check_constraint :trades, "quantity > 0"
+
+ check_constraints = @connection.check_constraints("trades")
+ assert_equal 1, check_constraints.size
+
+ constraint = check_constraints.first
+ assert_equal "trades", constraint.table_name
+ assert_equal "chk_rails_2189e9f96c", constraint.name
+
+ if current_adapter?(:Mysql2Adapter)
+ assert_equal "`quantity` > 0", constraint.expression
+ else
+ assert_equal "quantity > 0", constraint.expression
+ end
+ end
+
+ def test_added_check_constraint_ensures_valid_values
+ @connection.add_check_constraint :trades, "quantity > 0", name: "quantity_check"
+
+ assert_raises(ActiveRecord::StatementInvalid) do
+ Trade.create(quantity: -1)
+ end
+ end
+
+ def test_remove_check_constraint
+ @connection.add_check_constraint :trades, "price > 0", name: "price_check"
+ @connection.add_check_constraint :trades, "quantity > 0", name: "quantity_check"
+
+ assert_equal 2, @connection.check_constraints("trades").size
+ @connection.remove_check_constraint :trades, name: "quantity_check"
+ assert_equal 1, @connection.check_constraints("trades").size
+
+ constraint = @connection.check_constraints("trades").first
+ assert_equal "trades", constraint.table_name
+ assert_equal "price_check", constraint.name
+
+ if current_adapter?(:Mysql2Adapter)
+ assert_equal "`price` > 0", constraint.expression
+ else
+ assert_equal "price > 0", constraint.expression
+ end
+ end
+
+ def test_remove_non_existing_check_constraint
+ assert_raises(ArgumentError) do
+ @connection.remove_check_constraint :trades, name: "nonexistent"
+ end
+ end
+ end
+ end
+ end
+else
+ module ActiveRecord
+ class Migration
+ class NoForeignKeySupportTest < ActiveRecord::TestCase
+ setup do
+ @connection = ActiveRecord::Base.connection
+ end
+
+ def test_add_check_constraint_should_be_noop
+ @connection.add_check_constraint :products, "discounted_price > 0", name: "discounted_price_check"
+ end
+
+ def test_remove_check_constraint_should_be_noop
+ @connection.remove_check_constraint :products, name: "price_check"
+ end
+
+ def test_check_constraints_should_raise_not_implemented
+ assert_raises(NotImplementedError) do
+ @connection.check_constraints("products")
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/activerecord/test/cases/migration/command_recorder_test.rb b/activerecord/test/cases/migration/command_recorder_test.rb
index 392b9d031a..8915367c2e 100644
--- a/activerecord/test/cases/migration/command_recorder_test.rb
+++ b/activerecord/test/cases/migration/command_recorder_test.rb
@@ -421,6 +421,17 @@ module ActiveRecord
end
end
end
+
+ def test_invert_remove_check_constraint
+ enable = @recorder.inverse_of :remove_check_constraint, [:dogs, "speed > 0", name: "speed_check"]
+ assert_equal [:add_check_constraint, [:dogs, "speed > 0", name: "speed_check"], nil], enable
+ end
+
+ def test_invert_remove_check_constraint_without_expression
+ assert_raises(ActiveRecord::IrreversibleMigration) do
+ @recorder.inverse_of :remove_check_constraint, [:dogs]
+ end
+ end
end
end
end
diff --git a/activerecord/test/cases/schema_dumper_test.rb b/activerecord/test/cases/schema_dumper_test.rb
index fcfd4526d3..1c9281004b 100644
--- a/activerecord/test/cases/schema_dumper_test.rb
+++ b/activerecord/test/cases/schema_dumper_test.rb
@@ -200,6 +200,17 @@ class SchemaDumperTest < ActiveRecord::TestCase
end
end
+ 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)
+ assert_equal 't.check_constraint "`price` > `discounted_price`", name: "products_price_check"', constraint_definition
+ else
+ assert_equal 't.check_constraint "price > discounted_price", name: "products_price_check"', constraint_definition
+ end
+ end
+ end
+
def test_schema_dump_should_honor_nonstandard_primary_keys
output = standard_dump
match = output.match(%r{create_table "movies"(.*)do})
diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb
index 2437b74227..4d0b1e0ebc 100644
--- a/activerecord/test/schema/schema.rb
+++ b/activerecord/test/schema/schema.rb
@@ -767,9 +767,13 @@ ActiveRecord::Schema.define do
create_table :products, force: true do |t|
t.references :collection
t.references :type
- t.string :name
+ t.string :name
+ t.decimal :price
+ t.decimal :discounted_price
end
+ add_check_constraint :products, "price > discounted_price", name: "products_price_check"
+
create_table :product_types, force: true do |t|
t.string :name
end
diff --git a/guides/source/active_record_migrations.md b/guides/source/active_record_migrations.md
index 67b4414ccd..9600c498b9 100644
--- a/guides/source/active_record_migrations.md
+++ b/guides/source/active_record_migrations.md
@@ -725,10 +725,6 @@ of `create_table` and `reversible`, replacing `create_table`
by `drop_table`, and finally replacing `up` by `down` and vice-versa.
This is all taken care of by `revert`.
-NOTE: If you want to add check constraints like in the examples above,
-you will have to use `structure.sql` as dump method. See
-[Schema Dumping and You](#schema-dumping-and-you).
-
Running Migrations
------------------
@@ -970,7 +966,7 @@ database and expressing its structure using `create_table`, `add_index`, and so
on.
`db/schema.rb` cannot express everything your database may support such as
-triggers, sequences, stored procedures, check constraints, etc. While migrations
+triggers, sequences, stored procedures, etc. While migrations
may use `execute` to create database constructs that are not supported by the
Ruby migration DSL, these constructs may not be able to be reconstituted by the
schema dumper. If you are using features like these, you should set the schema