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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b472247321efcc716bc1f7c4e497dcbe32058e4d2ce11d8f6d0d9fea7136755
4
- data.tar.gz: 820bf96e3200deb2cffd30ba5534513600d05138f57f7812539cc8b33f96058a
3
+ metadata.gz: f6bc07b7a31ace17d8b98c1a4cd2d6c23086e81da40cc3ef6c27006effa29114
4
+ data.tar.gz: 0dafb9db37d66f7723cb96d38cbd44cc3747f61f8c2c7695f51a64559ff3b3fb
5
5
  SHA512:
6
- metadata.gz: b95318b6bf16ea1f5c9ec2514a6d6cccf912fc051caa9a954c58a038431d9afbbf01a23576f4ceaab1856922dc6e9aee5573743abb619f494c0f6023dcb217a9
7
- data.tar.gz: 8bf47117312865cf57b51d200f3d131c266171a0f9899f39b8aa3f09aad50842555d93007a0b33a00873a7d67ec56a0f9b9027a028bf28338399939cd285ceda
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
@@ -7,6 +7,7 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
+ /gemfiles/*.lock
10
11
 
11
12
  # rspec failure tracking
12
13
  .rspec_status
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- ruby-2.4.3
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
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
@@ -0,0 +1,16 @@
1
+ appraise "rails-5.0" do
2
+ gem "rails", "5.0.7.2"
3
+ end
4
+
5
+ appraise "rails-5.1" do
6
+ gem "rails", "5.1.7"
7
+ end
8
+
9
+ appraise "rails-5.2" do
10
+ gem "rails", "5.2.3"
11
+ end
12
+
13
+ appraise "rails-6.0" do
14
+ gem "rails", "6.0.0"
15
+ end
16
+
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 `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
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
@@ -4,6 +4,7 @@ IFS=$'\n\t'
4
4
  set -vx
5
5
 
6
6
  bundle install
7
+ bundle exec appraisal install
7
8
 
8
9
  # Do any other automated setup that you need to do here
9
10
 
@@ -0,0 +1,2 @@
1
+ ---
2
+ BUNDLE_RETRY: "1"
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "5.0.7.2"
6
+
7
+ gemspec path: "../"
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "5.1.7"
6
+
7
+ gemspec path: "../"
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "5.2.3"
6
+
7
+ gemspec path: "../"
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "6.0.0"
6
+
7
+ gemspec path: "../"
@@ -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,7 +1,7 @@
1
1
  require "active_record/migration/compatibility"
2
2
 
3
3
  module PgHaMigrations::AllowedVersions
4
- ALLOWED_VERSIONS = [4.2, 5.0, 5.1, 5.2].map do |v|
4
+ ALLOWED_VERSIONS = [4.2, 5.0, 5.1, 5.2, 6.0].map do |v|
5
5
  begin
6
6
  ActiveRecord::Migration[v]
7
7
  rescue ArgumentError
@@ -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
- "#{database} | tables (#{tables_with_locks.join(', ')}) have been locked for #{transaction_age} | query: #{current_query}"
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
- pid_column, state_column = if ActiveRecord::Base.connection.select_value("SHOW server_version") =~ /9\.1/
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
- raw_query = <<-SQL
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.#{state_column} as current_query,
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 (l.relation = c.oid) -- Database wide
33
- JOIN pg_namespace ns ON (c.relnamespace = ns.oid) -- Database wide
34
- WHERE psa.#{pid_column} != pg_backend_pid()
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 = 0
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
- GROUP BY psa.datname, psa.#{state_column}, psa.xact_start
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 << <<-eos.strip_heredoc.lines.map { |line| "\t#{line}" }.join
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? :default
30
- raise PgHaMigrations::UnsafeMigrationError.new(":default is NOT SAFE! Use safe_change_column_default afterwards then backfill the data to prevent locking the table")
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
- quote_default_expression(default_value, column) == "NULL"
51
- raise PgHaMigrations::InvalidMigrationError, "Requested new default value of <#{default_value}>, but that casts to NULL for the type <#{column.type}>. Did you mean to you mean to use a Proc instead?"
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 >= 90600
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 >= 90300 ? :adjust_lock_timeout : :adjust_statement_timeout
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
@@ -1,3 +1,3 @@
1
1
  module PgHaMigrations
2
- VERSION = "1.1.0"
2
+ VERSION = "1.2.4"
3
3
  end
@@ -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 'pry'
38
- spec.add_development_dependency 'pry-byebug'
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", "< 5.3"
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.1.0
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: 2019-09-05 00:00:00.000000000 Z
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: '5.3'
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: '5.3'
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: '0'
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: '0'
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.0.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