activerecord-pg-extensions 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d50633abe33747fede3503dad765972ffd7abd47f6aa3da2e955981820aff2ee
4
- data.tar.gz: ea51e816b3bb0c969e5ee2a6626206e9bd8b429ad4589caac620eaa3ca7d31b6
3
+ metadata.gz: 3b9b21a9400229a1c720957b77284901a9be4a0628fa0b9caa031fc30fe5125a
4
+ data.tar.gz: b256aa3c4299d2d5ecce29956b313e6199d94567160308f86598e6ec90645399
5
5
  SHA512:
6
- metadata.gz: fa97981e3721d0cc3d90697254cf89cd21666976ddeacc2e774112d3d02d0f74eaaf30fef33532965b2d8243e6f2ca2806f548d404987677cd02ba7192dbcb9f
7
- data.tar.gz: 93243bece3aceba1d70646dc7a34b5b8902d6673029b31788aee0beb836b00573cc50c142501ccd4842097276ddfb217eea3ce0d9209ea4eedc48cbecba73654
6
+ metadata.gz: 4cdc0365c9ac099cf176f6fe58278f8798f0d37b8035efa5df0e00988ca2ba198c5991050a4bfdc89e89dd24bdfdff1b875b10109a41b98a656e7492287df264
7
+ data.tar.gz: 1a5145a392d36fa89bf904346e237380befe419d6702941d73a7c2ab454da99ced1ecc5fc78f0dcc152a15147316bbfadb730d37586f3364ac26df927efb018d
@@ -8,6 +8,36 @@ module ActiveRecord
8
8
  record(:rename_constraint, [table_name, old_name, new_name, options])
9
9
  end
10
10
 
11
+ def change_constraint(table, constraint, **options)
12
+ record(:change_constraint, [table, constraint, options])
13
+ end
14
+
15
+ def change_check_constraint(table, **options)
16
+ record(:change_check_constraint, [table, options])
17
+ end
18
+
19
+ def change_index(table, **options)
20
+ record(:change_index, [table, options])
21
+ end
22
+
23
+ private
24
+
25
+ def invert_change_index(args)
26
+ table, options = args
27
+ options ||= {}
28
+ # reverse the change by swapping :from and :to, leaving everything else intact
29
+ inverted = options.merge(from: options[:to], to: options[:from])
30
+ [:change_index, [table, Hash.ruby2_keywords_hash(inverted)]]
31
+ end
32
+
33
+ def invert_change_check_constraint(args)
34
+ table, options = args
35
+ options ||= {}
36
+ # reverse the change by swapping :from and :to, leaving everything else intact
37
+ inverted = options.merge(from: options[:to], to: options[:from])
38
+ [:change_check_constraint, [table, Hash.ruby2_keywords_hash(inverted)]]
39
+ end
40
+
11
41
  def invert_rename_constraint(args)
12
42
  table_name, old_name, new_name, options = args
13
43
  options ||= {}
@@ -15,6 +45,95 @@ module ActiveRecord
15
45
  # `rename_constraint(..., if_exists:)` rather than a positional hash
16
46
  [:rename_constraint, [table_name, new_name, old_name, Hash.ruby2_keywords_hash(options)]]
17
47
  end
48
+
49
+ def invert_change_constraint(args)
50
+ table, constraint, options = args
51
+ options ||= {}
52
+ inverted = options.to_h do |key, value|
53
+ [key, invert_change_constraint_option(key, value)]
54
+ end
55
+ [:change_constraint, [table, constraint, Hash.ruby2_keywords_hash(inverted)]]
56
+ end
57
+
58
+ # stock Rails carries :if_not_exists through to the inverse (remove_*) command
59
+ # unchanged, but the remove side only understands :if_exists (and vice versa);
60
+ # rewrite the existence option so the reverse migration stays idempotent
61
+ def invert_add_index(args)
62
+ change_option(args, from: :if_not_exists, to: :if_exists)
63
+ super
64
+ end
65
+
66
+ def invert_remove_index(args)
67
+ change_option(args, from: :if_exists, to: :if_not_exists)
68
+ super
69
+ end
70
+
71
+ def invert_add_column(args)
72
+ change_option(args, from: :if_not_exists, to: :if_exists)
73
+ super
74
+ end
75
+
76
+ def invert_remove_column(args)
77
+ change_option(args, from: :if_exists, to: :if_not_exists)
78
+ super
79
+ end
80
+
81
+ def invert_add_foreign_key(args)
82
+ change_option(args, from: :if_not_exists, to: :if_exists)
83
+ super
84
+ end
85
+
86
+ def invert_remove_foreign_key(args)
87
+ change_option(args, from: :if_exists, to: :if_not_exists)
88
+ super
89
+ end
90
+
91
+ def invert_add_reference(args)
92
+ change_reference_option(args, from: :if_not_exists, to: :if_exists)
93
+ super
94
+ end
95
+ alias_method :invert_add_belongs_to, :invert_add_reference
96
+
97
+ def invert_remove_reference(args)
98
+ change_reference_option(args, from: :if_exists, to: :if_not_exists)
99
+ super
100
+ end
101
+ alias_method :invert_remove_belongs_to, :invert_remove_reference
102
+
103
+ # renames an option on the trailing options hash in place, e.g. so an inverted
104
+ # command receives :if_exists where the original had :if_not_exists
105
+ def change_option(args, from:, to:)
106
+ options = args.last
107
+ return unless options.is_a?(Hash) && options.key?(from)
108
+
109
+ options[to] = options.delete(from)
110
+ end
111
+
112
+ # like change_option, but also rewrites the option nested inside a reference's
113
+ # `index:` hash (e.g. `add_reference(..., index: { if_not_exists: true })`)
114
+ def change_reference_option(args, from:, to:)
115
+ change_option(args, from:, to:)
116
+ options = args.last
117
+ change_option([options[:index]], from:, to:) if options.is_a?(Hash) && options[:index].is_a?(Hash)
118
+ end
119
+
120
+ def invert_change_constraint_option(key, value)
121
+ return value if value.nil?
122
+
123
+ case key
124
+ when :deferrable, :enforced, :inherit
125
+ return !value if [true, false].include?(value)
126
+
127
+ raise ArgumentError, "#{key} must be true or false"
128
+ when :initially
129
+ return :immediate if value == :deferred
130
+ return :deferred if value == :immediate
131
+
132
+ raise ArgumentError, "initially must be :deferred or :immediate"
133
+ else
134
+ raise ArgumentError, "unknown change_constraint option: #{key.inspect}"
135
+ end
136
+ end
18
137
  end
19
138
  end
20
139
  end
@@ -89,20 +89,127 @@ module ActiveRecord
89
89
  super
90
90
  end
91
91
 
92
- def add_check_constraint(table_name, expression, if_not_exists: false, **options)
93
- options = check_constraint_options(table_name, expression, options)
94
- return if if_not_exists && check_constraint_for(table_name, **options)
92
+ # Replace one check constraint with another
93
+ #
94
+ # @param table [Symbol] The table name
95
+ # @param from [String, Hash] The check constraint to be replaced.
96
+ # If a Hash is provided, it must contain an :expression key with the check constraint expression.
97
+ # Other options are merged with `options` and passed through.
98
+ # @param to [String, Hash] The new check constraint to be added.
99
+ # If a Hash is provided, it must contain an :expression key with the check constraint expression.
100
+ # Other options are merged with `options` and passed through.
101
+ # @param if_exists [true, false] If true, the entire operation should be idempotent (only
102
+ # dropping/renaming the old constraint if it exists, only adding the new constraint if it doesn't exist.)
103
+ # @param delay_validation [true, false] If true, the new check constraint will be added as NOT VALID and
104
+ # validated before removing the old check constraint.
105
+ def change_check_constraint(table, from:, to:, if_exists: false, delay_validation: false, **options)
106
+ delay_validation = false if open_transactions.positive?
107
+ options[:validate] = false if delay_validation
95
108
 
96
- super
109
+ from_expression, from_options = split_arg(from, :expression, options)
110
+ to_expression, to_options = split_arg(to, :expression, options)
111
+
112
+ new_constraint_name = check_constraint_name(table, expression: to_expression, **to_options)
113
+ # when delaying validation, we don't drop the old constraint until after the new one is validated,
114
+ # so we need to rename it if the names are the same
115
+ if delay_validation
116
+ old_constraint_name = check_constraint_name(table, expression: from_expression, **from_options)
117
+ if old_constraint_name == new_constraint_name
118
+ new_old_constraint_name = temporary_name(old_constraint_name)
119
+ unless check_constraint_exists?(table, name: new_old_constraint_name)
120
+ rename_constraint(table, old_constraint_name, new_old_constraint_name, if_exists:)
121
+ end
122
+ old_constraint_name = new_old_constraint_name
123
+ end
124
+ else
125
+ remove_check_constraint(table, from_expression, if_exists:, **from_options)
126
+ end
127
+
128
+ add_check_constraint(table,
129
+ to_expression,
130
+ **to_options,
131
+ if_not_exists: if_exists || delay_validation,
132
+ validate: !delay_validation)
133
+
134
+ return unless delay_validation
135
+
136
+ validate_constraint(table, new_constraint_name)
137
+ remove_check_constraint(table, name: old_constraint_name, if_exists: true)
97
138
  end
98
139
 
99
- if ActiveRecord.version < Gem::Version.new("7.1")
100
- def remove_check_constraint(table_name, expression = nil, if_exists: false, **options)
101
- options = check_constraint_options(table_name, expression, options)
102
- return if if_exists && !check_constraint_for(table_name, **options)
140
+ # Replace one index with another
141
+ #
142
+ # @param table [Symbol] The table name
143
+ # @param from [String, Symbol, Array, Hash] The index to be replaced.
144
+ # If a Hash is provided, it must contain a :column key with the indexed column(s).
145
+ # Other options are merged with `options` and passed through.
146
+ # @param to [String, Symbol, Array, Hash] The new index to be added.
147
+ # If a Hash is provided, it must contain a :column key with the indexed column(s).
148
+ # Other options are merged with `options` and passed through.
149
+ # @param if_exists [true, false] If true, the entire operation should be idempotent (only
150
+ # dropping/renaming the old index if it exists, only adding the new index if it doesn't exist.)
151
+ # @param algorithm [Symbol, nil] If :concurrently, the new index is created concurrently and the
152
+ # old index is only removed after the new one exists.
153
+ def change_index(table, from:, to:, if_exists: false, algorithm: nil, **options)
154
+ algorithm = nil if open_transactions.positive?
155
+ concurrently = algorithm == :concurrently
103
156
 
104
- super
157
+ from_column, from_options = split_arg(from, :column, options)
158
+ to_column, to_options = split_arg(to, :column, options)
159
+
160
+ new_index_name = to_options[:name]&.to_s || index_name(table, column: to_column)
161
+ # when creating concurrently, we don't drop the old index until after the new one is created,
162
+ # so we need to rename it if the names are the same
163
+ if concurrently
164
+ old_index_name = from_options[:name]&.to_s || index_name(table, column: from_column)
165
+ if old_index_name == new_index_name
166
+ new_old_index_name = temporary_name(old_index_name)
167
+ # rename_index has no if_exists option, so guard it: skip if the old index is missing
168
+ # (only relevant when if_exists makes the whole operation idempotent) or the temp name is taken
169
+ old_missing = if_exists && !index_name_exists?(table, old_index_name)
170
+ unless old_missing || index_name_exists?(table, new_old_index_name)
171
+ rename_index(table, old_index_name, new_old_index_name)
172
+ end
173
+ old_index_name = new_old_index_name
174
+ end
175
+ else
176
+ remove_index(table, from_column, if_exists:, **from_options)
105
177
  end
178
+
179
+ add_index(table,
180
+ to_column,
181
+ **to_options,
182
+ if_not_exists: if_exists || concurrently,
183
+ algorithm:)
184
+
185
+ return unless concurrently
186
+
187
+ remove_index(table, name: old_index_name, if_exists: true, algorithm: :concurrently)
188
+ end
189
+
190
+ private
191
+
192
+ # splits a from/to argument into its subject and options. If a Hash is given, the given key
193
+ # (e.g. :column or :expression) is extracted and the rest is merged with options; otherwise
194
+ # the argument is treated as the subject itself.
195
+ def split_arg(arg, key, options)
196
+ return [arg, options] unless arg.is_a?(Hash)
197
+
198
+ arg_options = arg.merge(options)
199
+ [arg_options.delete(key), arg_options]
200
+ end
201
+
202
+ # derives a temporary name from +name+ to rename an existing index/constraint out of the way.
203
+ # Prefers a readable "<name>_to_be_replaced", but falls back to a hashed (still deterministic,
204
+ # still unique to +name+) form when that would exceed the database's identifier length limit,
205
+ # following the same scheme Rails uses for long index names.
206
+ def temporary_name(name)
207
+ suffixed = "#{name}_to_be_replaced"
208
+ return suffixed if suffixed.bytesize <= max_identifier_length
209
+
210
+ hashed_identifier = "_#{OpenSSL::Digest::SHA256.hexdigest(name.to_s).first(10)}"
211
+ short_name = name.to_s.truncate_bytes(max_identifier_length - hashed_identifier.bytesize, omission: nil)
212
+ "#{short_name}#{hashed_identifier}"
106
213
  end
107
214
  end
108
215
  end
@@ -38,6 +38,30 @@ module ActiveRecord
38
38
  SQL
39
39
  end
40
40
 
41
+ # alters the attributes of an existing constraint on a table
42
+ # see https://www.postgresql.org/docs/current/sql-altertable.html#SQL-ALTERTABLE-DESC-ALTER-CONSTRAINT
43
+ def change_constraint(table, constraint, deferrable: nil, enforced: nil, initially: nil, inherit: nil)
44
+ if deferrable.nil? && initially.nil? && enforced.nil? && inherit.nil?
45
+ raise ArgumentError, "at least one of :deferrable, :initially, :enforced, or :inherit must be specified"
46
+ end
47
+
48
+ options = []
49
+ options << (deferrable ? "DEFERRABLE" : "NOT DEFERRABLE") unless deferrable.nil?
50
+ unless initially.nil?
51
+ case initially
52
+ when :deferred then options << "INITIALLY DEFERRED"
53
+ when :immediate then options << "INITIALLY IMMEDIATE"
54
+ else raise ArgumentError, "initially must be :deferred or :immediate"
55
+ end
56
+ end
57
+ options << (enforced ? "ENFORCED" : "NOT ENFORCED") unless enforced.nil?
58
+
59
+ alter_table = "ALTER TABLE #{quote_table_name(table)} ALTER CONSTRAINT #{quote_column_name(constraint)}"
60
+ execute("#{alter_table} #{options.join(" ")}") unless options.empty?
61
+ # INHERIT/NO INHERIT cannot be combined with other options
62
+ execute("#{alter_table} #{inherit ? "INHERIT" : "NO INHERIT"}") unless inherit.nil?
63
+ end
64
+
41
65
  # see https://www.postgresql.org/docs/current/sql-altertable.html#SQL-CREATETABLE-REPLICA-IDENTITY
42
66
  def set_replica_identity(table, identity = :default)
43
67
  identity_clause = case identity
@@ -14,7 +14,7 @@ module ActiveRecord
14
14
  require "active_record/pg_extensions/transaction"
15
15
 
16
16
  ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(PostgreSQLAdapter)
17
- ::ActiveRecord::Migration::CommandRecorder.include(CommandRecorder)
17
+ ::ActiveRecord::Migration::CommandRecorder.prepend(CommandRecorder)
18
18
  ::ActiveRecord::ConnectionAdapters::NullTransaction.prepend(NullTransaction)
19
19
  ::ActiveRecord::ConnectionAdapters::Transaction.prepend(Transaction)
20
20
  # if they've already require 'all', then inject now
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module PGExtensions
5
- VERSION = "0.7.1"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-pg-extensions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cody Cutrer