activerecord-pg-extensions 0.2.0 → 0.3.0

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: abfb428bb45f993d1a1ee897e81be0372dafc62b5a345c433eb9e69c821ba100
4
- data.tar.gz: d8a2f84e3a2a70576d165e1ad56a17d2cb122acc1bf784a9a17c969cc3f018a8
3
+ metadata.gz: 3b240f7e9306ba57d7f52add8cef37ebe735e2f3d900d328d7cfb2e22fe1fcc6
4
+ data.tar.gz: 04b62ecf9b4c250e2f664f1a0be0c71baac1b6f5d117dc8d45f08bb7b7100459
5
5
  SHA512:
6
- metadata.gz: 4e83d36db41c570b469ccac0e931b0a3ce04536319b9a357e28225b6c3adb613d1cd300993b7eb9c45733ea54e2b3c3b2ab79a51a932c428aa054dae13fb138d
7
- data.tar.gz: 0f404b7d50dd5e48cdf729987888c164f133301da6b1be5867fa6d8c1aa3ba5223eae556778c082ad31afb10bccdc2eecba1f166f69c3b12311630047dea2a55
6
+ metadata.gz: c5a1ae9925eb093ea81424574d409186c85ef2474b1cc2a38f388757ff4e94a8b3893ca7e582e5010e8dbe8915a2e499d1600103c12936169d0447703b5c8d68
7
+ data.tar.gz: 256b8740a417e1bcc84a7edd23663da83543aa412a7221ae2a818721dd04a16b7ccf3f06915cb625d50f95ecb55ccd0719e1d8204441294dd9860ba25cb54b6a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.3] - 2021-06-23
4
+
5
+ - Fix extension_available?
6
+
7
+ ## [0.2.2] - 2021-06-22
8
+
9
+ - Fix bug in Ruby 2.6 calling format wrong.
10
+
11
+ ## [0.2.1] - 2021-06-22
12
+
13
+ - Ensure numeric is in the PG type map for Rails 6.0. So that lsn_diff will
14
+ return a numeric, instead of a string.
15
+
3
16
  ## [0.2.0] - 2021-06-07
4
17
 
5
18
  - Add PostgreSQLAdapter#set_replica_identity
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ class QueryTimeout < ActiveRecord::StatementInvalid; end
5
+ end
@@ -6,20 +6,36 @@ module ActiveRecord
6
6
  # to executing, in order to reduce the amount of time the actual DDL takes to
7
7
  # execute (and thus how long it needs the lock)
8
8
  module PessimisticMigrations
9
- # does a query first to warm the db cache, to make the actual constraint adding fast
9
+ # adds a temporary check constraint to reduce locking when changing to NOT NULL, and we're not in a transaction
10
10
  def change_column_null(table, column, nullness, default = nil)
11
- # no point in pre-warming the cache to avoid locking if we're already in a transaction
11
+ # no point in doing extra work to avoid locking if we're already in a transaction
12
12
  return super if nullness != false || open_transactions.positive?
13
+ return if columns(table).find { |c| c.name == column.to_s }&.null == false
14
+
15
+ temp_constraint_name = "chk_rails_#{table}_#{column}_not_null"
16
+ scope = quoted_scope(temp_constraint_name)
17
+ # check for temp constraint
18
+ valid = select_value(<<~SQL, "SCHEMA")
19
+ SELECT convalidated FROM pg_constraint INNER JOIN pg_namespace ON pg_namespace.oid=connamespace WHERE conname=#{scope[:name]} AND nspname=#{scope[:schema]}
20
+ SQL
21
+ if valid.nil?
22
+ add_check_constraint(table,
23
+ "#{quote_column_name(column)} IS NOT NULL",
24
+ name: temp_constraint_name,
25
+ validate: false)
26
+ end
27
+ begin
28
+ validate_constraint(table, temp_constraint_name)
29
+ rescue ActiveRecord::StatementInvalid => e
30
+ raise ActiveRecord::NotNullViolation.new(sql: e.sql, binds: e.binds) if e.cause.is_a?(PG::CheckViolation)
31
+
32
+ raise
33
+ end
13
34
 
14
35
  transaction do
15
- # make sure the query ignores indexes, because the actual ALTER TABLE will also ignore
16
- # indexes
17
- execute("SET LOCAL enable_indexscan=off")
18
- execute("SET LOCAL enable_bitmapscan=off")
19
- execute("SELECT COUNT(*) FROM #{quote_table_name(table)} WHERE #{quote_column_name(column)} IS NULL")
20
- raise ActiveRecord::Rollback
36
+ super
37
+ remove_check_constraint(table, name: temp_constraint_name)
21
38
  end
22
- super
23
39
  end
24
40
 
25
41
  # several improvements:
@@ -47,6 +47,7 @@ module ActiveRecord
47
47
  sql << " VERSION #{quote(version)}" if version
48
48
  sql << " CASCADE" if cascade
49
49
  execute(sql)
50
+ reload_type_map
50
51
  @extensions&.delete(extension.to_s)
51
52
  end
52
53
 
@@ -61,6 +62,7 @@ module ActiveRecord
61
62
  sql << " TO #{quote(version)}" if version && version != true
62
63
  sql << " SET SCHEMA #{schema}" if schema
63
64
  execute(sql)
65
+ reload_type_map
64
66
  @extensions&.delete(extension.to_s)
65
67
  end
66
68
 
@@ -73,13 +75,14 @@ module ActiveRecord
73
75
  sql << extensions.join(", ")
74
76
  sql << " CASCADE" if cascade
75
77
  execute(sql)
78
+ reload_type_map
76
79
  @extensions&.except!(*extensions.map(&:to_s))
77
80
  end
78
81
 
79
82
  # check if a particular extension can be installed
80
83
  def extension_available?(extension, version = nil)
81
84
  sql = +"SELECT 1 FROM "
82
- sql << version ? "pg_available_extensions" : "pg_available_extension_versions"
85
+ sql << (version ? "pg_available_extension_versions" : "pg_available_extensions")
83
86
  sql << " WHERE name=#{quote(extension)}"
84
87
  sql << " AND version=#{quote(version)}" if version
85
88
  select_value(sql).to_i == 1
@@ -233,8 +236,77 @@ module ActiveRecord
233
236
  select_value("SELECT pg_is_in_recovery()")
234
237
  end
235
238
 
239
+ def with_statement_timeout(timeout = nil)
240
+ timeout = 30 if timeout.nil? || timeout == true
241
+ transaction do
242
+ execute("SET LOCAL statement_timeout=#{(timeout * 1000).to_i}")
243
+ yield
244
+ rescue ActiveRecord::StatementInvalid => e
245
+ raise ActiveRecord::QueryTimeout.new(sql: e.sql, binds: e.binds) if e.cause.is_a?(PG::QueryCanceled)
246
+
247
+ raise
248
+ end
249
+ end
250
+
251
+ unless ::Rails.version >= "6.1"
252
+ def add_check_constraint(table_name, expression, name:, validate: true)
253
+ sql = +"ALTER TABLE #{quote_table_name(table_name)} ADD CONSTRAINT #{quote_column_name(name)} CHECK (#{expression})" # rubocop:disable Layout/LineLength
254
+ sql << " NOT VALID" unless validate
255
+ execute(sql)
256
+ end
257
+
258
+ def remove_check_constraint(table_name, name:)
259
+ execute("ALTER TABLE #{quote_table_name(table_name)} DROP CONSTRAINT #{quote_column_name(name)}")
260
+ end
261
+ end
262
+
236
263
  private
237
264
 
265
+ if ::Rails.version < "6.1"
266
+ # significant change: add PG::TextDecoder::Numeric
267
+ def add_pg_decoders
268
+ @default_timezone = nil
269
+ @timestamp_decoder = nil
270
+
271
+ coders_by_name = {
272
+ "int2" => PG::TextDecoder::Integer,
273
+ "int4" => PG::TextDecoder::Integer,
274
+ "int8" => PG::TextDecoder::Integer,
275
+ "oid" => PG::TextDecoder::Integer,
276
+ "float4" => PG::TextDecoder::Float,
277
+ "float8" => PG::TextDecoder::Float,
278
+ "bool" => PG::TextDecoder::Boolean,
279
+ "numeric" => PG::TextDecoder::Numeric
280
+ }
281
+
282
+ if defined?(PG::TextDecoder::TimestampUtc)
283
+ # Use native PG encoders available since pg-1.1
284
+ coders_by_name["timestamp"] = PG::TextDecoder::TimestampUtc
285
+ coders_by_name["timestamptz"] = PG::TextDecoder::TimestampWithTimeZone
286
+ end
287
+
288
+ known_coder_types = coders_by_name.keys.map { |n| quote(n) }
289
+ query = format(<<~SQL, *known_coder_types.join(", "))
290
+ SELECT t.oid, t.typname
291
+ FROM pg_type as t
292
+ WHERE t.typname IN (%s)
293
+ SQL
294
+ coders = execute_and_clear(query, "SCHEMA", []) do |result|
295
+ result
296
+ .map { |row| construct_coder(row, coders_by_name[row["typname"]]) }
297
+ .compact
298
+ end
299
+
300
+ map = PG::TypeMapByOid.new
301
+ coders.each { |coder| map.add_coder(coder) }
302
+ @connection.type_map_for_results = map
303
+
304
+ # extract timestamp decoder for use in update_typemap_for_default_timezone
305
+ @timestamp_decoder = coders.find { |coder| coder.name == "timestamp" }
306
+ update_typemap_for_default_timezone
307
+ end
308
+ end
309
+
238
310
  def initialize_type_map(map = type_map)
239
311
  map.register_type "pg_lsn", ActiveRecord::ConnectionAdapters::PostgreSQL::OID::SpecializedString.new(:pg_lsn)
240
312
 
@@ -8,9 +8,10 @@ module ActiveRecord
8
8
  class Railtie < Rails::Railtie
9
9
  initializer "pg_extensions.extend_ar", after: "active_record.initialize_database" do
10
10
  ActiveSupport.on_load(:active_record) do
11
+ require "active_record/pg_extensions/errors"
11
12
  require "active_record/pg_extensions/postgresql_adapter"
12
13
 
13
- ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(PostgreSQLAdapter)
14
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(PostgreSQLAdapter)
14
15
  # if they've already require 'all', then inject now
15
16
  defined?(All) && All.inject
16
17
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module PGExtensions
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -6,32 +6,61 @@ describe ActiveRecord::PGExtensions::PessimisticMigrations do
6
6
  end
7
7
 
8
8
  describe "#change_column_null" do
9
+ # Rails 6.1 doesn't quote the constraint name when adding a check constraint??
10
+ def quote_constraint_name(name)
11
+ Rails.version >= "6.1" ? name : connection.quote_column_name(name)
12
+ end
13
+
9
14
  it "does nothing extra when changing a column to nullable" do
10
15
  connection.change_column_null(:table, :column, true)
11
16
  expect(connection.executed_statements).to eq ['ALTER TABLE "table" ALTER COLUMN "column" DROP NOT NULL']
12
17
  end
13
18
 
14
- it "pre-warms the cache" do
19
+ it "does nothing if we're in a transaction" do
20
+ connection.transaction do
21
+ connection.change_column_null(:table, :column, true)
22
+ end
23
+ expect(connection.executed_statements).to eq ["BEGIN",
24
+ 'ALTER TABLE "table" ALTER COLUMN "column" DROP NOT NULL',
25
+ "COMMIT"]
26
+ end
27
+
28
+ it "skips entirely if the column is already NOT NULL" do
29
+ expect(connection).to receive(:columns).with(:table).and_return([double(name: "column", null: false)])
15
30
  connection.change_column_null(:table, :column, false)
16
- expect(connection.executed_statements).to eq(
17
- ["BEGIN",
18
- "SET LOCAL enable_indexscan=off",
19
- "SET LOCAL enable_bitmapscan=off",
20
- 'SELECT COUNT(*) FROM "table" WHERE "column" IS NULL',
21
- "ROLLBACK",
22
- 'ALTER TABLE "table" ALTER COLUMN "column" SET NOT NULL']
23
- )
31
+ expect(connection.executed_statements).to eq([])
24
32
  end
25
33
 
26
- it "does nothing extra if a transaction is already active" do
27
- connection.transaction do
28
- connection.change_column_null(:table, :column, false)
29
- end
30
- expect(connection.executed_statements).to eq(
31
- ["BEGIN",
32
- 'ALTER TABLE "table" ALTER COLUMN "column" SET NOT NULL',
33
- "COMMIT"]
34
- )
34
+ it "adds and removes a check constraint" do
35
+ expect(connection).to receive(:columns).and_return([])
36
+ allow(connection).to receive(:check_constraint_for!).and_return(double(name: "chk_rails_table_column_not_null"))
37
+ connection.change_column_null(:table, :column, false)
38
+
39
+ expect(connection.executed_statements).to eq [
40
+ "SELECT convalidated FROM pg_constraint INNER JOIN pg_namespace ON pg_namespace.oid=connamespace WHERE conname='chk_rails_table_column_not_null' AND nspname=ANY (current_schemas(false))\n", # rubocop:disable Layout/LineLength
41
+ %{ALTER TABLE "table" ADD CONSTRAINT #{quote_constraint_name('chk_rails_table_column_not_null')} CHECK ("column" IS NOT NULL) NOT VALID}, # rubocop:disable Layout/LineLength
42
+ 'ALTER TABLE "table" VALIDATE CONSTRAINT "chk_rails_table_column_not_null"',
43
+ "BEGIN",
44
+ 'ALTER TABLE "table" ALTER COLUMN "column" SET NOT NULL',
45
+ 'ALTER TABLE "table" DROP CONSTRAINT "chk_rails_table_column_not_null"',
46
+ "COMMIT"
47
+ ]
48
+ end
49
+
50
+ it "verifies an existing check constraint" do
51
+ expect(connection).to receive(:columns).and_return([])
52
+ allow(connection).to receive(:check_constraint_for!).and_return(double(name: "chk_rails_table_column_not_null"))
53
+ expect(connection).to receive(:select_value).and_return(false)
54
+ connection.change_column_null(:table, :column, false)
55
+
56
+ expect(connection.executed_statements).to eq [
57
+ # stubbed out <check constraint valid>
58
+ 'ALTER TABLE "table" VALIDATE CONSTRAINT "chk_rails_table_column_not_null"',
59
+ "BEGIN",
60
+ 'ALTER TABLE "table" ALTER COLUMN "column" SET NOT NULL',
61
+ 'ALTER TABLE "table" DROP CONSTRAINT "chk_rails_table_column_not_null"',
62
+ "COMMIT"
63
+ ]
35
64
  end
36
65
  end
37
66
 
@@ -92,8 +121,8 @@ describe ActiveRecord::PGExtensions::PessimisticMigrations do
92
121
  expect(connection).to receive(:remove_index).with(:users, name: "index_users_on_name", algorithm: :concurrently)
93
122
 
94
123
  connection.add_index :users, :name, algorithm: :concurrently
95
- expect(connection.executed_statements).to eq(
96
- ['CREATE INDEX CONCURRENTLY "index_users_on_name" ON "users" ("name")']
124
+ expect(connection.executed_statements).to match(
125
+ [match(/\ACREATE +INDEX CONCURRENTLY "index_users_on_name" ON "users" +\("name"\)\z/)]
97
126
  )
98
127
  end
99
128
 
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  describe ActiveRecord::ConnectionAdapters::PostgreSQLAdapter do
4
+ before do
5
+ allow(connection).to receive(:reload_type_map)
6
+ end
7
+
4
8
  describe "#set_constraints" do
5
9
  around do |example|
6
10
  connection.dont_execute(&example)
@@ -131,6 +135,20 @@ describe ActiveRecord::ConnectionAdapters::PostgreSQLAdapter do
131
135
  it "does not allow dropping no extensions" do
132
136
  expect { connection.drop_extension }.to raise_error(ArgumentError)
133
137
  end
138
+
139
+ describe "#extension_available?" do
140
+ it "works with no version constraint" do
141
+ connection.extension_available?(:postgis)
142
+ expect(connection.executed_statements).to eq ["SELECT 1 FROM pg_available_extensions WHERE name='postgis'"]
143
+ end
144
+
145
+ it "works with a version constraint" do
146
+ connection.extension_available?(:postgis, "2.0")
147
+ expect(connection.executed_statements).to eq(
148
+ ["SELECT 1 FROM pg_available_extension_versions WHERE name='postgis' AND version='2.0'"]
149
+ )
150
+ end
151
+ end
134
152
  end
135
153
  end
136
154
 
@@ -243,5 +261,110 @@ describe ActiveRecord::ConnectionAdapters::PostgreSQLAdapter do
243
261
  expect(connection.in_recovery?).to eq false
244
262
  end
245
263
  end
264
+
265
+ describe "#select_value" do
266
+ it "casts numeric types" do
267
+ expect(connection.select_value("SELECT factorial(2)")).to eq 2
268
+ end
269
+ end
270
+ end
271
+
272
+ describe "#with_statement_timeout" do
273
+ it "stops long-running queries" do
274
+ expect do
275
+ connection.with_statement_timeout(0.01) do
276
+ connection.execute("SELECT pg_sleep(3)")
277
+ end
278
+ end.to raise_error(ActiveRecord::QueryTimeout)
279
+ end
280
+
281
+ it "re-raises other errors" do
282
+ expect do
283
+ connection.with_statement_timeout(1) do
284
+ connection.execute("bad sql")
285
+ end
286
+ end.to raise_error(ActiveRecord::StatementInvalid)
287
+ end
288
+
289
+ context "without executing" do
290
+ it "converts integer to ms" do
291
+ connection.with_statement_timeout(30) { nil }
292
+ expect(connection.executed_statements).to eq(
293
+ [
294
+ "BEGIN",
295
+ "SET LOCAL statement_timeout=30000",
296
+ "COMMIT"
297
+ ]
298
+ )
299
+ end
300
+
301
+ it "converts float to ms" do
302
+ connection.with_statement_timeout(5.5) { nil }
303
+ expect(connection.executed_statements).to eq(
304
+ [
305
+ "BEGIN",
306
+ "SET LOCAL statement_timeout=5500",
307
+ "COMMIT"
308
+ ]
309
+ )
310
+ end
311
+
312
+ it "converts ActiveSupport::Duration to ms" do
313
+ connection.with_statement_timeout(5.seconds) { nil }
314
+ expect(connection.executed_statements).to eq(
315
+ [
316
+ "BEGIN",
317
+ "SET LOCAL statement_timeout=5000",
318
+ "COMMIT"
319
+ ]
320
+ )
321
+ end
322
+
323
+ it "allows true" do
324
+ connection.with_statement_timeout(true) { nil }
325
+ expect(connection.executed_statements).to eq(
326
+ [
327
+ "BEGIN",
328
+ "SET LOCAL statement_timeout=30000",
329
+ "COMMIT"
330
+ ]
331
+ )
332
+ end
333
+ end
334
+ end
335
+
336
+ unless Rails.version >= "6.1"
337
+ describe "#add_check_constraint" do
338
+ around do |example|
339
+ connection.dont_execute(&example)
340
+ end
341
+
342
+ it "works" do
343
+ connection.add_check_constraint(:table, "column IS NOT NULL", name: :my_constraint)
344
+ expect(connection.executed_statements).to eq(
345
+ ['ALTER TABLE "table" ADD CONSTRAINT "my_constraint" CHECK (column IS NOT NULL)']
346
+ )
347
+ end
348
+
349
+ it "defers validation" do
350
+ connection.add_check_constraint(:table, "column IS NOT NULL", name: :my_constraint, validate: false)
351
+ expect(connection.executed_statements).to eq(
352
+ ['ALTER TABLE "table" ADD CONSTRAINT "my_constraint" CHECK (column IS NOT NULL) NOT VALID']
353
+ )
354
+ end
355
+ end
356
+
357
+ describe "#remove_check_constraint" do
358
+ around do |example|
359
+ connection.dont_execute(&example)
360
+ end
361
+
362
+ it "works" do
363
+ connection.remove_check_constraint(:table, name: :my_constraint)
364
+ expect(connection.executed_statements).to eq(
365
+ ['ALTER TABLE "table" DROP CONSTRAINT "my_constraint"']
366
+ )
367
+ end
368
+ end
246
369
  end
247
370
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-pg-extensions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cody Cutrer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-06-07 00:00:00.000000000 Z
11
+ date: 2021-09-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -50,6 +50,20 @@ dependencies:
50
50
  - - "<"
51
51
  - !ruby/object:Gem::Version
52
52
  version: '6.2'
53
+ - !ruby/object:Gem::Dependency
54
+ name: appraisal
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.4'
60
+ type: :development
61
+ prerelease: false
62
+ version_requirements: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '2.4'
53
67
  - !ruby/object:Gem::Dependency
54
68
  name: byebug
55
69
  requirement: !ruby/object:Gem::Requirement
@@ -161,6 +175,7 @@ files:
161
175
  - config/database.yml
162
176
  - lib/active_record/pg_extensions.rb
163
177
  - lib/active_record/pg_extensions/all.rb
178
+ - lib/active_record/pg_extensions/errors.rb
164
179
  - lib/active_record/pg_extensions/extension.rb
165
180
  - lib/active_record/pg_extensions/pessimistic_migrations.rb
166
181
  - lib/active_record/pg_extensions/postgresql_adapter.rb
@@ -190,7 +205,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
190
205
  - !ruby/object:Gem::Version
191
206
  version: '0'
192
207
  requirements: []
193
- rubygems_version: 3.2.15
208
+ rubygems_version: 3.2.24
194
209
  signing_key:
195
210
  specification_version: 4
196
211
  summary: Several extensions to ActiveRecord's PostgreSQLAdapter.