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