1
0
Fork 0
mirror of https://github.com/rails/rails.git synced 2022-11-09 12:12:34 -05:00

Add basic support for check constraints to database migrations

This commit is contained in:
fatkodima 2017-12-04 01:04:42 +02:00 committed by Jeremy Daer
parent aaf20e3c7e
commit 1944a7e74c
20 changed files with 459 additions and 12 deletions

View file

@ -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.

View file

@ -17,6 +17,7 @@ module ActiveRecord
autoload :ColumnDefinition
autoload :ChangeColumnDefinition
autoload :ForeignKeyDefinition
autoload :CheckConstraintDefinition
autoload :TableDefinition
autoload :Table
autoload :AlterTable

View file

@ -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"

View file

@ -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 <tt>:datetime</tt> columns <tt>:created_at</tt> and
# <tt>:updated_at</tt> 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

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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 = \

View file

@ -166,6 +166,10 @@ module ActiveRecord
true
end
def supports_check_constraints?
true
end
def supports_validate_constraints?
true
end

View file

@ -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+(?<name>\w+)\s+CHECK\s+\((?<expression>(:?[^()]|\(\g<expression>\))+)\)/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

View file

@ -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

View file

@ -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

View file

@ -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|

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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})

View file

@ -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

View file

@ -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