activerecord-pg-extensions 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b19575bd4ea9eed35769514cf347333954d95087f57a65af0fc76f03e509ad96
4
- data.tar.gz: 7c9e81b01d65797f3a47b344f89089f8d4f7788a5ae6e0ef2301165e370a6111
3
+ metadata.gz: 3b240f7e9306ba57d7f52add8cef37ebe735e2f3d900d328d7cfb2e22fe1fcc6
4
+ data.tar.gz: 04b62ecf9b4c250e2f664f1a0be0c71baac1b6f5d117dc8d45f08bb7b7100459
5
5
  SHA512:
6
- metadata.gz: f32cdbdf6461d760ae956bd7cd7787fabff8d0b355c2c911e63e33c73357ce7170df4da05b1de5908612830e0bdaf754bba2cb8f9fe518d50d0064ccb5dfa5e9
7
- data.tar.gz: 548ed3a2d3d90d98a68c5c637f009ae615e21199f764e88ed8ea954952a6ffa46036e022a5a514e5500f8d17ff22fe33080420209e38f9732cde67724a1218ed
6
+ metadata.gz: c5a1ae9925eb093ea81424574d409186c85ef2474b1cc2a38f388757ff4e94a8b3893ca7e582e5010e8dbe8915a2e499d1600103c12936169d0447703b5c8d68
7
+ data.tar.gz: 256b8740a417e1bcc84a7edd23663da83543aa412a7221ae2a818721dd04a16b7ccf3f06915cb625d50f95ecb55ccd0719e1d8204441294dd9860ba25cb54b6a
@@ -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,6 +75,7 @@ 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
 
@@ -233,6 +236,30 @@ 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
 
238
265
  if ::Rails.version < "6.1"
@@ -8,6 +8,7 @@ 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
14
  ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(PostgreSQLAdapter)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module PGExtensions
5
- VERSION = "0.2.3"
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
 
@@ -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)
@@ -264,4 +268,103 @@ describe ActiveRecord::ConnectionAdapters::PostgreSQLAdapter do
264
268
  end
265
269
  end
266
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
369
+ end
267
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.3
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-22 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
@@ -175,6 +175,7 @@ files:
175
175
  - config/database.yml
176
176
  - lib/active_record/pg_extensions.rb
177
177
  - lib/active_record/pg_extensions/all.rb
178
+ - lib/active_record/pg_extensions/errors.rb
178
179
  - lib/active_record/pg_extensions/extension.rb
179
180
  - lib/active_record/pg_extensions/pessimistic_migrations.rb
180
181
  - lib/active_record/pg_extensions/postgresql_adapter.rb
@@ -204,7 +205,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
204
205
  - !ruby/object:Gem::Version
205
206
  version: '0'
206
207
  requirements: []
207
- rubygems_version: 3.0.3
208
+ rubygems_version: 3.2.24
208
209
  signing_key:
209
210
  specification_version: 4
210
211
  summary: Several extensions to ActiveRecord's PostgreSQLAdapter.