activerecord-pg-extensions 0.1.1 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/active_record/pg_extensions/all.rb +16 -0
- data/lib/active_record/pg_extensions/extension.rb +10 -0
- data/lib/active_record/pg_extensions/pessimistic_migrations.rb +82 -0
- data/lib/active_record/pg_extensions/postgresql_adapter.rb +222 -0
- data/lib/active_record/pg_extensions/railtie.rb +2 -0
- data/lib/active_record/pg_extensions/version.rb +1 -1
- data/spec/pessimistic_migrations_spec.rb +107 -0
- data/spec/postgresql_adapter_spec.rb +204 -0
- data/spec/spec_helper.rb +17 -3
- metadata +6 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: abfb428bb45f993d1a1ee897e81be0372dafc62b5a345c433eb9e69c821ba100
|
4
|
+
data.tar.gz: d8a2f84e3a2a70576d165e1ad56a17d2cb122acc1bf784a9a17c969cc3f018a8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4e83d36db41c570b469ccac0e931b0a3ce04536319b9a357e28225b6c3adb613d1cd300993b7eb9c45733ea54e2b3c3b2ab79a51a932c428aa054dae13fb138d
|
7
|
+
data.tar.gz: 0f404b7d50dd5e48cdf729987888c164f133301da6b1be5867fa6d8c1aa3ba5223eae556778c082ad31afb10bccdc2eecba1f166f69c3b12311630047dea2a55
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,13 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.2.0] - 2021-06-07
|
4
|
+
|
5
|
+
- Add PostgreSQLAdapter#set_replica_identity
|
6
|
+
- Add methods for discovering and managing extensions
|
7
|
+
- Add method to temporarily add a schema to the search path
|
8
|
+
- Add PostgreSQLAdapter#vacuum
|
9
|
+
- Add optional module PessimisticMigrations
|
10
|
+
|
3
11
|
## [0.1.1] - 2021-06-07
|
4
12
|
|
5
13
|
- Add PostgreSQLAdapter#defer_constraints
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record/pg_extensions/pessimistic_migrations"
|
4
|
+
|
5
|
+
module ActiveRecord
|
6
|
+
module PGExtensions
|
7
|
+
# includes all optional extensions at once
|
8
|
+
module All
|
9
|
+
def self.inject
|
10
|
+
ConnectionAdapters::PostgreSQLAdapter.prepend(PessimisticMigrations)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
ActiveRecord::PGExtensions::All.inject if defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecord
|
4
|
+
module PGExtensions
|
5
|
+
# Changes several DDL commands to trigger background queries to warm caches prior
|
6
|
+
# to executing, in order to reduce the amount of time the actual DDL takes to
|
7
|
+
# execute (and thus how long it needs the lock)
|
8
|
+
module PessimisticMigrations
|
9
|
+
# does a query first to warm the db cache, to make the actual constraint adding fast
|
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
|
12
|
+
return super if nullness != false || open_transactions.positive?
|
13
|
+
|
14
|
+
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
|
21
|
+
end
|
22
|
+
super
|
23
|
+
end
|
24
|
+
|
25
|
+
# several improvements:
|
26
|
+
# * support if_not_exists
|
27
|
+
# * delay_validation automatically creates the FK as NOT VALID, and then immediately validates it
|
28
|
+
# * if delay_validation is used, and the index already exists but is NOT VALID, it just re-tries
|
29
|
+
# the validation, instead of failing
|
30
|
+
def add_foreign_key(from_table, to_table, delay_validation: false, if_not_exists: false, **options)
|
31
|
+
# pointless if we're in a transaction
|
32
|
+
delay_validation = false if open_transactions.positive?
|
33
|
+
options[:validate] = false if delay_validation
|
34
|
+
|
35
|
+
options = foreign_key_options(from_table, to_table, options)
|
36
|
+
|
37
|
+
if if_not_exists || delay_validation
|
38
|
+
scope = quoted_scope(options[:name])
|
39
|
+
valid = select_value(<<~SQL, "SCHEMA")
|
40
|
+
SELECT convalidated FROM pg_constraint INNER JOIN pg_namespace ON pg_namespace.oid=connamespace WHERE conname=#{scope[:name]} AND nspname=#{scope[:schema]}
|
41
|
+
SQL
|
42
|
+
return if valid == true && if_not_exists
|
43
|
+
end
|
44
|
+
|
45
|
+
super(from_table, to_table, **options) unless valid == false
|
46
|
+
validate_constraint(from_table, options[:name]) if delay_validation
|
47
|
+
end
|
48
|
+
|
49
|
+
# will automatically remove a NOT VALID index before trying to add
|
50
|
+
def add_index(table_name, column_name, options = {})
|
51
|
+
# catch a concurrent index add that fails because it already exists, and is invalid
|
52
|
+
if options[:algorithm] == :concurrently || options[:if_not_exists]
|
53
|
+
column_names = index_column_names(column_name)
|
54
|
+
index_name = options[:name].to_s if options.key?(:name)
|
55
|
+
index_name ||= index_name(table_name, column_names)
|
56
|
+
|
57
|
+
index = quoted_scope(index_name)
|
58
|
+
table = quoted_scope(table_name)
|
59
|
+
valid = select_value(<<~SQL, "SCHEMA")
|
60
|
+
SELECT indisvalid
|
61
|
+
FROM pg_class t
|
62
|
+
INNER JOIN pg_index d ON t.oid = d.indrelid
|
63
|
+
INNER JOIN pg_class i ON d.indexrelid = i.oid
|
64
|
+
WHERE i.relkind = 'i'
|
65
|
+
AND i.relname = #{index[:name]}
|
66
|
+
AND t.relname = #{table[:name]}
|
67
|
+
AND i.relnamespace IN (SELECT oid FROM pg_namespace WHERE nspname = #{index[:schema]} )
|
68
|
+
LIMIT 1
|
69
|
+
SQL
|
70
|
+
if valid == false && options[:algorithm] == :concurrently
|
71
|
+
remove_index(table_name, name: index_name,
|
72
|
+
algorithm: :concurrently)
|
73
|
+
end
|
74
|
+
return if options[:if_not_exists] && valid == true
|
75
|
+
end
|
76
|
+
# Rails.version: can stop doing this in Rails 6.2, when it's natively supported
|
77
|
+
options.delete(:if_not_exists)
|
78
|
+
super
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "active_record/pg_extensions/extension"
|
4
|
+
|
3
5
|
module ActiveRecord
|
4
6
|
module PGExtensions
|
5
7
|
# Contains general additions to the PostgreSQLAdapter
|
@@ -24,6 +26,226 @@ module ActiveRecord
|
|
24
26
|
yield
|
25
27
|
set_constraints(:immediate, *constraints)
|
26
28
|
end
|
29
|
+
|
30
|
+
# see https://www.postgresql.org/docs/current/sql-altertable.html#SQL-CREATETABLE-REPLICA-IDENTITY
|
31
|
+
def set_replica_identity(table, identity = :default)
|
32
|
+
identity_clause = case identity
|
33
|
+
when :default, :full, :nothing
|
34
|
+
identity.to_s.upcase
|
35
|
+
else
|
36
|
+
"USING INDEX #{quote_column_name(identity)}"
|
37
|
+
end
|
38
|
+
execute("ALTER TABLE #{quote_table_name(table)} REPLICA IDENTITY #{identity_clause}")
|
39
|
+
end
|
40
|
+
|
41
|
+
# see https://www.postgresql.org/docs/current/sql-createextension.html
|
42
|
+
def create_extension(extension, if_not_exists: false, schema: nil, version: nil, cascade: false)
|
43
|
+
sql = +"CREATE EXTENSION "
|
44
|
+
sql <<= "IF NOT EXISTS " if if_not_exists
|
45
|
+
sql << extension.to_s
|
46
|
+
sql << " SCHEMA #{schema}" if schema
|
47
|
+
sql << " VERSION #{quote(version)}" if version
|
48
|
+
sql << " CASCADE" if cascade
|
49
|
+
execute(sql)
|
50
|
+
@extensions&.delete(extension.to_s)
|
51
|
+
end
|
52
|
+
|
53
|
+
# see https://www.postgresql.org/docs/current/sql-alterextension.html
|
54
|
+
def alter_extension(extension, schema: nil, version: nil)
|
55
|
+
if schema && version
|
56
|
+
raise ArgumentError, "Cannot change schema and upgrade to a particular version in a single statement"
|
57
|
+
end
|
58
|
+
|
59
|
+
sql = +"ALTER EXTENSION #{extension}"
|
60
|
+
sql << " UPDATE" if version
|
61
|
+
sql << " TO #{quote(version)}" if version && version != true
|
62
|
+
sql << " SET SCHEMA #{schema}" if schema
|
63
|
+
execute(sql)
|
64
|
+
@extensions&.delete(extension.to_s)
|
65
|
+
end
|
66
|
+
|
67
|
+
# see https://www.postgresql.org/docs/current/sql-dropextension.html
|
68
|
+
def drop_extension(*extensions, if_exists: false, cascade: false)
|
69
|
+
raise ArgumentError, "wrong number of arguments (given 0, expected 1+)" if extensions.empty?
|
70
|
+
|
71
|
+
sql = +"DROP EXTENSION "
|
72
|
+
sql << "IF EXISTS " if if_exists
|
73
|
+
sql << extensions.join(", ")
|
74
|
+
sql << " CASCADE" if cascade
|
75
|
+
execute(sql)
|
76
|
+
@extensions&.except!(*extensions.map(&:to_s))
|
77
|
+
end
|
78
|
+
|
79
|
+
# check if a particular extension can be installed
|
80
|
+
def extension_available?(extension, version = nil)
|
81
|
+
sql = +"SELECT 1 FROM "
|
82
|
+
sql << version ? "pg_available_extensions" : "pg_available_extension_versions"
|
83
|
+
sql << " WHERE name=#{quote(extension)}"
|
84
|
+
sql << " AND version=#{quote(version)}" if version
|
85
|
+
select_value(sql).to_i == 1
|
86
|
+
end
|
87
|
+
|
88
|
+
# returns an Extension object for a particular extension
|
89
|
+
def extension(extension)
|
90
|
+
@extensions ||= {}
|
91
|
+
@extensions.fetch(extension.to_s) do
|
92
|
+
rows = select_rows(<<~SQL, "SCHEMA")
|
93
|
+
SELECT nspname, extversion
|
94
|
+
FROM pg_extension
|
95
|
+
INNER JOIN pg_namespace ON extnamespace=pg_namespace.oid
|
96
|
+
WHERE extname=#{quote(extension)}
|
97
|
+
SQL
|
98
|
+
next nil if rows.empty?
|
99
|
+
|
100
|
+
Extension.new(extension.to_s, rows[0][0], rows[0][1])
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# temporarily adds schema to the search_path (i.e. so you can use an extension that won't work
|
105
|
+
# without being on the search path, such as postgis)
|
106
|
+
def add_schema_to_search_path(schema)
|
107
|
+
if schema_search_path.split(",").include?(schema)
|
108
|
+
yield
|
109
|
+
else
|
110
|
+
old_search_path = schema_search_path
|
111
|
+
manual_rollback = false
|
112
|
+
transaction(requires_new: true) do
|
113
|
+
self.schema_search_path += ",#{schema}"
|
114
|
+
yield
|
115
|
+
self.schema_search_path = old_search_path
|
116
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::Rollback => e
|
117
|
+
# the transaction rolling back will revert the search path change;
|
118
|
+
# we don't need to do another query to set it
|
119
|
+
@schema_search_path = old_search_path
|
120
|
+
manual_rollback = e if e.is_a?(ActiveRecord::Rollback)
|
121
|
+
raise
|
122
|
+
end
|
123
|
+
# the transaction call will swallow ActiveRecord::Rollback,
|
124
|
+
# but we want it this method to be transparent
|
125
|
+
raise manual_rollback if manual_rollback
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# see https://www.postgresql.org/docs/current/sql-vacuum.html
|
130
|
+
def vacuum(*table_and_columns,
|
131
|
+
full: false,
|
132
|
+
freeze: false,
|
133
|
+
verbose: false,
|
134
|
+
analyze: false,
|
135
|
+
disable_page_skipping: false,
|
136
|
+
skip_locked: false,
|
137
|
+
index_cleanup: false,
|
138
|
+
truncate: false,
|
139
|
+
parallel: nil)
|
140
|
+
if parallel && !(parallel.is_a?(Integer) && parallel.positive?)
|
141
|
+
raise ArgumentError, "parallel must be a positive integer"
|
142
|
+
end
|
143
|
+
|
144
|
+
sql = +"VACUUM"
|
145
|
+
sql << " FULL" if full
|
146
|
+
sql << " FREEZE" if freeze
|
147
|
+
sql << " VERBOSE" if verbose
|
148
|
+
sql << " ANALYZE" if analyze
|
149
|
+
sql << " DISABLE_PAGE_SKIPPING" if disable_page_skipping
|
150
|
+
sql << " SKIP_LOCKED" if skip_locked
|
151
|
+
sql << " INDEX_CLEANUP" if index_cleanup
|
152
|
+
sql << " TRUNCATE" if truncate
|
153
|
+
sql << " PARALLEL #{parallel}" if parallel
|
154
|
+
sql << " " unless table_and_columns.empty?
|
155
|
+
sql << table_and_columns.map do |table|
|
156
|
+
if table.is_a?(Hash)
|
157
|
+
raise ArgumentError, "columns may only be specified if a analyze is specified" unless analyze
|
158
|
+
|
159
|
+
table.map do |table_name, columns|
|
160
|
+
"#{quote_table_name(table_name)} (#{Array.wrap(columns).map { |c| quote_column_name(c) }.join(', ')})"
|
161
|
+
end.join(", ")
|
162
|
+
else
|
163
|
+
quote_table_name(table)
|
164
|
+
end
|
165
|
+
end.join(", ")
|
166
|
+
execute(sql)
|
167
|
+
end
|
168
|
+
|
169
|
+
# Amazon Aurora doesn't have a WAL
|
170
|
+
def wal?
|
171
|
+
unless instance_variable_defined?(:@has_wal)
|
172
|
+
function_name = pre_pg10_wal_function_name("pg_current_wal_lsn")
|
173
|
+
@has_wal = select_value("SELECT true FROM pg_proc WHERE proname='#{function_name}' LIMIT 1")
|
174
|
+
end
|
175
|
+
@has_wal
|
176
|
+
end
|
177
|
+
|
178
|
+
# see https://www.postgresql.org/docs/current/functions-admin.html#id-1.5.8.33.5.5.2.2.4.1.1.1
|
179
|
+
def current_wal_lsn
|
180
|
+
return nil unless wal?
|
181
|
+
|
182
|
+
select_value("SELECT #{pre_pg10_wal_function_name('pg_current_wal_lsn')}()")
|
183
|
+
end
|
184
|
+
|
185
|
+
# see https://www.postgresql.org/docs/current/functions-admin.html#id-1.5.8.33.5.5.2.2.2.1.1.1
|
186
|
+
def current_wal_flush_lsn
|
187
|
+
return nil unless wal?
|
188
|
+
|
189
|
+
select_value("SELECT #{pre_pg10_wal_function_name('pg_current_wal_flush_lsn')}()")
|
190
|
+
end
|
191
|
+
|
192
|
+
# see https://www.postgresql.org/docs/current/functions-admin.html#id-1.5.8.33.5.5.2.2.3.1.1.1
|
193
|
+
def current_wal_insert_lsn
|
194
|
+
return nil unless wal?
|
195
|
+
|
196
|
+
select_value("SELECT #{pre_pg10_wal_function_name('pg_current_wal_insert_lsn')}()")
|
197
|
+
end
|
198
|
+
|
199
|
+
# https://www.postgresql.org/docs/current/functions-admin.html#id-1.5.8.33.6.3.2.2.2.1.1.1
|
200
|
+
def last_wal_receive_lsn
|
201
|
+
return nil unless wal?
|
202
|
+
|
203
|
+
select_value("SELECT #{pre_pg10_wal_function_name('pg_last_wal_receive_lsn')}()")
|
204
|
+
end
|
205
|
+
|
206
|
+
# see https://www.postgresql.org/docs/current/functions-admin.html#id-1.5.8.33.6.3.2.2.3.1.1.1
|
207
|
+
def last_wal_replay_lsn
|
208
|
+
return nil unless wal?
|
209
|
+
|
210
|
+
select_value("SELECT #{pre_pg10_wal_function_name('pg_last_wal_replay_lsn')}()")
|
211
|
+
end
|
212
|
+
|
213
|
+
# see https://www.postgresql.org/docs/current/functions-admin.html#id-1.5.8.33.5.5.2.2.4.1.1.1
|
214
|
+
# lsns can be literals, or :current, :current_flush, :current_insert, :last_receive, or :last_replay
|
215
|
+
def wal_lsn_diff(lsn1 = :current, lsn2 = :last_replay)
|
216
|
+
return nil unless wal?
|
217
|
+
|
218
|
+
lsns = [lsn1, lsn2].map do |lsn|
|
219
|
+
case lsn
|
220
|
+
when :current then pre_pg10_wal_function_name("pg_current_wal_lsn()")
|
221
|
+
when :current_flush then pre_pg10_wal_function_name("pg_current_flush_wal_lsn()")
|
222
|
+
when :current_insert then pre_pg10_wal_function_name("pg_current_insert_wal_lsn()")
|
223
|
+
when :last_receive then pre_pg10_wal_function_name("pg_last_wal_receive_lsn()")
|
224
|
+
when :last_replay then pre_pg10_wal_function_name("pg_last_wal_replay_lsn()")
|
225
|
+
else; quote(lsn)
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
select_value("SELECT #{pre_pg10_wal_function_name('pg_wal_lsn_diff')}(#{lsns[0]}, #{lsns[1]})")
|
230
|
+
end
|
231
|
+
|
232
|
+
def in_recovery?
|
233
|
+
select_value("SELECT pg_is_in_recovery()")
|
234
|
+
end
|
235
|
+
|
236
|
+
private
|
237
|
+
|
238
|
+
def initialize_type_map(map = type_map)
|
239
|
+
map.register_type "pg_lsn", ActiveRecord::ConnectionAdapters::PostgreSQL::OID::SpecializedString.new(:pg_lsn)
|
240
|
+
|
241
|
+
super
|
242
|
+
end
|
243
|
+
|
244
|
+
def pre_pg10_wal_function_name(func)
|
245
|
+
return func if postgresql_version >= 100_000
|
246
|
+
|
247
|
+
func.sub("wal", "xlog").sub("lsn", "location")
|
248
|
+
end
|
27
249
|
end
|
28
250
|
end
|
29
251
|
end
|
@@ -11,6 +11,8 @@ module ActiveRecord
|
|
11
11
|
require "active_record/pg_extensions/postgresql_adapter"
|
12
12
|
|
13
13
|
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include(PostgreSQLAdapter)
|
14
|
+
# if they've already require 'all', then inject now
|
15
|
+
defined?(All) && All.inject
|
14
16
|
end
|
15
17
|
end
|
16
18
|
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
describe ActiveRecord::PGExtensions::PessimisticMigrations do
|
4
|
+
around do |example|
|
5
|
+
connection.dont_execute(&example)
|
6
|
+
end
|
7
|
+
|
8
|
+
describe "#change_column_null" do
|
9
|
+
it "does nothing extra when changing a column to nullable" do
|
10
|
+
connection.change_column_null(:table, :column, true)
|
11
|
+
expect(connection.executed_statements).to eq ['ALTER TABLE "table" ALTER COLUMN "column" DROP NOT NULL']
|
12
|
+
end
|
13
|
+
|
14
|
+
it "pre-warms the cache" do
|
15
|
+
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
|
+
)
|
24
|
+
end
|
25
|
+
|
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
|
+
)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
describe "#add_foreign_key" do
|
39
|
+
it "does nothing extra if a transaction is already active" do
|
40
|
+
connection.transaction do
|
41
|
+
connection.add_foreign_key :emails, :users, delay_validation: true
|
42
|
+
end
|
43
|
+
expect(connection.executed_statements).to match(
|
44
|
+
["BEGIN",
|
45
|
+
match(/\AALTER TABLE "emails" ADD CONSTRAINT "fk_rails_[0-9a-f]+".+REFERENCES "users" \("id"\)\s*\z/m),
|
46
|
+
"COMMIT"]
|
47
|
+
)
|
48
|
+
end
|
49
|
+
|
50
|
+
it "delays validation" do
|
51
|
+
connection.add_foreign_key :emails, :users, delay_validation: true
|
52
|
+
expect(connection.executed_statements).to match(
|
53
|
+
[/convalidated/,
|
54
|
+
match(/\AALTER TABLE "emails" ADD CONSTRAINT "[a-z0-9_]+".+REFERENCES "users" \("id"\)\s+NOT VALID\z/m),
|
55
|
+
match(/^ALTER TABLE "emails" VALIDATE CONSTRAINT "fk_rails_[0-9a-f]+"/)]
|
56
|
+
)
|
57
|
+
end
|
58
|
+
|
59
|
+
it "only validates if the constraint already exists, and is not valid" do
|
60
|
+
expect(connection).to receive(:select_value).with(/convalidated/, "SCHEMA").and_return(false)
|
61
|
+
connection.add_foreign_key :emails, :users, delay_validation: true
|
62
|
+
expect(connection.executed_statements).to match(
|
63
|
+
[match(/^ALTER TABLE "emails" VALIDATE CONSTRAINT "fk_rails_[0-9a-f]+"/)]
|
64
|
+
)
|
65
|
+
end
|
66
|
+
|
67
|
+
it "does nothing if constraint already exists" do
|
68
|
+
expect(connection).to receive(:select_value).with(/convalidated/, "SCHEMA").and_return(true)
|
69
|
+
connection.add_foreign_key :emails, :users, if_not_exists: true
|
70
|
+
expect(connection.executed_statements).to eq []
|
71
|
+
end
|
72
|
+
|
73
|
+
it "still tries if delay_validation is true but if_not_exists is false and it already exists" do
|
74
|
+
expect(connection).to receive(:select_value).with(/convalidated/, "SCHEMA").and_return(true)
|
75
|
+
connection.add_foreign_key :emails, :users, delay_validation: true
|
76
|
+
expect(connection.executed_statements).to match(
|
77
|
+
[match(/\AALTER TABLE "emails" ADD CONSTRAINT "[a-z0-9_]+".+REFERENCES "users" \("id"\)\s+NOT VALID\z/m),
|
78
|
+
match(/^ALTER TABLE "emails" VALIDATE CONSTRAINT "fk_rails_[0-9a-f]+"/)]
|
79
|
+
)
|
80
|
+
end
|
81
|
+
|
82
|
+
it "does nothing if_not_exists is true and it is NOT VALID" do
|
83
|
+
expect(connection).to receive(:select_value).with(/convalidated/, "SCHEMA").and_return(false)
|
84
|
+
connection.add_foreign_key :emails, :users, if_not_exists: true
|
85
|
+
expect(connection.executed_statements).to eq []
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
describe "#add_index" do
|
90
|
+
it "removes a NOT VALID index before re-adding" do
|
91
|
+
expect(connection).to receive(:select_value).with(/indisvalid/, "SCHEMA").and_return(false)
|
92
|
+
expect(connection).to receive(:remove_index).with(:users, name: "index_users_on_name", algorithm: :concurrently)
|
93
|
+
|
94
|
+
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")']
|
97
|
+
)
|
98
|
+
end
|
99
|
+
|
100
|
+
it "does nothing if the index already exists" do
|
101
|
+
expect(connection).to receive(:select_value).with(/indisvalid/, "SCHEMA").and_return(true)
|
102
|
+
|
103
|
+
connection.add_index :users, :name, if_not_exists: true
|
104
|
+
expect(connection.executed_statements).to eq []
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -40,4 +40,208 @@ describe ActiveRecord::ConnectionAdapters::PostgreSQLAdapter do
|
|
40
40
|
expect(connection.executed_statements).to eq ["SET CONSTRAINTS ALL DEFERRED", "SET CONSTRAINTS ALL IMMEDIATE"]
|
41
41
|
end
|
42
42
|
end
|
43
|
+
|
44
|
+
describe "#set_replica_identity" do
|
45
|
+
around do |example|
|
46
|
+
connection.dont_execute(&example)
|
47
|
+
end
|
48
|
+
|
49
|
+
it "resets identity" do
|
50
|
+
connection.set_replica_identity(:my_table)
|
51
|
+
expect(connection.executed_statements).to eq ['ALTER TABLE "my_table" REPLICA IDENTITY DEFAULT']
|
52
|
+
end
|
53
|
+
|
54
|
+
it "sets full identity" do
|
55
|
+
connection.set_replica_identity(:my_table, :full)
|
56
|
+
expect(connection.executed_statements).to eq ['ALTER TABLE "my_table" REPLICA IDENTITY FULL']
|
57
|
+
end
|
58
|
+
|
59
|
+
it "sets an index" do
|
60
|
+
connection.set_replica_identity(:my_table, :my_index)
|
61
|
+
expect(connection.executed_statements).to eq ['ALTER TABLE "my_table" REPLICA IDENTITY USING INDEX "my_index"']
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
context "extensions" do
|
66
|
+
it "creates and drops an extension" do
|
67
|
+
connection.create_extension(:pg_trgm, schema: "public")
|
68
|
+
expect(connection.executed_statements).to eq ["CREATE EXTENSION pg_trgm SCHEMA public"]
|
69
|
+
expect(ext = connection.extension(:pg_trgm)).not_to be_nil
|
70
|
+
expect(ext.schema).to eq "public"
|
71
|
+
expect(ext.version).not_to be_nil
|
72
|
+
expect(ext.name).to eq "pg_trgm"
|
73
|
+
ensure
|
74
|
+
connection.executed_statements.clear
|
75
|
+
connection.drop_extension(:pg_trgm, if_exists: true)
|
76
|
+
expect(connection.executed_statements).to eq ["DROP EXTENSION IF EXISTS pg_trgm"]
|
77
|
+
expect(connection.extension(:pg_trgm)).to be_nil
|
78
|
+
end
|
79
|
+
|
80
|
+
it "doesn't try to recreate" do
|
81
|
+
connection.create_extension(:pg_trgm, schema: "public")
|
82
|
+
connection.create_extension(:pg_trgm, schema: "public", if_not_exists: true)
|
83
|
+
expect(connection.executed_statements).to eq ["CREATE EXTENSION pg_trgm SCHEMA public",
|
84
|
+
"CREATE EXTENSION IF NOT EXISTS pg_trgm SCHEMA public"]
|
85
|
+
ensure
|
86
|
+
connection.drop_extension(:pg_trgm, if_exists: true)
|
87
|
+
end
|
88
|
+
|
89
|
+
context "non-executing" do
|
90
|
+
around do |example|
|
91
|
+
connection.dont_execute(&example)
|
92
|
+
end
|
93
|
+
|
94
|
+
it "supports all options on create" do
|
95
|
+
connection.create_extension(:my_extension, if_not_exists: true, schema: "public", version: "2.0", cascade: true)
|
96
|
+
expect(connection.executed_statements).to eq(
|
97
|
+
["CREATE EXTENSION IF NOT EXISTS my_extension SCHEMA public VERSION '2.0' CASCADE"]
|
98
|
+
)
|
99
|
+
end
|
100
|
+
|
101
|
+
it "supports all options on drop" do
|
102
|
+
connection.drop_extension(:my_extension, if_exists: true, cascade: true)
|
103
|
+
expect(connection.executed_statements).to eq ["DROP EXTENSION IF EXISTS my_extension CASCADE"]
|
104
|
+
end
|
105
|
+
|
106
|
+
it "can update an extensions" do
|
107
|
+
connection.alter_extension(:my_extension, version: true)
|
108
|
+
expect(connection.executed_statements).to eq ["ALTER EXTENSION my_extension UPDATE"]
|
109
|
+
end
|
110
|
+
|
111
|
+
it "can update to a specific version" do
|
112
|
+
connection.alter_extension(:my_extension, version: "2.0")
|
113
|
+
expect(connection.executed_statements).to eq ["ALTER EXTENSION my_extension UPDATE TO '2.0'"]
|
114
|
+
end
|
115
|
+
|
116
|
+
it "can change schemas" do
|
117
|
+
connection.alter_extension(:my_extension, schema: "my_app")
|
118
|
+
expect(connection.executed_statements).to eq ["ALTER EXTENSION my_extension SET SCHEMA my_app"]
|
119
|
+
end
|
120
|
+
|
121
|
+
it "cannot change schema and update" do
|
122
|
+
expect { connection.alter_extension(:my_extension, schema: "my_app", version: "2.0") }
|
123
|
+
.to raise_error(ArgumentError)
|
124
|
+
end
|
125
|
+
|
126
|
+
it "can drop multiple extensions" do
|
127
|
+
connection.drop_extension(:my_extension1, :my_extension2)
|
128
|
+
expect(connection.executed_statements).to eq ["DROP EXTENSION my_extension1, my_extension2"]
|
129
|
+
end
|
130
|
+
|
131
|
+
it "does not allow dropping no extensions" do
|
132
|
+
expect { connection.drop_extension }.to raise_error(ArgumentError)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
describe "#add_schema_to_search_path" do
|
138
|
+
around do |example|
|
139
|
+
original_search_path = connection.schema_search_path
|
140
|
+
connection.schema_search_path = "public"
|
141
|
+
example.call
|
142
|
+
ensure
|
143
|
+
connection.schema_search_path = original_search_path
|
144
|
+
end
|
145
|
+
|
146
|
+
it "adds a schema to search path" do
|
147
|
+
connection.add_schema_to_search_path("postgis") do
|
148
|
+
expect(connection.schema_search_path).to eq "public,postgis"
|
149
|
+
end
|
150
|
+
expect(connection.schema_search_path).to eq "public"
|
151
|
+
end
|
152
|
+
|
153
|
+
it "doesn't duplicate an existing schema" do
|
154
|
+
connection.add_schema_to_search_path("public") do
|
155
|
+
expect(connection.schema_search_path).to eq "public"
|
156
|
+
end
|
157
|
+
expect(connection.schema_search_path).to eq "public"
|
158
|
+
end
|
159
|
+
|
160
|
+
it "is cleaned up properly when the transaction rolls back manually" do
|
161
|
+
expect do
|
162
|
+
connection.add_schema_to_search_path("postgis") do
|
163
|
+
raise ActiveRecord::Rollback
|
164
|
+
end
|
165
|
+
end.to raise_error(ActiveRecord::Rollback)
|
166
|
+
expect(connection.schema_search_path).to eq "public"
|
167
|
+
end
|
168
|
+
|
169
|
+
it "is cleaned up properly when the transaction rolls back" do
|
170
|
+
expect do
|
171
|
+
connection.add_schema_to_search_path("postgis") do
|
172
|
+
connection.execute("gibberish")
|
173
|
+
end
|
174
|
+
end.to raise_error(ActiveRecord::StatementInvalid)
|
175
|
+
expect(connection.schema_search_path).to eq "public"
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
describe "#vacuum" do
|
180
|
+
it "does a straight vacuum of everything" do
|
181
|
+
connection.vacuum
|
182
|
+
expect(connection.executed_statements).to eq ["VACUUM"]
|
183
|
+
end
|
184
|
+
|
185
|
+
it "supports several options" do
|
186
|
+
connection.vacuum(analyze: true, verbose: true)
|
187
|
+
expect(connection.executed_statements).to eq ["VACUUM VERBOSE ANALYZE"]
|
188
|
+
end
|
189
|
+
|
190
|
+
it "validates parallel option is an integer" do
|
191
|
+
expect { connection.vacuum(parallel: :garbage) }.to raise_error(ArgumentError)
|
192
|
+
end
|
193
|
+
|
194
|
+
it "validates parallel option is postive" do
|
195
|
+
expect { connection.vacuum(parallel: -1) }.to raise_error(ArgumentError)
|
196
|
+
end
|
197
|
+
|
198
|
+
context "non-executing" do
|
199
|
+
around do |example|
|
200
|
+
connection.dont_execute(&example)
|
201
|
+
end
|
202
|
+
|
203
|
+
it "vacuums a table" do
|
204
|
+
connection.vacuum(:my_table)
|
205
|
+
expect(connection.executed_statements).to eq ['VACUUM "my_table"']
|
206
|
+
end
|
207
|
+
|
208
|
+
it "vacuums multiple tables" do
|
209
|
+
connection.vacuum(:table1, :table2)
|
210
|
+
expect(connection.executed_statements).to eq ['VACUUM "table1", "table2"']
|
211
|
+
end
|
212
|
+
|
213
|
+
it "requires analyze with specific columns" do
|
214
|
+
expect { connection.vacuum({ my_table: :column1 }) }.to raise_error(ArgumentError)
|
215
|
+
end
|
216
|
+
|
217
|
+
it "analyzes a specific column" do
|
218
|
+
connection.vacuum({ my_table: :column }, analyze: true)
|
219
|
+
expect(connection.executed_statements).to eq ['VACUUM ANALYZE "my_table" ("column")']
|
220
|
+
end
|
221
|
+
|
222
|
+
it "analyzes multiples columns" do
|
223
|
+
connection.vacuum({ my_table: %i[column1 column2] }, analyze: true)
|
224
|
+
expect(connection.executed_statements).to eq ['VACUUM ANALYZE "my_table" ("column1", "column2")']
|
225
|
+
end
|
226
|
+
|
227
|
+
it "analyzes a mixture of tables and columns" do
|
228
|
+
connection.vacuum(:table1, { my_table: %i[column1 column2] }, analyze: true)
|
229
|
+
expect(connection.executed_statements).to eq ['VACUUM ANALYZE "table1", "my_table" ("column1", "column2")']
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
describe "#wal_lsn_diff" do
|
234
|
+
skip unless connection.wal?
|
235
|
+
|
236
|
+
it "executes" do
|
237
|
+
expect(connection.wal_lsn_diff(:current, :current)).to eq 0
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
describe "#in_recovery?" do
|
242
|
+
it "works" do
|
243
|
+
expect(connection.in_recovery?).to eq false
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
43
247
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
require "activerecord-pg-extensions"
|
4
4
|
require "byebug"
|
5
5
|
require "active_record/railtie"
|
6
|
+
require "active_record/pg_extensions/all"
|
6
7
|
|
7
8
|
ActiveRecord::Base # rubocop:disable Lint/Void
|
8
9
|
Rails.env = "test"
|
@@ -26,9 +27,22 @@ module StatementCaptureConnection
|
|
26
27
|
@executed_statements ||= []
|
27
28
|
end
|
28
29
|
|
29
|
-
|
30
|
-
|
31
|
-
|
30
|
+
%w[execute exec_no_cache exec_cache].each do |method|
|
31
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
32
|
+
def #{method}(statement, *)
|
33
|
+
materialize_transactions # this still needs to get called, even if we skip actually executing
|
34
|
+
executed_statements << statement
|
35
|
+
return empty_pg_result if @dont_execute
|
36
|
+
|
37
|
+
super
|
38
|
+
end
|
39
|
+
RUBY
|
40
|
+
end
|
41
|
+
|
42
|
+
# we can't actually generate a dummy one of these, so we just query the db with something
|
43
|
+
# that won't return anything
|
44
|
+
def empty_pg_result
|
45
|
+
@connection.async_exec("SELECT 0 WHERE FALSE")
|
32
46
|
end
|
33
47
|
end
|
34
48
|
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(StatementCaptureConnection)
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: activerecord-pg-extensions
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Cody Cutrer
|
@@ -160,10 +160,14 @@ files:
|
|
160
160
|
- README.md
|
161
161
|
- config/database.yml
|
162
162
|
- lib/active_record/pg_extensions.rb
|
163
|
+
- lib/active_record/pg_extensions/all.rb
|
164
|
+
- lib/active_record/pg_extensions/extension.rb
|
165
|
+
- lib/active_record/pg_extensions/pessimistic_migrations.rb
|
163
166
|
- lib/active_record/pg_extensions/postgresql_adapter.rb
|
164
167
|
- lib/active_record/pg_extensions/railtie.rb
|
165
168
|
- lib/active_record/pg_extensions/version.rb
|
166
169
|
- lib/activerecord-pg-extensions.rb
|
170
|
+
- spec/pessimistic_migrations_spec.rb
|
167
171
|
- spec/postgresql_adapter_spec.rb
|
168
172
|
- spec/spec_helper.rb
|
169
173
|
homepage: https://github.com/instructure/activerecord-pg-extensions
|
@@ -193,4 +197,5 @@ summary: Several extensions to ActiveRecord's PostgreSQLAdapter.
|
|
193
197
|
test_files:
|
194
198
|
- spec/spec_helper.rb
|
195
199
|
- spec/postgresql_adapter_spec.rb
|
200
|
+
- spec/pessimistic_migrations_spec.rb
|
196
201
|
- config/database.yml
|