activerecord-pg-extensions 0.2.3 → 0.4.2

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: b19575bd4ea9eed35769514cf347333954d95087f57a65af0fc76f03e509ad96
4
- data.tar.gz: 7c9e81b01d65797f3a47b344f89089f8d4f7788a5ae6e0ef2301165e370a6111
3
+ metadata.gz: 36ff139014c4b0b2288bf7839ff1f5d87ee596edb2f7f758f4eb66e2513b6479
4
+ data.tar.gz: 9880dd828e1d1d216e5797d8d94450b6f0fb41b01d32af31554f511f352c86c3
5
5
  SHA512:
6
- metadata.gz: f32cdbdf6461d760ae956bd7cd7787fabff8d0b355c2c911e63e33c73357ce7170df4da05b1de5908612830e0bdaf754bba2cb8f9fe518d50d0064ccb5dfa5e9
7
- data.tar.gz: 548ed3a2d3d90d98a68c5c637f009ae615e21199f764e88ed8ea954952a6ffa46036e022a5a514e5500f8d17ff22fe33080420209e38f9732cde67724a1218ed
6
+ metadata.gz: f7f15fbbf3b6475a894dc44d27380bdb141f25552d3c618550ab508e7e5d59d88b2d0dba297f40ca1d4ef030811942c00308674bdff89ac92a1a78a8c4d125a8
7
+ data.tar.gz: 69c17fc5576db6f1205b2a4819a87a26ac09fc3d98e43061e556cfb6b115d53e4ed326038632e2c2a219507e447e3e3d3199e879dbc97273c84227d25e2da27a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2021-09-29
4
+
5
+ - Deprecate with_statement_timeout
6
+ - Add statement_timeout, lock_timeout, and idle_in_transaction_session_timeout
3
7
  ## [0.2.3] - 2021-06-23
4
8
 
5
9
  - Fix extension_available?
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ # @deprecated just use QueryCanceled
5
+ QueryTimeout = QueryCanceled
6
+ 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,56 @@ module ActiveRecord
233
236
  select_value("SELECT pg_is_in_recovery()")
234
237
  end
235
238
 
239
+ def set(configuration_parameter, value, local: false)
240
+ value = value.nil? ? "DEFAULT" : quote(value)
241
+ execute("SET#{' LOCAL' if local} #{configuration_parameter} TO #{value}")
242
+ end
243
+
244
+ def reset(configuration_parameter)
245
+ execute("RESET #{configuration_parameter}")
246
+ end
247
+
248
+ TIMEOUTS = %i[lock_timeout statement_timeout idle_in_transaction_session_timeout].freeze
249
+
250
+ TIMEOUTS.each do |kind|
251
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
252
+ def #{kind}
253
+ current_transaction.#{kind}
254
+ end
255
+
256
+ def #{kind}=(timeout)
257
+ raise ArgumentError, "Timeouts can only be set inside of a transaction" unless current_transaction.open?
258
+
259
+ current_transaction.send(:#{kind}=, timeout)
260
+ end
261
+ RUBY
262
+ end
263
+
264
+ # @deprecated: manage the transaction yourself and set statement_timeout directly
265
+ #
266
+ # otherwise, if you're already in a transaction, or you nest with_statement_timeout,
267
+ # the value will unexpectedly "stick" even after the block returns
268
+ def with_statement_timeout(timeout = nil)
269
+ timeout = 30 if timeout.nil? || timeout == true
270
+
271
+ transaction do
272
+ self.statement_timeout = timeout
273
+ yield
274
+ end
275
+ end
276
+
277
+ unless ::Rails.version >= "6.1"
278
+ def add_check_constraint(table_name, expression, name:, validate: true)
279
+ sql = +"ALTER TABLE #{quote_table_name(table_name)} ADD CONSTRAINT #{quote_column_name(name)} CHECK (#{expression})" # rubocop:disable Layout/LineLength
280
+ sql << " NOT VALID" unless validate
281
+ execute(sql)
282
+ end
283
+
284
+ def remove_check_constraint(table_name, name:)
285
+ execute("ALTER TABLE #{quote_table_name(table_name)} DROP CONSTRAINT #{quote_column_name(name)}")
286
+ end
287
+ end
288
+
236
289
  private
237
290
 
238
291
  if ::Rails.version < "6.1"
@@ -8,9 +8,13 @@ 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"
13
+ require "active_record/pg_extensions/transaction"
12
14
 
13
- ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(PostgreSQLAdapter)
15
+ ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(PostgreSQLAdapter)
16
+ ::ActiveRecord::ConnectionAdapters::NullTransaction.prepend(NullTransaction)
17
+ ::ActiveRecord::ConnectionAdapters::Transaction.prepend(Transaction)
14
18
  # if they've already require 'all', then inject now
15
19
  defined?(All) && All.inject
16
20
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module PGExtensions
5
+ # Contains general additions to Transaction
6
+ module Transaction
7
+ PostgreSQLAdapter::TIMEOUTS.each do |kind|
8
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
9
+ def #{kind}(local: false)
10
+ return @#{kind} if local
11
+ @#{kind} || parent_transaction.#{kind}
12
+ end
13
+
14
+ private def #{kind}=(timeout)
15
+ return if @#{kind} == timeout
16
+
17
+ @#{kind} = timeout
18
+ return unless materialized?
19
+ connection.set(#{kind.inspect}, "\#{(timeout * 1000).to_i}ms", local: true)
20
+ end
21
+ RUBY
22
+ end
23
+
24
+ def materialize!
25
+ PostgreSQLAdapter::TIMEOUTS.each do |kind|
26
+ next if (timeout = send(kind, local: true)).nil?
27
+
28
+ connection.set(kind, "#{(timeout * 1000).to_i}ms", local: true)
29
+ end
30
+ super
31
+ end
32
+ end
33
+
34
+ # Contains general additions to NullTransaction
35
+ module NullTransaction
36
+ PostgreSQLAdapter::TIMEOUTS.each do |kind|
37
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
38
+ def #{kind}
39
+ nil
40
+ end
41
+ RUBY
42
+ end
43
+ end
44
+ end
45
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module PGExtensions
5
- VERSION = "0.2.3"
5
+ VERSION = "0.4.2"
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,190 @@ describe ActiveRecord::ConnectionAdapters::PostgreSQLAdapter do
264
268
  end
265
269
  end
266
270
  end
271
+
272
+ describe "#with_statement_timeout" do
273
+ around do |example|
274
+ # these specs were written before we supported deferring setting timeouts
275
+ # until the transaction materializes
276
+ connection.disable_lazy_transactions!
277
+ example.call
278
+ connection.enable_lazy_transactions!
279
+ end
280
+
281
+ it "stops long-running queries" do
282
+ expect do
283
+ connection.with_statement_timeout(0.01) do
284
+ connection.execute("SELECT pg_sleep(3)")
285
+ end
286
+ end.to raise_error(ActiveRecord::QueryCanceled)
287
+ end
288
+
289
+ it "re-raises other errors" do
290
+ expect do
291
+ connection.with_statement_timeout(1) do
292
+ connection.execute("bad sql")
293
+ end
294
+ end.to(raise_error { |e| expect(e.cause).to be_a(PG::SyntaxError) })
295
+ end
296
+
297
+ context "without executing" do
298
+ around do |example|
299
+ connection.dont_execute(&example)
300
+ end
301
+
302
+ it "converts integer to ms" do
303
+ connection.with_statement_timeout(30) { nil }
304
+ expect(connection.executed_statements).to eq(
305
+ [
306
+ "BEGIN",
307
+ "SET LOCAL statement_timeout TO '30000ms'",
308
+ "COMMIT"
309
+ ]
310
+ )
311
+ end
312
+
313
+ it "converts float to ms" do
314
+ connection.with_statement_timeout(5.5) { nil }
315
+ expect(connection.executed_statements).to eq(
316
+ [
317
+ "BEGIN",
318
+ "SET LOCAL statement_timeout TO '5500ms'",
319
+ "COMMIT"
320
+ ]
321
+ )
322
+ end
323
+
324
+ it "converts ActiveSupport::Duration to ms" do
325
+ connection.with_statement_timeout(5.seconds) { nil }
326
+ expect(connection.executed_statements).to eq(
327
+ [
328
+ "BEGIN",
329
+ "SET LOCAL statement_timeout TO '5000ms'",
330
+ "COMMIT"
331
+ ]
332
+ )
333
+ end
334
+
335
+ it "allows true" do
336
+ connection.with_statement_timeout(true) { nil }
337
+ expect(connection.executed_statements).to eq(
338
+ [
339
+ "BEGIN",
340
+ "SET LOCAL statement_timeout TO '30000ms'",
341
+ "COMMIT"
342
+ ]
343
+ )
344
+ end
345
+ end
346
+ end
347
+
348
+ describe "#statement_timeout=" do
349
+ around do |example|
350
+ connection.dont_execute(&example)
351
+ end
352
+
353
+ it "raises if a transaction isn't active" do
354
+ expect { connection.statement_timeout = 30 }.to raise_error(ArgumentError)
355
+ end
356
+
357
+ it "does nothing if the transaction never materializes" do
358
+ connection.transaction do
359
+ connection.statement_timeout = 30
360
+ expect(connection.statement_timeout).to eq 30
361
+ end
362
+ expect(connection.statement_timeout).to be_nil
363
+
364
+ expect(connection.executed_statements).to be_empty
365
+ end
366
+
367
+ it "sets the timeout if the transaction is materialized" do
368
+ connection.transaction do
369
+ connection.select_value("SELECT 1")
370
+ connection.statement_timeout = 30
371
+ expect(connection.statement_timeout).to eq 30
372
+ end
373
+ expect(connection.statement_timeout).to be_nil
374
+
375
+ expect(connection.executed_statements).to eq(
376
+ ["BEGIN",
377
+ "SELECT 1",
378
+ "SET LOCAL statement_timeout TO '30000ms'",
379
+ "COMMIT"]
380
+ )
381
+ end
382
+
383
+ it "sets the timeout if the transaction materializes" do
384
+ connection.transaction do
385
+ connection.statement_timeout = 30
386
+ connection.select_value("SELECT 1")
387
+ expect(connection.statement_timeout).to eq 30
388
+ end
389
+ expect(connection.statement_timeout).to be_nil
390
+
391
+ expect(connection.executed_statements).to eq(
392
+ ["BEGIN",
393
+ "SET LOCAL statement_timeout TO '30000ms'",
394
+ "SELECT 1",
395
+ "COMMIT"]
396
+ )
397
+ end
398
+
399
+ it "works with nested transactions" do
400
+ connection.transaction do
401
+ connection.statement_timeout = 30
402
+ connection.transaction(requires_new: true) do
403
+ connection.statement_timeout = 15
404
+ connection.select_value("SELECT 1")
405
+ expect(connection.statement_timeout).to eq 15
406
+ end
407
+ expect(connection.statement_timeout).to eq 30
408
+ end
409
+ expect(connection.statement_timeout).to be_nil
410
+
411
+ expect(connection.executed_statements).to eq(
412
+ ["BEGIN",
413
+ "SET LOCAL statement_timeout TO '30000ms'",
414
+ "SAVEPOINT active_record_1",
415
+ "SET LOCAL statement_timeout TO '15000ms'",
416
+ "SELECT 1",
417
+ "RELEASE SAVEPOINT active_record_1",
418
+ "COMMIT"]
419
+ )
420
+ end
421
+ end
422
+
423
+ unless Rails.version >= "6.1"
424
+ describe "#add_check_constraint" do
425
+ around do |example|
426
+ connection.dont_execute(&example)
427
+ end
428
+
429
+ it "works" do
430
+ connection.add_check_constraint(:table, "column IS NOT NULL", name: :my_constraint)
431
+ expect(connection.executed_statements).to eq(
432
+ ['ALTER TABLE "table" ADD CONSTRAINT "my_constraint" CHECK (column IS NOT NULL)']
433
+ )
434
+ end
435
+
436
+ it "defers validation" do
437
+ connection.add_check_constraint(:table, "column IS NOT NULL", name: :my_constraint, validate: false)
438
+ expect(connection.executed_statements).to eq(
439
+ ['ALTER TABLE "table" ADD CONSTRAINT "my_constraint" CHECK (column IS NOT NULL) NOT VALID']
440
+ )
441
+ end
442
+ end
443
+
444
+ describe "#remove_check_constraint" do
445
+ around do |example|
446
+ connection.dont_execute(&example)
447
+ end
448
+
449
+ it "works" do
450
+ connection.remove_check_constraint(:table, name: :my_constraint)
451
+ expect(connection.executed_statements).to eq(
452
+ ['ALTER TABLE "table" DROP CONSTRAINT "my_constraint"']
453
+ )
454
+ end
455
+ end
456
+ end
267
457
  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.4.2
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-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -175,10 +175,12 @@ 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
181
182
  - lib/active_record/pg_extensions/railtie.rb
183
+ - lib/active_record/pg_extensions/transaction.rb
182
184
  - lib/active_record/pg_extensions/version.rb
183
185
  - lib/activerecord-pg-extensions.rb
184
186
  - spec/pessimistic_migrations_spec.rb
@@ -204,7 +206,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
204
206
  - !ruby/object:Gem::Version
205
207
  version: '0'
206
208
  requirements: []
207
- rubygems_version: 3.0.3
209
+ rubygems_version: 3.2.24
208
210
  signing_key:
209
211
  specification_version: 4
210
212
  summary: Several extensions to ActiveRecord's PostgreSQLAdapter.