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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c73159dc2062391844fc7309dd066720684b43a7d81c4c19c8bc5acaeacb36d
4
- data.tar.gz: bff7754ec2e0a7817b7806f0e37c80ec91675a2e745841d60421da9192438840
3
+ metadata.gz: abfb428bb45f993d1a1ee897e81be0372dafc62b5a345c433eb9e69c821ba100
4
+ data.tar.gz: d8a2f84e3a2a70576d165e1ad56a17d2cb122acc1bf784a9a17c969cc3f018a8
5
5
  SHA512:
6
- metadata.gz: a8ed2044c44c79a5d6f0412ebb95de80e9a43361d22866950605a14c2a59bcf9566a6cd73febe06743d5e4f8c445a76b3c25c9ed7395733ba9159a386e1bbd2b
7
- data.tar.gz: 3c2b19dccfa6b809f5e9de4a0c94c5bb26000537175957a2782bf8e94daf7f568dd311c45c36261f843128b53f8f9ff038c59a9141a952104316c7cb5b91f4d2
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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module PGExtensions
5
+ # Contains general additions to the PostgreSQLAdapter
6
+ module PostgreSQLAdapter
7
+ Extension = Struct.new(:name, :schema, :version)
8
+ end
9
+ end
10
+ end
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module PGExtensions
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  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
- def execute(statement, *)
30
- executed_statements << statement
31
- super unless @dont_execute
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.1.1
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