Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
ruby-version:
- '3.3'
- '3.4'
- '4.0'
lockfile:
- 'Gemfile.rails-7.2.lock'
- 'Gemfile.rails-8.0.lock'
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
activerecord-pg-extensions (0.7.1)
activerecord-pg-extensions (0.8.0)
activerecord (>= 7.2, < 8.2)
railties (>= 7.2, < 8.2)

Expand Down
2 changes: 1 addition & 1 deletion Gemfile.rails-7.2.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
activerecord-pg-extensions (0.7.1)
activerecord-pg-extensions (0.8.0)
activerecord (>= 7.2, < 8.2)
railties (>= 7.2, < 8.2)

Expand Down
2 changes: 1 addition & 1 deletion Gemfile.rails-8.0.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
activerecord-pg-extensions (0.7.1)
activerecord-pg-extensions (0.8.0)
activerecord (>= 7.2, < 8.2)
railties (>= 7.2, < 8.2)

Expand Down
119 changes: 119 additions & 0 deletions lib/active_record/pg_extensions/command_recorder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,132 @@ def rename_constraint(table_name, old_name, new_name, **options)
record(:rename_constraint, [table_name, old_name, new_name, options])
end

def change_constraint(table, constraint, **options)
record(:change_constraint, [table, constraint, options])
end

def change_check_constraint(table, **options)
record(:change_check_constraint, [table, options])
end

def change_index(table, **options)
record(:change_index, [table, options])
end

private

def invert_change_index(args)
table, options = args
options ||= {}
# reverse the change by swapping :from and :to, leaving everything else intact
inverted = options.merge(from: options[:to], to: options[:from])
[:change_index, [table, Hash.ruby2_keywords_hash(inverted)]]
end

def invert_change_check_constraint(args)
table, options = args
options ||= {}
# reverse the change by swapping :from and :to, leaving everything else intact
inverted = options.merge(from: options[:to], to: options[:from])
[:change_check_constraint, [table, Hash.ruby2_keywords_hash(inverted)]]
end

def invert_rename_constraint(args)
table_name, old_name, new_name, options = args
options ||= {}
# flag the trailing hash as keyword arguments so it's replayed as
# `rename_constraint(..., if_exists:)` rather than a positional hash
[:rename_constraint, [table_name, new_name, old_name, Hash.ruby2_keywords_hash(options)]]
end

def invert_change_constraint(args)
table, constraint, options = args
options ||= {}
inverted = options.to_h do |key, value|
[key, invert_change_constraint_option(key, value)]
end
[:change_constraint, [table, constraint, Hash.ruby2_keywords_hash(inverted)]]
end

# stock Rails carries :if_not_exists through to the inverse (remove_*) command
# unchanged, but the remove side only understands :if_exists (and vice versa);
# rewrite the existence option so the reverse migration stays idempotent
def invert_add_index(args)
change_option(args, from: :if_not_exists, to: :if_exists)
super
end

def invert_remove_index(args)
change_option(args, from: :if_exists, to: :if_not_exists)
super
end

def invert_add_column(args)
change_option(args, from: :if_not_exists, to: :if_exists)
super
end

def invert_remove_column(args)
change_option(args, from: :if_exists, to: :if_not_exists)
super
end

def invert_add_foreign_key(args)
change_option(args, from: :if_not_exists, to: :if_exists)
super
end

def invert_remove_foreign_key(args)
change_option(args, from: :if_exists, to: :if_not_exists)
super
end

def invert_add_reference(args)
change_reference_option(args, from: :if_not_exists, to: :if_exists)
super
end
alias_method :invert_add_belongs_to, :invert_add_reference

def invert_remove_reference(args)
change_reference_option(args, from: :if_exists, to: :if_not_exists)
super
end
alias_method :invert_remove_belongs_to, :invert_remove_reference

# renames an option on the trailing options hash in place, e.g. so an inverted
# command receives :if_exists where the original had :if_not_exists
def change_option(args, from:, to:)
options = args.last
return unless options.is_a?(Hash) && options.key?(from)

options[to] = options.delete(from)
end

# like change_option, but also rewrites the option nested inside a reference's
# `index:` hash (e.g. `add_reference(..., index: { if_not_exists: true })`)
def change_reference_option(args, from:, to:)
change_option(args, from:, to:)
options = args.last
change_option([options[:index]], from:, to:) if options.is_a?(Hash) && options[:index].is_a?(Hash)
end

def invert_change_constraint_option(key, value)
return value if value.nil?

case key
when :deferrable, :enforced, :inherit
return !value if [true, false].include?(value)

raise ArgumentError, "#{key} must be true or false"
when :initially
return :immediate if value == :deferred
return :deferred if value == :immediate

raise ArgumentError, "initially must be :deferred or :immediate"
else
raise ArgumentError, "unknown change_constraint option: #{key.inspect}"
end
end
end
end
end
125 changes: 116 additions & 9 deletions lib/active_record/pg_extensions/pessimistic_migrations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,20 +89,127 @@ def add_index(table_name, column_name, **options)
super
end

def add_check_constraint(table_name, expression, if_not_exists: false, **options)
options = check_constraint_options(table_name, expression, options)
return if if_not_exists && check_constraint_for(table_name, **options)
# Replace one check constraint with another
#
# @param table [Symbol] The table name
# @param from [String, Hash] The check constraint to be replaced.
# If a Hash is provided, it must contain an :expression key with the check constraint expression.
# Other options are merged with `options` and passed through.
# @param to [String, Hash] The new check constraint to be added.
# If a Hash is provided, it must contain an :expression key with the check constraint expression.
# Other options are merged with `options` and passed through.
# @param if_exists [true, false] If true, the entire operation should be idempotent (only
# dropping/renaming the old constraint if it exists, only adding the new constraint if it doesn't exist.)
# @param delay_validation [true, false] If true, the new check constraint will be added as NOT VALID and
# validated before removing the old check constraint.
def change_check_constraint(table, from:, to:, if_exists: false, delay_validation: false, **options)
delay_validation = false if open_transactions.positive?
options[:validate] = false if delay_validation

super
from_expression, from_options = split_arg(from, :expression, options)
to_expression, to_options = split_arg(to, :expression, options)

new_constraint_name = check_constraint_name(table, expression: to_expression, **to_options)
# when delaying validation, we don't drop the old constraint until after the new one is validated,
# so we need to rename it if the names are the same
if delay_validation
old_constraint_name = check_constraint_name(table, expression: from_expression, **from_options)
if old_constraint_name == new_constraint_name
new_old_constraint_name = temporary_name(old_constraint_name)
unless check_constraint_exists?(table, name: new_old_constraint_name)
rename_constraint(table, old_constraint_name, new_old_constraint_name, if_exists:)
end
old_constraint_name = new_old_constraint_name
end
else
remove_check_constraint(table, from_expression, if_exists:, **from_options)
end

add_check_constraint(table,
to_expression,
**to_options,
if_not_exists: if_exists || delay_validation,
validate: !delay_validation)

return unless delay_validation

validate_constraint(table, new_constraint_name)
remove_check_constraint(table, name: old_constraint_name, if_exists: true)
end

if ActiveRecord.version < Gem::Version.new("7.1")
def remove_check_constraint(table_name, expression = nil, if_exists: false, **options)
options = check_constraint_options(table_name, expression, options)
return if if_exists && !check_constraint_for(table_name, **options)
# Replace one index with another
#
# @param table [Symbol] The table name
# @param from [String, Symbol, Array, Hash] The index to be replaced.
# If a Hash is provided, it must contain a :column key with the indexed column(s).
# Other options are merged with `options` and passed through.
# @param to [String, Symbol, Array, Hash] The new index to be added.
# If a Hash is provided, it must contain a :column key with the indexed column(s).
# Other options are merged with `options` and passed through.
# @param if_exists [true, false] If true, the entire operation should be idempotent (only
# dropping/renaming the old index if it exists, only adding the new index if it doesn't exist.)
# @param algorithm [Symbol, nil] If :concurrently, the new index is created concurrently and the
# old index is only removed after the new one exists.
def change_index(table, from:, to:, if_exists: false, algorithm: nil, **options)
algorithm = nil if open_transactions.positive?
concurrently = algorithm == :concurrently

super
from_column, from_options = split_arg(from, :column, options)
to_column, to_options = split_arg(to, :column, options)

new_index_name = to_options[:name]&.to_s || index_name(table, column: to_column)
# when creating concurrently, we don't drop the old index until after the new one is created,
# so we need to rename it if the names are the same
if concurrently
old_index_name = from_options[:name]&.to_s || index_name(table, column: from_column)
if old_index_name == new_index_name
new_old_index_name = temporary_name(old_index_name)
# rename_index has no if_exists option, so guard it: skip if the old index is missing
# (only relevant when if_exists makes the whole operation idempotent) or the temp name is taken
old_missing = if_exists && !index_name_exists?(table, old_index_name)
unless old_missing || index_name_exists?(table, new_old_index_name)
rename_index(table, old_index_name, new_old_index_name)
end
old_index_name = new_old_index_name
end
else
remove_index(table, from_column, if_exists:, **from_options)
end

add_index(table,
to_column,
**to_options,
if_not_exists: if_exists || concurrently,
algorithm:)

return unless concurrently

remove_index(table, name: old_index_name, if_exists: true, algorithm: :concurrently)
end

private

# splits a from/to argument into its subject and options. If a Hash is given, the given key
# (e.g. :column or :expression) is extracted and the rest is merged with options; otherwise
# the argument is treated as the subject itself.
def split_arg(arg, key, options)
return [arg, options] unless arg.is_a?(Hash)

arg_options = arg.merge(options)
[arg_options.delete(key), arg_options]
end

# derives a temporary name from +name+ to rename an existing index/constraint out of the way.
# Prefers a readable "<name>_to_be_replaced", but falls back to a hashed (still deterministic,
# still unique to +name+) form when that would exceed the database's identifier length limit,
# following the same scheme Rails uses for long index names.
def temporary_name(name)
suffixed = "#{name}_to_be_replaced"
return suffixed if suffixed.bytesize <= max_identifier_length

hashed_identifier = "_#{OpenSSL::Digest::SHA256.hexdigest(name.to_s).first(10)}"
short_name = name.to_s.truncate_bytes(max_identifier_length - hashed_identifier.bytesize, omission: nil)
"#{short_name}#{hashed_identifier}"
end
end
end
Expand Down
24 changes: 24 additions & 0 deletions lib/active_record/pg_extensions/postgresql_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@ def rename_constraint(table_name, old_name, new_name, if_exists: false)
SQL
end

# alters the attributes of an existing constraint on a table
# see https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-DESC-ALTER-CONSTRAINT
def change_constraint(table, constraint, deferrable: nil, enforced: nil, initially: nil, inherit: nil)
if deferrable.nil? && initially.nil? && enforced.nil? && inherit.nil?
raise ArgumentError, "at least one of :deferrable, :initially, :enforced, or :inherit must be specified"
end

options = []
options << (deferrable ? "DEFERRABLE" : "NOT DEFERRABLE") unless deferrable.nil?
unless initially.nil?
case initially
when :deferred then options << "INITIALLY DEFERRED"
when :immediate then options << "INITIALLY IMMEDIATE"
else raise ArgumentError, "initially must be :deferred or :immediate"
end
end
options << (enforced ? "ENFORCED" : "NOT ENFORCED") unless enforced.nil?

alter_table = "ALTER TABLE #{quote_table_name(table)} ALTER CONSTRAINT #{quote_column_name(constraint)}"
execute("#{alter_table} #{options.join(" ")}") unless options.empty?
# INHERIT/NO INHERIT cannot be combined with other options
execute("#{alter_table} #{inherit ? "INHERIT" : "NO INHERIT"}") unless inherit.nil?
end

# see https://www.postgresql.org/docs/current/sql-altertable.html#SQL-CREATETABLE-REPLICA-IDENTITY
def set_replica_identity(table, identity = :default)
identity_clause = case identity
Expand Down
2 changes: 1 addition & 1 deletion lib/active_record/pg_extensions/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Railtie < Rails::Railtie
require "active_record/pg_extensions/transaction"

::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(PostgreSQLAdapter)
::ActiveRecord::Migration::CommandRecorder.include(CommandRecorder)
::ActiveRecord::Migration::CommandRecorder.prepend(CommandRecorder)
::ActiveRecord::ConnectionAdapters::NullTransaction.prepend(NullTransaction)
::ActiveRecord::ConnectionAdapters::Transaction.prepend(Transaction)
# if they've already require 'all', then inject now
Expand Down
2 changes: 1 addition & 1 deletion lib/active_record/pg_extensions/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module ActiveRecord
module PGExtensions
VERSION = "0.7.1"
VERSION = "0.8.0"
end
end
Loading
Loading