activerecord-pg-extensions 0.1.1 → 0.2.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 +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
|