提交 1944a7e7 编写于 作者: F fatkodima 提交者: Jeremy Daer

Add basic support for check constraints to database migrations

上级 aaf20e3c
* 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.
......
......@@ -17,6 +17,7 @@ module ConnectionAdapters
autoload :ColumnDefinition
autoload :ChangeColumnDefinition
autoload :ForeignKeyDefinition
autoload :CheckConstraintDefinition
autoload :TableDefinition
autoload :Table
autoload :AlterTable
......
......@@ -15,7 +15,8 @@ def accept(o)
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 @@ def visit_AlterTable(o)
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 @@ def visit_TableDefinition(o)
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 @@ def visit_CreateIndexDefinition(o)
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 @@ def foreign_key_in_create(from_table, to_table, options)
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"
......
......@@ -127,6 +127,16 @@ def default_primary_key
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 @@ def #{column_type}(*names, **options)
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 @@ def initialize(
@indexes = []
@foreign_keys = []
@primary_keys = nil
@check_constraints = []
@temporary = temporary
@if_not_exists = if_not_exists
@options = options
......@@ -412,6 +423,10 @@ def foreign_key(table_name, **options) # :nodoc:
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 @@ def integer_like_primary_key_type(type, options)
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 @@ def drop_foreign_key(name)
@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 @@ def add_column(name, type, **options)
# t.rename
# t.references
# t.belongs_to
# t.check_constraint
# t.string
# t.text
# t.integer
......@@ -536,6 +562,7 @@ def add_column(name, type, **options)
# t.remove_references
# t.remove_belongs_to
# t.remove_index
# t.remove_check_constraint
# t.remove_timestamps
# end
#
......@@ -737,6 +764,24 @@ def remove_foreign_key(*args, **options)
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
......@@ -1128,6 +1128,55 @@ def foreign_key_options(from_table, to_table, options) # :nodoc:
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 @@ def extract_foreign_key_action(specifier)
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"
......
......@@ -338,6 +338,11 @@ def supports_foreign_keys_in_create?
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
......
......@@ -92,6 +92,14 @@ def supports_foreign_keys?
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 @@ def foreign_keys(table_name)
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)
......
......@@ -11,6 +11,10 @@ def visit_DropForeignKey(name)
"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
......
......@@ -519,6 +519,27 @@ def foreign_table_exists?(table_name)
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 = \
......
......@@ -166,6 +166,10 @@ def supports_foreign_keys?
true
end
def supports_check_constraints?
true
end
def supports_validate_constraints?
true
end
......
......@@ -78,6 +78,35 @@ def remove_foreign_key(from_table, to_table = nil, **options)
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
......
......@@ -136,6 +136,10 @@ def supports_foreign_keys?
true
end
def supports_check_constraints?
true
end
def supports_views?
true
end
......@@ -361,7 +365,12 @@ def invalid_alter_table_type?(type, options)
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 @@ def alter_table(table_name, foreign_keys = foreign_keys(table_name), **options)
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
......
......@@ -8,6 +8,7 @@ class Migration
#
# * add_column
# * add_foreign_key
# * add_check_constraint
# * add_index
# * add_reference
# * add_timestamps
......@@ -25,6 +26,7 @@ class Migration
# * 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 @@ class CommandRecorder
: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 StraightReversions # :nodoc:
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 @@ def invert_change_table_comment(args)
[: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
......
......@@ -23,6 +23,12 @@ class SchemaDumper #:nodoc:
# 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 @@ def table(table, stream)
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 @@ def index_parts(index)
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|
......
......@@ -345,6 +345,20 @@ def test_table_name_set
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
# 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
......@@ -421,6 +421,17 @@ def test_invert_transaction_with_irreversible_inside_is_irreversible
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
......@@ -200,6 +200,17 @@ def test_schema_dumps_index_length
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})
......
......@@ -767,9 +767,13 @@
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
......
......@@ -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
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册