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 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