pg_ha_migrations 1.1.0 → 1.2.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +41 -0
- data/.gitignore +1 -0
- data/.ruby-version +1 -1
- data/.travis.yml +19 -3
- data/Appraisals +16 -0
- data/README.md +51 -2
- data/Rakefile +5 -0
- data/bin/setup +1 -0
- data/gemfiles/.bundle/config +2 -0
- data/gemfiles/rails_5.0.gemfile +7 -0
- data/gemfiles/rails_5.1.gemfile +7 -0
- data/gemfiles/rails_5.2.gemfile +7 -0
- data/gemfiles/rails_6.0.gemfile +7 -0
- data/lib/pg_ha_migrations.rb +6 -1
- data/lib/pg_ha_migrations/allowed_versions.rb +1 -1
- data/lib/pg_ha_migrations/blocking_database_transactions.rb +40 -17
- data/lib/pg_ha_migrations/blocking_database_transactions_reporter.rb +1 -1
- data/lib/pg_ha_migrations/hacks/cleanup_unnecessary_output.rb +29 -0
- data/lib/pg_ha_migrations/safe_statements.rb +133 -6
- data/lib/pg_ha_migrations/unsafe_statements.rb +12 -1
- data/lib/pg_ha_migrations/version.rb +1 -1
- data/pg_ha_migrations.gemspec +5 -5
- metadata +29 -21
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f6bc07b7a31ace17d8b98c1a4cd2d6c23086e81da40cc3ef6c27006effa29114
|
4
|
+
data.tar.gz: 0dafb9db37d66f7723cb96d38cbd44cc3747f61f8c2c7695f51a64559ff3b3fb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 973a40f102ede133528abe11ff9bbe89104d0ca47f2b2e8d36436ab241adddd5c3cc575d57e5bc33aaf37006bd5046df0cad530671b515b5ae6b64b904870b52
|
7
|
+
data.tar.gz: c386d4f0ee49f49ef31514556fb93a7554356a440899e3641a91071b0cdebbe6900f5c7e1df20803f2c6eb8a4259b6c1039676523b4a18b551ef30ee9e8c1cc5
|
@@ -0,0 +1,41 @@
|
|
1
|
+
name: CI
|
2
|
+
on: [push, pull_request]
|
3
|
+
jobs:
|
4
|
+
test:
|
5
|
+
strategy:
|
6
|
+
matrix:
|
7
|
+
pg:
|
8
|
+
- 9.6
|
9
|
+
- 10
|
10
|
+
- 11
|
11
|
+
- 12
|
12
|
+
gemfile:
|
13
|
+
- rails_5.0
|
14
|
+
- rails_5.1
|
15
|
+
- rails_5.2
|
16
|
+
- rails_6.0
|
17
|
+
name: PostgreSQL ${{ matrix.pg }}
|
18
|
+
runs-on: ubuntu-latest
|
19
|
+
env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
|
20
|
+
BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
|
21
|
+
ImageOS: ubuntu20
|
22
|
+
services:
|
23
|
+
postgresql:
|
24
|
+
image: postgres:${{ matrix.pg }}
|
25
|
+
env:
|
26
|
+
POSTGRES_PASSWORD: postgres
|
27
|
+
# Set health checks to wait until postgres has started
|
28
|
+
options: >-
|
29
|
+
--health-cmd pg_isready
|
30
|
+
--health-interval 10s
|
31
|
+
--health-timeout 5s
|
32
|
+
--health-retries 5
|
33
|
+
ports:
|
34
|
+
- 5432:5432
|
35
|
+
steps:
|
36
|
+
- uses: actions/checkout@v2
|
37
|
+
- name: Setup Ruby using .ruby-version file
|
38
|
+
uses: ruby/setup-ruby@v1
|
39
|
+
with:
|
40
|
+
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
41
|
+
- run: bundle exec rake spec
|
data/.gitignore
CHANGED
data/.ruby-version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
ruby-2.
|
1
|
+
ruby-2.7
|
data/.travis.yml
CHANGED
@@ -1,11 +1,27 @@
|
|
1
1
|
sudo: false
|
2
2
|
language: ruby
|
3
3
|
rvm:
|
4
|
-
- 2.
|
4
|
+
- 2.5
|
5
|
+
env:
|
6
|
+
jobs:
|
7
|
+
- PGVERSION: "9.6"
|
8
|
+
- PGVERSION: "10"
|
9
|
+
- PGVERSION: "11"
|
10
|
+
- PGVERSION: "12"
|
5
11
|
services:
|
6
12
|
- postgresql
|
7
|
-
addons:
|
8
|
-
postgresql: "9.6"
|
9
13
|
before_install:
|
14
|
+
- "for CLUSTER_VERSION in $(pg_lsclusters -h | cut -d' ' -f1); do sudo pg_dropcluster $CLUSTER_VERSION main --stop || true; done"
|
15
|
+
- sudo apt-get update
|
16
|
+
- sudo apt-get -y install postgresql-$PGVERSION postgresql-client-$PGVERSION postgresql-server-dev-$PGVERSION postgresql-client-common postgresql-common
|
17
|
+
- sudo pg_dropcluster $PGVERSION main --stop || true
|
18
|
+
- sudo pg_createcluster $PGVERSION main -D /var/ramfs/postgresql/11/main -- --auth=trust
|
19
|
+
- sudo pg_ctlcluster start $PGVERSION main
|
10
20
|
- gem uninstall -v '>= 2' -i $(rvm gemdir)@global -ax bundler || true
|
11
21
|
- gem install bundler -v 1.15.4
|
22
|
+
gemfile:
|
23
|
+
- gemfiles/rails_5.0.gemfile
|
24
|
+
- gemfiles/rails_5.1.gemfile
|
25
|
+
- gemfiles/rails_5.2.gemfile
|
26
|
+
- gemfiles/rails_6.0.gemfile
|
27
|
+
script: "bundle exec rake spec"
|
data/Appraisals
ADDED
data/README.md
CHANGED
@@ -55,7 +55,7 @@ There are two major classes of concerns we try to handle in the API:
|
|
55
55
|
|
56
56
|
We rename migration methods with prefixes denoting their safety level:
|
57
57
|
|
58
|
-
- `safe_*`: These methods check for both application and database safety concerns prefer concurrent operations where available, set low lock timeouts where appropriate, and decompose operations into multiple safe steps.
|
58
|
+
- `safe_*`: These methods check for both application and database safety concerns, prefer concurrent operations where available, set low lock timeouts where appropriate, and decompose operations into multiple safe steps.
|
59
59
|
- `unsafe_*`: These methods are generally a direct dispatch to the native ActiveRecord migration method.
|
60
60
|
|
61
61
|
Calling the original migration methods without a prefix will raise an error.
|
@@ -106,6 +106,18 @@ Safely add a new enum value.
|
|
106
106
|
safe_add_enum_value :enum, "value"
|
107
107
|
```
|
108
108
|
|
109
|
+
#### unsafe\_rename\_enum\_value
|
110
|
+
|
111
|
+
Unsafely change the value of an enum type entry.
|
112
|
+
|
113
|
+
```ruby
|
114
|
+
unsafe_rename_enum_value(:enum, "old_value", "new_value")
|
115
|
+
```
|
116
|
+
|
117
|
+
Note:
|
118
|
+
|
119
|
+
Changing an enum value does not issue any long-running scans or acquire locks on usages of the enum type. Therefore multiple queries within a transaction concurrent with the change may see both the old and new values. To highlight these potential pitfalls no `safe_rename_enum_value` equivalent exists. Before modifying an enum type entry you should verify that no concurrently executing queries will attempt to write the old value and that read queries understand the new value.
|
120
|
+
|
109
121
|
#### safe\_add\_column
|
110
122
|
|
111
123
|
Safely add a column.
|
@@ -127,7 +139,13 @@ unsafe_add_column :table, :column, :type
|
|
127
139
|
Safely change the default value for a column.
|
128
140
|
|
129
141
|
```ruby
|
142
|
+
# Constant value:
|
130
143
|
safe_change_column_default :table, :column, "value"
|
144
|
+
safe_change_column_default :table, :column, DateTime.new(...)
|
145
|
+
# Functional expression evaluated at row insert time:
|
146
|
+
safe_change_column_default :table, :column, -> { "NOW()" }
|
147
|
+
# Functional expression evaluated at migration time:
|
148
|
+
safe_change_column_default :table, :column, -> { "'NOW()'" }
|
131
149
|
```
|
132
150
|
|
133
151
|
#### safe\_make\_column\_nullable
|
@@ -168,6 +186,37 @@ Safely remove an index. Migrations that contain this statement must also include
|
|
168
186
|
safe_remove_concurrent_index :table, :name => :index_name
|
169
187
|
```
|
170
188
|
|
189
|
+
#### safe\_add\_unvalidated\_check\_constraint
|
190
|
+
|
191
|
+
Safely add a `CHECK` constraint. The constraint will not be immediately validated on existing rows to avoid a full table scan while holding an exclusive lock. After adding the constraint, you'll need to use `safe_validate_check_constraint` to validate existing rows.
|
192
|
+
|
193
|
+
```ruby
|
194
|
+
safe_add_unvalidated_check_constraint :table, "column LIKE 'example%'", name: :constraint_table_on_column_like_example
|
195
|
+
```
|
196
|
+
|
197
|
+
#### safe\_validate\_check\_constraint
|
198
|
+
|
199
|
+
Safely validate (without acquiring an exclusive lock) existing rows for a newly added but as-yet unvalidated `CHECK` constraint.
|
200
|
+
|
201
|
+
```ruby
|
202
|
+
safe_validate_check_constraint :table, name: :constraint_table_on_column_like_example
|
203
|
+
```
|
204
|
+
|
205
|
+
#### safe\_rename\_constraint
|
206
|
+
|
207
|
+
Safely rename any (not just `CHECK`) constraint.
|
208
|
+
|
209
|
+
```ruby
|
210
|
+
safe_rename_constraint :table, from: :constraint_table_on_column_like_typo, to: :constraint_table_on_column_like_example
|
211
|
+
```
|
212
|
+
|
213
|
+
#### unsafe\_remove\_constraint
|
214
|
+
|
215
|
+
Drop any (not just `CHECK`) constraint.
|
216
|
+
|
217
|
+
```ruby
|
218
|
+
unsafe_remove_constraint :table, name: :constraint_table_on_column_like_example
|
219
|
+
```
|
171
220
|
|
172
221
|
### Utilities
|
173
222
|
|
@@ -246,7 +295,7 @@ Rake::Task["pg_ha_migrations:check_blocking_database_transactions"].enhance ["db
|
|
246
295
|
|
247
296
|
## Development
|
248
297
|
|
249
|
-
After checking out the repo, run `bin/setup` to install dependencies and start a postgres docker container. Then, run `
|
298
|
+
After checking out the repo, run `bin/setup` to install dependencies and start a postgres docker container. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. This project uses Appraisal to test against multiple versions of ActiveRecord; you can run the tests against all supported version with `bundle exec appraisal rspec`.
|
250
299
|
|
251
300
|
Running tests will automatically create a test database in the locally running Postgres server. You can find the connection parameters in `spec/spec_helper.rb`, but setting the environment variables `PGHOST`, `PGPORT`, `PGUSER`, and `PGPASSWORD` will override the defaults.
|
252
301
|
|
data/Rakefile
CHANGED
@@ -1,8 +1,13 @@
|
|
1
1
|
require "bundler/gem_tasks"
|
2
2
|
require "rspec/core/rake_task"
|
3
|
+
require "appraisal"
|
3
4
|
require_relative File.join("lib", "pg_ha_migrations")
|
4
5
|
|
5
6
|
RSpec::Core::RakeTask.new(:spec)
|
6
7
|
|
8
|
+
if !ENV["APPRAISAL_INITIALIZED"] && !ENV["TRAVIS"]
|
9
|
+
task :default => :appraisal
|
10
|
+
end
|
11
|
+
|
7
12
|
task :default => :spec
|
8
13
|
|
data/bin/setup
CHANGED
data/lib/pg_ha_migrations.rb
CHANGED
@@ -35,6 +35,10 @@ module PgHaMigrations
|
|
35
35
|
# as expected or get the schema into an inconsistent state
|
36
36
|
InvalidMigrationError = Class.new(Exception)
|
37
37
|
|
38
|
+
# Unsupported migrations use ActiveRecord::Migration features that
|
39
|
+
# we don't support, and therefore will likely have unexpected behavior.
|
40
|
+
UnsupportedMigrationError = Class.new(Exception)
|
41
|
+
|
38
42
|
# This gem only supports the PostgreSQL adapter at this time.
|
39
43
|
UnsupportedAdapter = Class.new(Exception)
|
40
44
|
end
|
@@ -47,13 +51,14 @@ require "pg_ha_migrations/dependent_objects_checks"
|
|
47
51
|
require "pg_ha_migrations/allowed_versions"
|
48
52
|
require "pg_ha_migrations/railtie"
|
49
53
|
require "pg_ha_migrations/hacks/disable_ddl_transaction"
|
54
|
+
require "pg_ha_migrations/hacks/cleanup_unnecessary_output"
|
50
55
|
|
51
56
|
module PgHaMigrations::AutoIncluder
|
52
57
|
def inherited(klass)
|
53
58
|
super(klass) if defined?(super)
|
54
59
|
|
55
|
-
klass.prepend(PgHaMigrations::SafeStatements)
|
56
60
|
klass.prepend(PgHaMigrations::UnsafeStatements)
|
61
|
+
klass.prepend(PgHaMigrations::SafeStatements)
|
57
62
|
end
|
58
63
|
end
|
59
64
|
|
@@ -1,13 +1,23 @@
|
|
1
1
|
module PgHaMigrations
|
2
2
|
class BlockingDatabaseTransactions
|
3
|
-
LongRunningTransaction = Struct.new(:database, :current_query, :transaction_age, :tables_with_locks) do
|
3
|
+
LongRunningTransaction = Struct.new(:database, :current_query, :state, :transaction_age, :tables_with_locks) do
|
4
4
|
def description
|
5
|
-
|
5
|
+
locked_tables = tables_with_locks.compact
|
6
|
+
[
|
7
|
+
database,
|
8
|
+
locked_tables.size > 0 ? "tables (#{locked_tables.join(', ')})" : nil,
|
9
|
+
"#{idle? ? "currently idle " : ""}transaction open for #{transaction_age}",
|
10
|
+
"#{idle? ? "last " : ""}query: #{current_query}"
|
11
|
+
].compact.join(" | ")
|
6
12
|
end
|
7
13
|
|
8
14
|
def concurrent_index_creation?
|
9
15
|
!!current_query.match(/create\s+index\s+concurrently/i)
|
10
16
|
end
|
17
|
+
|
18
|
+
def idle?
|
19
|
+
state == "idle in transaction"
|
20
|
+
end
|
11
21
|
end
|
12
22
|
|
13
23
|
def self.autovacuum_regex
|
@@ -15,35 +25,48 @@ module PgHaMigrations
|
|
15
25
|
end
|
16
26
|
|
17
27
|
def self.find_blocking_transactions(minimum_transaction_age = "0 seconds")
|
18
|
-
|
28
|
+
postgres_version = ActiveRecord::Base.connection.postgresql_version
|
29
|
+
pid_column, query_column = if postgres_version < 9_02_00
|
19
30
|
["procpid", "current_query"]
|
20
31
|
else
|
21
32
|
["pid", "query"]
|
22
33
|
end
|
23
34
|
|
24
|
-
|
35
|
+
# In some versions of Postgres, walsenders show up here with a non-null xact_start.
|
36
|
+
# That's been patched, so hard to test, but we should exclude them anyway.
|
37
|
+
# https://www.postgresql.org/message-id/flat/20191209234409.exe7osmyalwkt5j4%40development
|
38
|
+
ignore_sqlsender_sql = "psa.backend_type != 'walsender'"
|
39
|
+
|
40
|
+
raw_query = <<~SQL
|
25
41
|
SELECT
|
26
42
|
psa.datname as database, -- Will only ever be one database
|
27
|
-
psa.#{
|
43
|
+
psa.#{query_column} as current_query,
|
44
|
+
psa.state,
|
28
45
|
clock_timestamp() - psa.xact_start AS transaction_age,
|
29
46
|
array_agg(distinct c.relname) AS tables_with_locks
|
30
47
|
FROM pg_stat_activity psa -- Cluster wide
|
31
|
-
JOIN pg_locks l ON (psa.#{pid_column} = l.pid) -- Cluster wide
|
32
|
-
JOIN pg_class c ON (
|
33
|
-
|
34
|
-
|
35
|
-
AND ns.nspname != 'pg_catalog'
|
36
|
-
AND c.relkind = 'r'
|
37
|
-
AND psa.xact_start < clock_timestamp() - ?::interval
|
38
|
-
AND psa.#{state_column} !~ ?
|
39
|
-
AND (
|
48
|
+
LEFT JOIN pg_locks l ON (psa.#{pid_column} = l.pid) -- Cluster wide
|
49
|
+
LEFT JOIN pg_class c ON ( -- Database wide
|
50
|
+
l.locktype = 'relation'
|
51
|
+
AND l.relation = c.oid
|
40
52
|
-- Be explicit about this being for a single database -- it's already implicit in
|
41
53
|
-- the relations used, and if we don't restrict this we could get incorrect results
|
42
54
|
-- with oid collisions from pg_namespace and pg_class.
|
43
|
-
l.database =
|
44
|
-
OR l.database = (SELECT d.oid FROM pg_database d WHERE d.datname = current_database())
|
55
|
+
AND l.database = (SELECT d.oid FROM pg_database d WHERE d.datname = current_database())
|
45
56
|
)
|
46
|
-
|
57
|
+
LEFT JOIN pg_namespace ns ON (c.relnamespace = ns.oid) -- Database wide
|
58
|
+
WHERE psa.#{pid_column} != pg_backend_pid()
|
59
|
+
AND (
|
60
|
+
l.locktype != 'relation'
|
61
|
+
OR (
|
62
|
+
ns.nspname != 'pg_catalog'
|
63
|
+
AND c.relkind = 'r'
|
64
|
+
)
|
65
|
+
)
|
66
|
+
AND psa.xact_start < clock_timestamp() - ?::interval
|
67
|
+
AND psa.#{query_column} !~ ?
|
68
|
+
#{postgres_version >= 10_00_00 ? "AND #{ignore_sqlsender_sql}" : ""}
|
69
|
+
GROUP BY psa.datname, psa.#{query_column}, psa.state, psa.xact_start
|
47
70
|
SQL
|
48
71
|
|
49
72
|
query = ActiveRecord::Base.send(:sanitize_sql_for_conditions, [raw_query, minimum_transaction_age, autovacuum_regex])
|
@@ -24,7 +24,7 @@ module PgHaMigrations
|
|
24
24
|
end
|
25
25
|
|
26
26
|
if blocking_transactions.any?(&:concurrent_index_creation?)
|
27
|
-
report <<
|
27
|
+
report << <<~eos.lines.map { |line| "\t#{line}" }.join
|
28
28
|
Warning: concurrent indexes are currently being built. If you have any other
|
29
29
|
migrations in this deploy that will attempt to create additional
|
30
30
|
concurrent indexes on the same physical database (even if the table
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require "active_record/migration/compatibility"
|
2
|
+
|
3
|
+
module PgHaMigrations
|
4
|
+
module ActiveRecordHacks
|
5
|
+
module CleanupUnnecessaryOutput
|
6
|
+
# This is fixed in Rails 6+, but previously there were several
|
7
|
+
# places where #adapter_name was called directly which implicitly
|
8
|
+
# delegated to the connection through #method_missing. That
|
9
|
+
# delegation though results in wrapping the call in #say_with_time
|
10
|
+
# which unnecessarily outputs a bunch of calls to #adapter_name.
|
11
|
+
# The easiest way to clean this up retroactively is to just patch
|
12
|
+
# in a direct dispatch to the connection's method.
|
13
|
+
#
|
14
|
+
# See: https://github.com/rails/rails/commit/eb7c71bcd3d0c7e079dffdb11e43fb466eec06aa
|
15
|
+
def adapter_name
|
16
|
+
connection.adapter_name
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
patchable_module = [
|
23
|
+
defined?(ActiveRecord::Migration::Compatibility::V5_2) ? ActiveRecord::Migration::Compatibility::V5_2 : nil,
|
24
|
+
defined?(ActiveRecord::Migration::Compatibility::V5_1) ? ActiveRecord::Migration::Compatibility::V5_1 : nil,
|
25
|
+
defined?(ActiveRecord::Migration::Compatibility::V5_0) ? ActiveRecord::Migration::Compatibility::V5_0 : nil,
|
26
|
+
].detect { |m| m }
|
27
|
+
if patchable_module
|
28
|
+
patchable_module.prepend(PgHaMigrations::ActiveRecordHacks::CleanupUnnecessaryOutput)
|
29
|
+
end
|
@@ -25,9 +25,21 @@ module PgHaMigrations::SafeStatements
|
|
25
25
|
unsafe_execute("ALTER TYPE #{PG::Connection.quote_ident(name.to_s)} ADD VALUE '#{PG::Connection.escape_string(value)}'")
|
26
26
|
end
|
27
27
|
|
28
|
+
def unsafe_rename_enum_value(name, old_value, new_value)
|
29
|
+
if ActiveRecord::Base.connection.postgresql_version < 10_00_00
|
30
|
+
raise PgHaMigrations::InvalidMigrationError, "Renaming an enum value is not supported on Postgres databases before version 10"
|
31
|
+
end
|
32
|
+
|
33
|
+
unsafe_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)}'")
|
34
|
+
end
|
35
|
+
|
28
36
|
def safe_add_column(table, column, type, options = {})
|
29
|
-
if options.has_key?
|
30
|
-
|
37
|
+
if options.has_key?(:default)
|
38
|
+
if ActiveRecord::Base.connection.postgresql_version < 11_00_00
|
39
|
+
raise PgHaMigrations::UnsafeMigrationError.new(":default is NOT SAFE! Use safe_change_column_default afterwards then backfill the data to prevent locking the table")
|
40
|
+
elsif options[:default].is_a?(Proc) || (options[:default].is_a?(String) && !([:string, :text, :binary].include?(type.to_sym) || _type_is_enum(type)))
|
41
|
+
raise PgHaMigrations::UnsafeMigrationError.new(":default is not safe if the default value is volatile. Use safe_change_column_default afterwards then backfill the data to prevent locking the table")
|
42
|
+
end
|
31
43
|
end
|
32
44
|
if options[:null] == false
|
33
45
|
raise PgHaMigrations::UnsafeMigrationError.new(":null => false is NOT SAFE if the table has data! If you _really_ want to do this, use unsafe_make_column_not_nullable")
|
@@ -45,10 +57,53 @@ module PgHaMigrations::SafeStatements
|
|
45
57
|
def safe_change_column_default(table_name, column_name, default_value)
|
46
58
|
column = connection.send(:column_for, table_name, column_name)
|
47
59
|
|
60
|
+
# In 5.2 we have an edge whereby passing in a string literal with an expression
|
61
|
+
# results in confusing behavior because instead of being executed in the database
|
62
|
+
# that expression is turned into a Ruby nil before being sent to the database layer;
|
63
|
+
# this seems to be an expected side effect of a change that was targeted at a use
|
64
|
+
# case unrelated to migrations: https://github.com/rails/rails/commit/7b2dfdeab6e4ef096e4dc1fe313056f08ccf7dc5
|
65
|
+
#
|
66
|
+
# On the other hand, the behavior in 5.1 is also confusing because it quotes the
|
67
|
+
# expression (instead of maintaining the string as-is), which results in Postgres
|
68
|
+
# evaluating the expression once when executing the DDL and setting the default to
|
69
|
+
# the constant result of that evaluation rather than setting the default to the
|
70
|
+
# expression itself.
|
71
|
+
#
|
72
|
+
# Therefore we want to disallow passing in an expression directly as a string and
|
73
|
+
# require the use of a Proc instead with specific quoting rules to determine exact
|
74
|
+
# behavior. It's fairly difficult (without relying on something like the PgQuery gem
|
75
|
+
# which requires native extensions built with the Postgres dev packages installed)
|
76
|
+
# to determine if a string literal represent an expression or just a constant. So
|
77
|
+
# instead of trying to parse the expression, we employ a set of heuristics:
|
78
|
+
# - If the column is text-like or binary, then we can allow anything in the default
|
79
|
+
# value since a Ruby string there will always coerce directly to the equivalent
|
80
|
+
# text/binary value rather than being interpreted as a DDL-time expression.
|
81
|
+
# - Custom enum types are a special case: they also are treated like strings by
|
82
|
+
# Rails, so we want to allow those as-is.
|
83
|
+
# - Otherwise, disallow any Ruby string values and instead require the Ruby object
|
84
|
+
# type that maps to the column type.
|
85
|
+
#
|
86
|
+
# These heuristics eliminate (virtually?) all ambiguity. In theory there's a
|
87
|
+
# possiblity that some custom object could be coerced-Ruby side into a SQL string
|
88
|
+
# that does something weird here, but that seems an odd enough case that we can
|
89
|
+
# safely ignore it.
|
48
90
|
if default_value.present? &&
|
49
91
|
!default_value.is_a?(Proc) &&
|
50
|
-
|
51
|
-
|
92
|
+
(
|
93
|
+
connection.quote_default_expression(default_value, column) == "NULL" ||
|
94
|
+
(
|
95
|
+
![:string, :text, :binary, :enum].include?(column.sql_type_metadata.type) &&
|
96
|
+
default_value.is_a?(String)
|
97
|
+
)
|
98
|
+
)
|
99
|
+
raise PgHaMigrations::InvalidMigrationError, <<~ERROR
|
100
|
+
Setting a default value to an expression using a string literal is ambiguous.
|
101
|
+
|
102
|
+
If you want the default to be:
|
103
|
+
* ...a constant scalar value, use the matching Ruby object type instead of a string if possible (e.g., `DateTime.new(...)`).
|
104
|
+
* ...an expression evaluated at runtime for each row, then pass a Proc that returns the expression string (e.g., `-> { "NOW()" }`).
|
105
|
+
* ...an expression evaluated at migration time, then pass a Proc that returns a quoted expression string (e.g., `-> { "'NOW()'" }`).
|
106
|
+
ERROR
|
52
107
|
end
|
53
108
|
|
54
109
|
safely_acquire_lock_for_table(table_name) do
|
@@ -76,7 +131,7 @@ module PgHaMigrations::SafeStatements
|
|
76
131
|
unless options.is_a?(Hash) && options.key?(:name)
|
77
132
|
raise ArgumentError, "Expected safe_remove_concurrent_index to be called with arguments (table_name, :name => ...)"
|
78
133
|
end
|
79
|
-
unless ActiveRecord::Base.connection.postgresql_version >=
|
134
|
+
unless ActiveRecord::Base.connection.postgresql_version >= 9_06_00
|
80
135
|
raise PgHaMigrations::InvalidMigrationError, "Removing an index concurrently is not supported on Postgres 9.1 databases"
|
81
136
|
end
|
82
137
|
index_size = select_value("SELECT pg_size_pretty(pg_relation_size('#{options[:name]}'))")
|
@@ -88,6 +143,66 @@ module PgHaMigrations::SafeStatements
|
|
88
143
|
unsafe_execute("SET maintenance_work_mem = '#{PG::Connection.escape_string(gigabytes.to_s)} GB'")
|
89
144
|
end
|
90
145
|
|
146
|
+
def safe_add_unvalidated_check_constraint(table, expression, name:)
|
147
|
+
unsafe_add_check_constraint(table, expression, name: name, validate: false)
|
148
|
+
end
|
149
|
+
|
150
|
+
def unsafe_add_check_constraint(table, expression, name:, validate: true)
|
151
|
+
raise ArgumentError, "Expected <name> to be present" unless name.present?
|
152
|
+
|
153
|
+
quoted_table_name = connection.quote_table_name(table)
|
154
|
+
quoted_constraint_name = connection.quote_table_name(name)
|
155
|
+
sql = "ALTER TABLE #{quoted_table_name} ADD CONSTRAINT #{quoted_constraint_name} CHECK (#{expression}) #{validate ? "" : "NOT VALID"}"
|
156
|
+
|
157
|
+
safely_acquire_lock_for_table(table) do
|
158
|
+
say_with_time "add_check_constraint(#{table.inspect}, #{expression.inspect}, name: #{name.inspect}, validate: #{validate.inspect})" do
|
159
|
+
connection.execute(sql)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def safe_validate_check_constraint(table, name:)
|
165
|
+
raise ArgumentError, "Expected <name> to be present" unless name.present?
|
166
|
+
|
167
|
+
quoted_table_name = connection.quote_table_name(table)
|
168
|
+
quoted_constraint_name = connection.quote_table_name(name)
|
169
|
+
sql = "ALTER TABLE #{quoted_table_name} VALIDATE CONSTRAINT #{quoted_constraint_name}"
|
170
|
+
|
171
|
+
say_with_time "validate_check_constraint(#{table.inspect}, name: #{name.inspect})" do
|
172
|
+
connection.execute(sql)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def safe_rename_constraint(table, from:, to:)
|
177
|
+
raise ArgumentError, "Expected <from> to be present" unless from.present?
|
178
|
+
raise ArgumentError, "Expected <to> to be present" unless to.present?
|
179
|
+
|
180
|
+
quoted_table_name = connection.quote_table_name(table)
|
181
|
+
quoted_constraint_from_name = connection.quote_table_name(from)
|
182
|
+
quoted_constraint_to_name = connection.quote_table_name(to)
|
183
|
+
sql = "ALTER TABLE #{quoted_table_name} RENAME CONSTRAINT #{quoted_constraint_from_name} TO #{quoted_constraint_to_name}"
|
184
|
+
|
185
|
+
safely_acquire_lock_for_table(table) do
|
186
|
+
say_with_time "rename_constraint(#{table.inspect}, from: #{from.inspect}, to: #{to.inspect})" do
|
187
|
+
connection.execute(sql)
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def unsafe_remove_constraint(table, name:)
|
193
|
+
raise ArgumentError, "Expected <name> to be present" unless name.present?
|
194
|
+
|
195
|
+
quoted_table_name = connection.quote_table_name(table)
|
196
|
+
quoted_constraint_name = connection.quote_table_name(name)
|
197
|
+
sql = "ALTER TABLE #{quoted_table_name} DROP CONSTRAINT #{quoted_constraint_name}"
|
198
|
+
|
199
|
+
safely_acquire_lock_for_table(table) do
|
200
|
+
say_with_time "remove_constraint(#{table.inspect}, name: #{name.inspect})" do
|
201
|
+
connection.execute(sql)
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
91
206
|
def _per_migration_caller
|
92
207
|
@_per_migration_caller ||= Kernel.caller
|
93
208
|
end
|
@@ -98,6 +213,18 @@ module PgHaMigrations::SafeStatements
|
|
98
213
|
raise PgHaMigrations::UnsupportedAdapter, "This gem only works with the #{expected_adapter} adapter, found #{actual_adapter} instead" unless actual_adapter == expected_adapter
|
99
214
|
end
|
100
215
|
|
216
|
+
def _type_is_enum(type)
|
217
|
+
ActiveRecord::Base.connection.select_values("SELECT typname FROM pg_type JOIN pg_enum ON pg_type.oid = pg_enum.enumtypid").include?(type.to_s)
|
218
|
+
end
|
219
|
+
|
220
|
+
def migrate(direction)
|
221
|
+
if respond_to?(:change)
|
222
|
+
raise PgHaMigrations::UnsupportedMigrationError, "Tracking changes for automated rollback is not supported; use explicit #up instead."
|
223
|
+
end
|
224
|
+
|
225
|
+
super(direction)
|
226
|
+
end
|
227
|
+
|
101
228
|
def exec_migration(conn, direction)
|
102
229
|
_check_postgres_adapter!
|
103
230
|
super(conn, direction)
|
@@ -123,7 +250,7 @@ module PgHaMigrations::SafeStatements
|
|
123
250
|
end
|
124
251
|
|
125
252
|
connection.transaction do
|
126
|
-
adjust_timeout_method = connection.postgresql_version >=
|
253
|
+
adjust_timeout_method = connection.postgresql_version >= 9_03_00 ? :adjust_lock_timeout : :adjust_statement_timeout
|
127
254
|
begin
|
128
255
|
method(adjust_timeout_method).call(PgHaMigrations::LOCK_TIMEOUT_SECONDS) do
|
129
256
|
connection.execute("LOCK #{quoted_table_name};")
|
@@ -39,10 +39,10 @@ module PgHaMigrations::UnsafeStatements
|
|
39
39
|
delegate_unsafe_method_to_migration_base_class :change_column
|
40
40
|
delegate_unsafe_method_to_migration_base_class :change_column_default
|
41
41
|
delegate_unsafe_method_to_migration_base_class :remove_column
|
42
|
-
delegate_unsafe_method_to_migration_base_class :add_index
|
43
42
|
delegate_unsafe_method_to_migration_base_class :execute
|
44
43
|
delegate_unsafe_method_to_migration_base_class :remove_index
|
45
44
|
delegate_unsafe_method_to_migration_base_class :add_foreign_key
|
45
|
+
delegate_unsafe_method_to_migration_base_class :remove_foreign_key
|
46
46
|
|
47
47
|
disable_or_delegate_default_method :create_table, ":create_table is NOT SAFE! Use safe_create_table instead"
|
48
48
|
disable_or_delegate_default_method :add_column, ":add_column is NOT SAFE! Use safe_add_column instead"
|
@@ -58,6 +58,7 @@ module PgHaMigrations::UnsafeStatements
|
|
58
58
|
disable_or_delegate_default_method :execute, ":execute is NOT SAFE! Explicitly call :unsafe_execute to proceed", allow_reentry_from_compatibility_module: true
|
59
59
|
disable_or_delegate_default_method :remove_index, ":remove_index is NOT SAFE! Use safe_remove_concurrent_index instead for Postgres 9.6 databases; Explicitly call :unsafe_remove_index to proceed on Postgres 9.1"
|
60
60
|
disable_or_delegate_default_method :add_foreign_key, ":add_foreign_key is NOT SAFE! Explicitly call :unsafe_add_foreign_key"
|
61
|
+
disable_or_delegate_default_method :remove_foreign_key, ":remove_foreign_key is NOT SAFE! Explicitly call :unsafe_remove_foreign_key"
|
61
62
|
|
62
63
|
def unsafe_create_table(table, options={}, &block)
|
63
64
|
if options[:force] && !PgHaMigrations.config.allow_force_create_table
|
@@ -67,6 +68,16 @@ module PgHaMigrations::UnsafeStatements
|
|
67
68
|
execute_ancestor_statement(:create_table, table, options, &block)
|
68
69
|
end
|
69
70
|
|
71
|
+
def unsafe_add_index(table, column_names, options = {})
|
72
|
+
if ((ActiveRecord::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 2) || ActiveRecord::VERSION::MAJOR > 5) &&
|
73
|
+
column_names.is_a?(String) && /\W/.match?(column_names) && options.key?(:opclass)
|
74
|
+
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"
|
75
|
+
end
|
76
|
+
|
77
|
+
execute_ancestor_statement(:add_index, table, column_names, options)
|
78
|
+
end
|
79
|
+
|
80
|
+
|
70
81
|
def execute_ancestor_statement(method_name, *args, &block)
|
71
82
|
# Dispatching here is a bit complicated: we need to execute the method
|
72
83
|
# belonging to the first member of the inheritance chain (besides
|
data/pg_ha_migrations.gemspec
CHANGED
@@ -29,14 +29,14 @@ Gem::Specification.new do |spec|
|
|
29
29
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
30
30
|
spec.require_paths = ["lib"]
|
31
31
|
|
32
|
-
spec.add_development_dependency "bundler", "~> 1.15"
|
33
32
|
spec.add_development_dependency "rake", "~> 10.0"
|
34
33
|
spec.add_development_dependency "rspec", "~> 3.0"
|
35
34
|
spec.add_development_dependency "pg"
|
36
35
|
spec.add_development_dependency "db-query-matchers", "~> 0.9.0"
|
37
|
-
spec.add_development_dependency
|
38
|
-
spec.add_development_dependency
|
36
|
+
spec.add_development_dependency "pry"
|
37
|
+
spec.add_development_dependency "pry-byebug"
|
38
|
+
spec.add_development_dependency "appraisal", "~> 2.2.0"
|
39
39
|
|
40
|
-
spec.add_dependency "rails", ">= 5.0", "<
|
41
|
-
spec.add_dependency "relation_to_struct"
|
40
|
+
spec.add_dependency "rails", ">= 5.0", "< 6.1"
|
41
|
+
spec.add_dependency "relation_to_struct", ">= 1.5.1"
|
42
42
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pg_ha_migrations
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.2.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- celeen
|
@@ -14,22 +14,8 @@ authors:
|
|
14
14
|
autorequire:
|
15
15
|
bindir: exe
|
16
16
|
cert_chain: []
|
17
|
-
date:
|
17
|
+
date: 2021-03-05 00:00:00.000000000 Z
|
18
18
|
dependencies:
|
19
|
-
- !ruby/object:Gem::Dependency
|
20
|
-
name: bundler
|
21
|
-
requirement: !ruby/object:Gem::Requirement
|
22
|
-
requirements:
|
23
|
-
- - "~>"
|
24
|
-
- !ruby/object:Gem::Version
|
25
|
-
version: '1.15'
|
26
|
-
type: :development
|
27
|
-
prerelease: false
|
28
|
-
version_requirements: !ruby/object:Gem::Requirement
|
29
|
-
requirements:
|
30
|
-
- - "~>"
|
31
|
-
- !ruby/object:Gem::Version
|
32
|
-
version: '1.15'
|
33
19
|
- !ruby/object:Gem::Dependency
|
34
20
|
name: rake
|
35
21
|
requirement: !ruby/object:Gem::Requirement
|
@@ -114,6 +100,20 @@ dependencies:
|
|
114
100
|
- - ">="
|
115
101
|
- !ruby/object:Gem::Version
|
116
102
|
version: '0'
|
103
|
+
- !ruby/object:Gem::Dependency
|
104
|
+
name: appraisal
|
105
|
+
requirement: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - "~>"
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: 2.2.0
|
110
|
+
type: :development
|
111
|
+
prerelease: false
|
112
|
+
version_requirements: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - "~>"
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: 2.2.0
|
117
117
|
- !ruby/object:Gem::Dependency
|
118
118
|
name: rails
|
119
119
|
requirement: !ruby/object:Gem::Requirement
|
@@ -123,7 +123,7 @@ dependencies:
|
|
123
123
|
version: '5.0'
|
124
124
|
- - "<"
|
125
125
|
- !ruby/object:Gem::Version
|
126
|
-
version: '
|
126
|
+
version: '6.1'
|
127
127
|
type: :runtime
|
128
128
|
prerelease: false
|
129
129
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -133,21 +133,21 @@ dependencies:
|
|
133
133
|
version: '5.0'
|
134
134
|
- - "<"
|
135
135
|
- !ruby/object:Gem::Version
|
136
|
-
version: '
|
136
|
+
version: '6.1'
|
137
137
|
- !ruby/object:Gem::Dependency
|
138
138
|
name: relation_to_struct
|
139
139
|
requirement: !ruby/object:Gem::Requirement
|
140
140
|
requirements:
|
141
141
|
- - ">="
|
142
142
|
- !ruby/object:Gem::Version
|
143
|
-
version:
|
143
|
+
version: 1.5.1
|
144
144
|
type: :runtime
|
145
145
|
prerelease: false
|
146
146
|
version_requirements: !ruby/object:Gem::Requirement
|
147
147
|
requirements:
|
148
148
|
- - ">="
|
149
149
|
- !ruby/object:Gem::Version
|
150
|
-
version:
|
150
|
+
version: 1.5.1
|
151
151
|
description: Enforces DDL/migration safety in Ruby on Rails project with an emphasis
|
152
152
|
on explicitly choosing trade-offs and avoiding unnecessary magic.
|
153
153
|
email:
|
@@ -156,11 +156,13 @@ executables: []
|
|
156
156
|
extensions: []
|
157
157
|
extra_rdoc_files: []
|
158
158
|
files:
|
159
|
+
- ".github/workflows/ci.yml"
|
159
160
|
- ".gitignore"
|
160
161
|
- ".pryrc"
|
161
162
|
- ".rspec"
|
162
163
|
- ".ruby-version"
|
163
164
|
- ".travis.yml"
|
165
|
+
- Appraisals
|
164
166
|
- CODE_OF_CONDUCT.md
|
165
167
|
- Gemfile
|
166
168
|
- LICENSE.txt
|
@@ -168,11 +170,17 @@ files:
|
|
168
170
|
- Rakefile
|
169
171
|
- bin/console
|
170
172
|
- bin/setup
|
173
|
+
- gemfiles/.bundle/config
|
174
|
+
- gemfiles/rails_5.0.gemfile
|
175
|
+
- gemfiles/rails_5.1.gemfile
|
176
|
+
- gemfiles/rails_5.2.gemfile
|
177
|
+
- gemfiles/rails_6.0.gemfile
|
171
178
|
- lib/pg_ha_migrations.rb
|
172
179
|
- lib/pg_ha_migrations/allowed_versions.rb
|
173
180
|
- lib/pg_ha_migrations/blocking_database_transactions.rb
|
174
181
|
- lib/pg_ha_migrations/blocking_database_transactions_reporter.rb
|
175
182
|
- lib/pg_ha_migrations/dependent_objects_checks.rb
|
183
|
+
- lib/pg_ha_migrations/hacks/cleanup_unnecessary_output.rb
|
176
184
|
- lib/pg_ha_migrations/hacks/disable_ddl_transaction.rb
|
177
185
|
- lib/pg_ha_migrations/railtie.rb
|
178
186
|
- lib/pg_ha_migrations/safe_statements.rb
|
@@ -199,7 +207,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
199
207
|
- !ruby/object:Gem::Version
|
200
208
|
version: '0'
|
201
209
|
requirements: []
|
202
|
-
rubygems_version: 3.
|
210
|
+
rubygems_version: 3.1.4
|
203
211
|
signing_key:
|
204
212
|
specification_version: 4
|
205
213
|
summary: Enforces DDL/migration safety in Ruby on Rails project with an emphasis on
|