Skip to content

Commit ec7ea92

Browse files
committed
wip
1 parent 303eff1 commit ec7ea92

File tree

3 files changed

+169
-76
lines changed

3 files changed

+169
-76
lines changed

Diff for: lib/pg_ha_migrations/safe_statements.rb

+8-36
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ def safe_added_columns_without_default_value
33
@safe_added_columns_without_default_value ||= []
44
end
55

6-
def safe_create_table(table, options={}, &block)
6+
def safe_create_table(table, **options, &block)
77
if options[:force]
88
raise PgHaMigrations::UnsafeMigrationError.new(":force is NOT SAFE! Explicitly call unsafe_drop_table first if you want to recreate an existing table")
99
end
1010

11-
unsafe_create_table(table, options, &block)
11+
unsafe_create_table(table, **options, &block)
1212
end
1313

1414
def safe_create_enum_type(name, values=nil)
@@ -29,15 +29,7 @@ def safe_add_enum_value(name, value)
2929
raw_execute("ALTER TYPE #{PG::Connection.quote_ident(name.to_s)} ADD VALUE '#{PG::Connection.escape_string(value)}'")
3030
end
3131

32-
def unsafe_rename_enum_value(name, old_value, new_value)
33-
if ActiveRecord::Base.connection.postgresql_version < 10_00_00
34-
raise PgHaMigrations::InvalidMigrationError, "Renaming an enum value is not supported on Postgres databases before version 10"
35-
end
36-
37-
raw_execute("ALTER TYPE #{PG::Connection.quote_ident(name.to_s)} RENAME VALUE '#{PG::Connection.escape_string(old_value)}' TO '#{PG::Connection.escape_string(new_value)}'")
38-
end
39-
40-
def safe_add_column(table, column, type, options = {})
32+
def safe_add_column(table, column, type, **options)
4133
# Note: we don't believe we need to consider the odd case where
4234
# `:default => nil` or `:default => -> { null }` (or similar) is
4335
# passed because:
@@ -60,13 +52,7 @@ def safe_add_column(table, column, type, options = {})
6052
self.safe_added_columns_without_default_value << [table.to_s, column.to_s]
6153
end
6254

63-
unsafe_add_column(table, column, type, options)
64-
end
65-
66-
def unsafe_add_column(table, column, type, options = {})
67-
safely_acquire_lock_for_table(table) do
68-
super(table, column, type, **options)
69-
end
55+
unsafe_add_column(table, column, type, **options)
7056
end
7157

7258
def safe_change_column_default(table_name, column_name, default_value)
@@ -177,7 +163,7 @@ def unsafe_make_column_not_nullable(table, column, options={}) # options arg is
177163
end
178164
end
179165

180-
def safe_add_index_on_empty_table(table, columns, options={})
166+
def safe_add_index_on_empty_table(table, columns, **options)
181167
if options[:algorithm] == :concurrently
182168
raise ArgumentError, "Cannot call safe_add_index_on_empty_table with :algorithm => :concurrently"
183169
end
@@ -193,11 +179,11 @@ def safe_add_index_on_empty_table(table, columns, options={})
193179
end
194180
end
195181

196-
def safe_add_concurrent_index(table, columns, options={})
182+
def safe_add_concurrent_index(table, columns, **options)
197183
unsafe_add_index(table, columns, **options.merge(:algorithm => :concurrently))
198184
end
199185

200-
def safe_remove_concurrent_index(table, options={})
186+
def safe_remove_concurrent_index(table, **options)
201187
unless options.is_a?(Hash) && options.key?(:name)
202188
raise ArgumentError, "Expected safe_remove_concurrent_index to be called with arguments (table_name, :name => ...)"
203189
end
@@ -329,20 +315,6 @@ def safe_rename_constraint(table, from:, to:)
329315
end
330316
end
331317

332-
def unsafe_remove_constraint(table, name:)
333-
raise ArgumentError, "Expected <name> to be present" unless name.present?
334-
335-
quoted_table_name = connection.quote_table_name(table)
336-
quoted_constraint_name = connection.quote_table_name(name)
337-
sql = "ALTER TABLE #{quoted_table_name} DROP CONSTRAINT #{quoted_constraint_name}"
338-
339-
safely_acquire_lock_for_table(table) do
340-
say_with_time "remove_constraint(#{table.inspect}, name: #{name.inspect})" do
341-
connection.execute(sql)
342-
end
343-
end
344-
end
345-
346318
def safe_create_partitioned_table(table, partition_key:, type:, infer_primary_key: nil, **options, &block)
347319
raise ArgumentError, "Expected <partition_key> to be present" unless partition_key.present?
348320

@@ -384,7 +356,7 @@ def safe_create_partitioned_table(table, partition_key:, type:, infer_primary_ke
384356

385357
options[:options] = "PARTITION BY #{type.upcase} (#{quoted_partition_key})"
386358

387-
safe_create_table(table, options) do |td|
359+
safe_create_table(table, **options) do |td|
388360
block.call(td) if block
389361

390362
next unless options[:id]

Diff for: lib/pg_ha_migrations/unsafe_statements.rb

+104-17
Original file line numberDiff line numberDiff line change
@@ -29,30 +29,47 @@ def self.delegate_unsafe_method_to_migration_base_class(method_name)
2929
ruby2_keywords "unsafe_#{method_name}"
3030
end
3131

32+
def self.delegate_unsafe_method_to_migration_base_class_with_lock(method_name, mode: :access_exclusive)
33+
define_method("unsafe_#{method_name}") do |*args, &block|
34+
if PgHaMigrations.config.check_for_dependent_objects
35+
disallow_migration_method_if_dependent_objects!(method_name, arguments: args)
36+
end
37+
38+
safely_acquire_lock_for_table(args.first, mode: mode) do
39+
execute_ancestor_statement(method_name, *args, &block)
40+
end
41+
end
42+
ruby2_keywords "unsafe_#{method_name}"
43+
end
44+
3245
def self.delegate_raw_method_to_migration_base_class(method_name)
3346
define_method("raw_#{method_name}") do |*args, &block|
3447
execute_ancestor_statement(method_name, *args, &block)
3548
end
3649
ruby2_keywords "raw_#{method_name}"
3750
end
3851

39-
# Direct dispatch to underlying Rails method as unsafe_<method_name> with dependent object check
40-
delegate_unsafe_method_to_migration_base_class :add_check_constraint
41-
delegate_unsafe_method_to_migration_base_class :add_column
42-
delegate_unsafe_method_to_migration_base_class :add_foreign_key
43-
delegate_unsafe_method_to_migration_base_class :change_column
44-
delegate_unsafe_method_to_migration_base_class :change_column_default
45-
delegate_unsafe_method_to_migration_base_class :change_table
52+
# Direct dispatch to underlying Rails method as unsafe_<method_name> with dependent object check / safe lock acquisition
53+
delegate_unsafe_method_to_migration_base_class_with_lock :add_check_constraint
54+
delegate_unsafe_method_to_migration_base_class_with_lock :add_column
55+
delegate_unsafe_method_to_migration_base_class_with_lock :add_foreign_key, mode: :share_row_exclusive # TODO: lock referenced table as well
56+
delegate_unsafe_method_to_migration_base_class_with_lock :change_column
57+
delegate_unsafe_method_to_migration_base_class_with_lock :change_column_default
58+
delegate_unsafe_method_to_migration_base_class_with_lock :remove_check_constraint
59+
delegate_unsafe_method_to_migration_base_class_with_lock :remove_column
60+
delegate_unsafe_method_to_migration_base_class_with_lock :rename_column
61+
delegate_unsafe_method_to_migration_base_class_with_lock :remove_foreign_key # TODO: lock referenced table as well
62+
63+
# Direct dispatch to underlying Rails method as unsafe_<method_name> with dependent object check / no locking
64+
#
65+
# We don't do locking on execute because it's so generic.
66+
# For drop_table and rename_table, it's implied that the table is unused.
67+
# If the table is still being used... that's a bigger problem that locking won't solve.
4668
delegate_unsafe_method_to_migration_base_class :drop_table
4769
delegate_unsafe_method_to_migration_base_class :execute
48-
delegate_unsafe_method_to_migration_base_class :remove_check_constraint
49-
delegate_unsafe_method_to_migration_base_class :remove_column
50-
delegate_unsafe_method_to_migration_base_class :remove_foreign_key
51-
delegate_unsafe_method_to_migration_base_class :remove_index
52-
delegate_unsafe_method_to_migration_base_class :rename_column
5370
delegate_unsafe_method_to_migration_base_class :rename_table
5471

55-
# Direct dispatch to underlying Rails method as raw_<method_name> without dependent object check
72+
# Direct dispatch to underlying Rails method as raw_<method_name> without dependent object check / locking
5673
delegate_raw_method_to_migration_base_class :add_check_constraint
5774
delegate_raw_method_to_migration_base_class :add_column
5875
delegate_raw_method_to_migration_base_class :add_foreign_key
@@ -72,7 +89,7 @@ def self.delegate_raw_method_to_migration_base_class(method_name)
7289
delegate_raw_method_to_migration_base_class :rename_table
7390

7491
# Raises error if disable_default_migration_methods is true
75-
# Otherwise, direct dispatch to underlying Rails method without dependent object check
92+
# Otherwise, direct dispatch to underlying Rails method without dependent object check / locking
7693
disable_or_delegate_default_method :add_check_constraint, ":add_check_constraint is NOT SAFE! Use :safe_add_unvalidated_check_constraint and then :safe_validate_check_constraint instead"
7794
disable_or_delegate_default_method :add_column, ":add_column is NOT SAFE! Use safe_add_column instead"
7895
disable_or_delegate_default_method :add_foreign_key, ":add_foreign_key is NOT SAFE! Explicitly call :unsafe_add_foreign_key"
@@ -91,15 +108,21 @@ def self.delegate_raw_method_to_migration_base_class(method_name)
91108
disable_or_delegate_default_method :rename_column, ":rename_column is NOT SAFE! Explicitly call :unsafe_rename_column to proceed"
92109
disable_or_delegate_default_method :rename_table, ":rename_table is NOT SAFE! Explicitly call :unsafe_rename_table to proceed"
93110

94-
def unsafe_create_table(table, options={}, &block)
111+
# Note that unsafe_* methods defined below do not run dependent object checks
112+
113+
def unsafe_change_table(*args, &block)
114+
raise PgHaMigrations::UnsafeMigrationError.new(":change_table is too generic to even allow an unsafe variant. Instead use explicit methods for adding / removing / modifying columns.")
115+
end
116+
117+
def unsafe_create_table(table, **options, &block)
95118
if options[:force] && !PgHaMigrations.config.allow_force_create_table
96119
raise PgHaMigrations::UnsafeMigrationError.new(":force is NOT SAFE! Explicitly call unsafe_drop_table first if you want to recreate an existing table")
97120
end
98121

99122
execute_ancestor_statement(:create_table, table, **options, &block)
100123
end
101124

102-
def unsafe_add_index(table, column_names, options = {})
125+
def unsafe_add_index(table, column_names, **options)
103126
if ((ActiveRecord::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 2) || ActiveRecord::VERSION::MAJOR > 5) &&
104127
column_names.is_a?(String) && /\W/.match?(column_names) && options.key?(:opclass)
105128
raise PgHaMigrations::InvalidMigrationError, "ActiveRecord drops the :opclass option when supplying a string containing an expression or list of columns; instead either supply an array of columns or include the opclass in the string for each column"
@@ -115,7 +138,71 @@ def unsafe_add_index(table, column_names, options = {})
115138

116139
options[:name] = validated_index.name
117140

118-
execute_ancestor_statement(:add_index, table, column_names, **options)
141+
if options[:algorithm] == :concurrently
142+
execute_ancestor_statement(:add_index, table, column_names, **options)
143+
else
144+
safely_acquire_lock_for_table(table, mode: :share) do
145+
execute_ancestor_statement(:add_index, table, column_names, **options)
146+
end
147+
end
148+
end
149+
150+
def unsafe_remove_index(table, column = nil, **options)
151+
if options[:algorithm]== :concurrently
152+
execute_ancestor_statement(:remove_index, table, column, **options)
153+
else
154+
safely_acquire_lock_for_table(table) do
155+
execute_ancestor_statement(:remove_index, table, column, **options)
156+
end
157+
end
158+
end
159+
160+
def unsafe_rename_enum_value(name, old_value, new_value)
161+
if ActiveRecord::Base.connection.postgresql_version < 10_00_00
162+
raise PgHaMigrations::InvalidMigrationError, "Renaming an enum value is not supported on Postgres databases before version 10"
163+
end
164+
165+
raw_execute("ALTER TYPE #{PG::Connection.quote_ident(name.to_s)} RENAME VALUE '#{PG::Connection.escape_string(old_value)}' TO '#{PG::Connection.escape_string(new_value)}'")
166+
end
167+
168+
def unsafe_make_column_not_nullable(table, column, **options) # options arg is only present for backwards compatiblity
169+
safely_acquire_lock_for_table(table) do
170+
raw_execute("ALTER TABLE #{table} ALTER COLUMN #{column} SET NOT NULL") # TODO: quoting
171+
end
172+
end
173+
174+
def unsafe_remove_constraint(table, name:)
175+
raise ArgumentError, "Expected <name> to be present" unless name.present?
176+
177+
quoted_table_name = connection.quote_table_name(table)
178+
quoted_constraint_name = connection.quote_table_name(name)
179+
sql = "ALTER TABLE #{quoted_table_name} DROP CONSTRAINT #{quoted_constraint_name}"
180+
181+
safely_acquire_lock_for_table(table) do
182+
say_with_time "remove_constraint(#{table.inspect}, name: #{name.inspect})" do
183+
connection.execute(sql)
184+
end
185+
end
186+
end
187+
188+
def unsafe_partman_update_config(table, **options)
189+
invalid_options = options.keys - PgHaMigrations::PARTMAN_UPDATE_CONFIG_OPTIONS
190+
191+
raise ArgumentError, "Unrecognized argument(s): #{invalid_options}" unless invalid_options.empty?
192+
193+
PgHaMigrations::PartmanConfig.schema = _quoted_partman_schema
194+
195+
config = PgHaMigrations::PartmanConfig.find(_fully_qualified_table_name_for_partman(table))
196+
197+
config.assign_attributes(**options)
198+
199+
inherit_privileges_changed = config.inherit_privileges_changed?
200+
201+
say_with_time "partman_update_config(#{table.inspect}, #{options.map { |k,v| "#{k}: #{v.inspect}" }.join(", ")})" do
202+
config.save!
203+
end
204+
205+
safe_partman_reapply_privileges(table) if inherit_privileges_changed
119206
end
120207

121208
def unsafe_partman_update_config(table, **options)

Diff for: spec/safe_statements_spec.rb

+57-23
Original file line numberDiff line numberDiff line change
@@ -857,7 +857,7 @@ def up
857857
it "calls safely_acquire_lock_for_table" do
858858
migration = Class.new(migration_klass).new
859859

860-
expect(migration).to receive(:safely_acquire_lock_for_table).with(:foos)
860+
expect(migration).to receive(:safely_acquire_lock_for_table).with(:foos, mode: :access_exclusive)
861861
migration.unsafe_add_column(:foos, :bar, :text)
862862
end
863863
end
@@ -1624,7 +1624,7 @@ def up
16241624

16251625
expect do
16261626
test_migration.suppress_messages { test_migration.migrate(:up) }
1627-
end.to make_database_queries(matching: /LOCK "public"\."foos" IN SHARE MODE/, count: 1)
1627+
end.to make_database_queries(matching: /LOCK "public"\."foos" IN SHARE MODE/, count: 2)
16281628
.and(make_database_queries(matching: /CREATE INDEX "index_foos_on_bar"/, count: 1))
16291629

16301630
indexes = ActiveRecord::Base.connection.indexes("foos")
@@ -2520,7 +2520,7 @@ def up
25202520
end
25212521
end
25222522

2523-
describe "unsafe_add_index" do
2523+
describe "unsafe_add_index" do
25242524
it "raises a helper warning when ActiveRecord is going to swallow per-column options" do
25252525
migration = Class.new(migration_klass) do
25262526
def up
@@ -2558,6 +2558,52 @@ def up
25582558
end.not_to make_database_queries(matching: /int4_ops/)
25592559
end
25602560

2561+
it "safely acquires SHARE lock when not creating indexes concurrently" do
2562+
setup_migration = Class.new(migration_klass) do
2563+
def up
2564+
unsafe_create_table :foos do |t|
2565+
t.integer :bar
2566+
end
2567+
end
2568+
end
2569+
setup_migration.suppress_messages { setup_migration.migrate(:up) }
2570+
2571+
test_migration = Class.new(migration_klass) do
2572+
def up
2573+
unsafe_add_index :foos, :bar
2574+
end
2575+
end
2576+
2577+
expect do
2578+
test_migration.suppress_messages { test_migration.migrate(:up) }
2579+
end.to make_database_queries(matching: /LOCK "public"\."foos" IN SHARE MODE/, count: 1)
2580+
.and(make_database_queries(matching: /CREATE INDEX "index_foos_on_bar"/, count: 1))
2581+
end
2582+
2583+
it "skips lock acquisition when creating indexes concurrently" do
2584+
setup_migration = Class.new(migration_klass) do
2585+
def up
2586+
unsafe_create_table :foos do |t|
2587+
t.integer :bar
2588+
end
2589+
end
2590+
end
2591+
setup_migration.suppress_messages { setup_migration.migrate(:up) }
2592+
2593+
test_migration = Class.new(migration_klass) do
2594+
def up
2595+
unsafe_add_index :foos, :bar, algorithm: :concurrently
2596+
end
2597+
end
2598+
2599+
allow(ActiveRecord::Base.connection).to receive(:execute).and_call_original
2600+
expect(ActiveRecord::Base.connection).to_not receive(:execute).with(/LOCK/)
2601+
2602+
expect do
2603+
test_migration.suppress_messages { test_migration.migrate(:up) }
2604+
end.to make_database_queries(matching: /CREATE INDEX CONCURRENTLY "index_foos_on_bar"/, count: 1)
2605+
end
2606+
25612607
it "generates index name with hashed identifier when default index name is too large" do
25622608
setup_migration = Class.new(migration_klass) do
25632609
def up
@@ -2667,19 +2713,7 @@ def up
26672713
end
26682714
end
26692715

2670-
it "raises a nice error if options isn't a hash" do
2671-
test_migration = Class.new(migration_klass) do
2672-
def up
2673-
safe_remove_concurrent_index :foos, [:column]
2674-
end
2675-
end
2676-
2677-
expect do
2678-
test_migration.suppress_messages { test_migration.migrate(:up) }
2679-
end.to raise_error(ArgumentError, "Expected safe_remove_concurrent_index to be called with arguments (table_name, :name => ...)")
2680-
end
2681-
2682-
it "raises a nice error if options is a hash with a :name key" do
2716+
it "raises a nice error if options does not have :name key" do
26832717
test_migration = Class.new(migration_klass) do
26842718
def up
26852719
safe_remove_concurrent_index :foos, :blah => :foo
@@ -4056,16 +4090,16 @@ def up
40564090
it "renames change_table to unsafe_change_table" do
40574091
migration = Class.new(migration_klass) do
40584092
def up
4059-
unsafe_create_table :foos
4060-
unsafe_change_table :foos do |t|
4061-
t.string :bar
4062-
end
4093+
unsafe_change_table :foos
40634094
end
40644095
end
40654096

4066-
migration.suppress_messages { migration.migrate(:up) }
4067-
4068-
expect(ActiveRecord::Base.connection.columns("foos").map(&:name)).to include("bar")
4097+
expect do
4098+
migration.suppress_messages { migration.migrate(:up) }
4099+
end.to raise_error(
4100+
PgHaMigrations::UnsafeMigrationError,
4101+
":change_table is too generic to even allow an unsafe variant. Instead use explicit methods for adding / removing / modifying columns."
4102+
)
40694103
end
40704104

40714105
it "renames rename_table to unsafe_rename_table" do

0 commit comments

Comments
 (0)