activerecord-pg-extensions 0.2.3 → 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: 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.