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