pg_ha_migrations 1.7.0 → 2.0.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/.github/workflows/ci.yml +8 -8
- data/.ruby-version +1 -1
- data/Appraisals +8 -4
- data/Dockerfile +2 -2
- data/Gemfile +0 -1
- data/README.md +179 -44
- data/Rakefile +2 -0
- data/bin/setup +3 -1
- data/docker-compose.yml +1 -1
- data/gemfiles/{rails_6.1.gemfile → rails_7.1.gemfile} +1 -1
- data/gemfiles/{rails_7.0.gemfile → rails_7.2.gemfile} +1 -1
- data/gemfiles/rails_8.0.gemfile +7 -0
- data/lib/pg_ha_migrations/allowed_versions.rb +1 -1
- data/lib/pg_ha_migrations/blocking_database_transactions.rb +10 -5
- data/lib/pg_ha_migrations/constraint.rb +1 -0
- data/lib/pg_ha_migrations/hacks/add_index_on_only.rb +30 -0
- data/lib/pg_ha_migrations/hacks/disable_ddl_transaction.rb +0 -1
- data/lib/pg_ha_migrations/lock_mode.rb +112 -0
- data/lib/pg_ha_migrations/relation.rb +224 -0
- data/lib/pg_ha_migrations/safe_statements.rb +288 -127
- data/lib/pg_ha_migrations/unsafe_statements.rb +159 -31
- data/lib/pg_ha_migrations/version.rb +1 -1
- data/lib/pg_ha_migrations.rb +22 -1
- data/pg_ha_migrations.gemspec +3 -3
- metadata +18 -16
@@ -0,0 +1,112 @@
|
|
1
|
+
module PgHaMigrations
|
2
|
+
class LockMode
|
3
|
+
include Comparable
|
4
|
+
|
5
|
+
MODE_CONFLICTS = ActiveSupport::OrderedHash.new
|
6
|
+
|
7
|
+
MODE_CONFLICTS[:access_share] = %i[
|
8
|
+
access_exclusive
|
9
|
+
]
|
10
|
+
|
11
|
+
MODE_CONFLICTS[:row_share] = %i[
|
12
|
+
exclusive
|
13
|
+
access_exclusive
|
14
|
+
]
|
15
|
+
|
16
|
+
MODE_CONFLICTS[:row_exclusive] = %i[
|
17
|
+
share
|
18
|
+
share_row_exclusive
|
19
|
+
exclusive
|
20
|
+
access_exclusive
|
21
|
+
]
|
22
|
+
|
23
|
+
MODE_CONFLICTS[:share_update_exclusive] = %i[
|
24
|
+
share_update_exclusive
|
25
|
+
share
|
26
|
+
share_row_exclusive
|
27
|
+
exclusive
|
28
|
+
access_exclusive
|
29
|
+
]
|
30
|
+
|
31
|
+
MODE_CONFLICTS[:share] = %i[
|
32
|
+
row_exclusive
|
33
|
+
share_update_exclusive
|
34
|
+
share_row_exclusive
|
35
|
+
exclusive
|
36
|
+
access_exclusive
|
37
|
+
]
|
38
|
+
|
39
|
+
MODE_CONFLICTS[:share_row_exclusive] = %i[
|
40
|
+
row_exclusive
|
41
|
+
share_update_exclusive
|
42
|
+
share
|
43
|
+
share_row_exclusive
|
44
|
+
exclusive
|
45
|
+
access_exclusive
|
46
|
+
]
|
47
|
+
|
48
|
+
MODE_CONFLICTS[:exclusive] = %i[
|
49
|
+
row_share
|
50
|
+
row_exclusive
|
51
|
+
share_update_exclusive
|
52
|
+
share
|
53
|
+
share_row_exclusive
|
54
|
+
exclusive
|
55
|
+
access_exclusive
|
56
|
+
]
|
57
|
+
|
58
|
+
MODE_CONFLICTS[:access_exclusive] = %i[
|
59
|
+
access_share
|
60
|
+
row_share
|
61
|
+
row_exclusive
|
62
|
+
share_update_exclusive
|
63
|
+
share
|
64
|
+
share_row_exclusive
|
65
|
+
exclusive
|
66
|
+
access_exclusive
|
67
|
+
]
|
68
|
+
|
69
|
+
attr_reader :mode
|
70
|
+
|
71
|
+
delegate :to_s, to: :mode
|
72
|
+
|
73
|
+
def initialize(mode)
|
74
|
+
@mode = mode
|
75
|
+
.to_s
|
76
|
+
.underscore
|
77
|
+
.delete_suffix("_lock")
|
78
|
+
.to_sym
|
79
|
+
|
80
|
+
if !MODE_CONFLICTS.keys.include?(@mode)
|
81
|
+
raise ArgumentError, "Unrecognized lock mode #{@mode.inspect}. Valid modes: #{MODE_CONFLICTS.keys}"
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def to_sql
|
86
|
+
mode
|
87
|
+
.to_s
|
88
|
+
.upcase
|
89
|
+
.gsub("_", " ")
|
90
|
+
end
|
91
|
+
|
92
|
+
def <=>(other)
|
93
|
+
MODE_CONFLICTS.keys.index(mode) <=> MODE_CONFLICTS.keys.index(other.mode)
|
94
|
+
end
|
95
|
+
|
96
|
+
def eql?(other)
|
97
|
+
other.is_a?(LockMode) && mode == other.mode
|
98
|
+
end
|
99
|
+
|
100
|
+
def ==(other)
|
101
|
+
eql?(other)
|
102
|
+
end
|
103
|
+
|
104
|
+
def hash
|
105
|
+
mode.hash
|
106
|
+
end
|
107
|
+
|
108
|
+
def conflicts_with?(other)
|
109
|
+
MODE_CONFLICTS[mode].include?(other.mode)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,224 @@
|
|
1
|
+
module PgHaMigrations
|
2
|
+
# This object represents a pointer to an actual relation in Postgres.
|
3
|
+
# The mode attribute is optional metadata which can represent a lock
|
4
|
+
# that has already been acquired or a potential lock that we are
|
5
|
+
# looking to acquire.
|
6
|
+
Relation = Struct.new(:name, :schema, :mode) do
|
7
|
+
def self.connection
|
8
|
+
ActiveRecord::Base.connection
|
9
|
+
end
|
10
|
+
|
11
|
+
delegate :inspect, to: :name
|
12
|
+
delegate :connection, to: :class
|
13
|
+
|
14
|
+
def initialize(name, schema, mode=nil)
|
15
|
+
super(name, schema)
|
16
|
+
|
17
|
+
self.mode = LockMode.new(mode) if mode.present?
|
18
|
+
end
|
19
|
+
|
20
|
+
def fully_qualified_name
|
21
|
+
@fully_qualified_name ||= [
|
22
|
+
PG::Connection.quote_ident(schema),
|
23
|
+
PG::Connection.quote_ident(name),
|
24
|
+
].join(".")
|
25
|
+
end
|
26
|
+
|
27
|
+
def present?
|
28
|
+
name.present? && schema.present?
|
29
|
+
end
|
30
|
+
|
31
|
+
def conflicts_with?(other)
|
32
|
+
eql?(other) && (
|
33
|
+
mode.nil? || other.mode.nil? || mode.conflicts_with?(other.mode)
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Some code paths need to compare lock modes, while others just need
|
38
|
+
# to determine if the relation is the same object in Postgres, so
|
39
|
+
# equality here is simply looking at the relation name / schema.
|
40
|
+
# To also compare lock modes, #conflicts_with? is used.
|
41
|
+
def eql?(other)
|
42
|
+
other.is_a?(Relation) && hash == other.hash
|
43
|
+
end
|
44
|
+
|
45
|
+
def ==(other)
|
46
|
+
eql?(other)
|
47
|
+
end
|
48
|
+
|
49
|
+
def hash
|
50
|
+
[name, schema].hash
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class Table < Relation
|
55
|
+
def self.from_table_name(table, mode=nil)
|
56
|
+
pg_name = ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name(table.to_s)
|
57
|
+
|
58
|
+
schema_conditional = if pg_name.schema
|
59
|
+
"#{connection.quote(pg_name.schema)}"
|
60
|
+
else
|
61
|
+
"ANY (current_schemas(false))"
|
62
|
+
end
|
63
|
+
|
64
|
+
schema = connection.select_value(<<~SQL)
|
65
|
+
SELECT schemaname
|
66
|
+
FROM pg_tables
|
67
|
+
WHERE tablename = #{connection.quote(pg_name.identifier)} AND schemaname = #{schema_conditional}
|
68
|
+
ORDER BY array_position(current_schemas(false), schemaname)
|
69
|
+
LIMIT 1
|
70
|
+
SQL
|
71
|
+
|
72
|
+
raise UndefinedTableError, "Table #{pg_name.quoted} does not exist#{" in search path" unless pg_name.schema}" unless schema.present?
|
73
|
+
|
74
|
+
new(pg_name.identifier, schema, mode)
|
75
|
+
end
|
76
|
+
|
77
|
+
def natively_partitioned?
|
78
|
+
return @natively_partitioned if defined?(@natively_partitioned)
|
79
|
+
|
80
|
+
@natively_partitioned = !!connection.select_value(<<~SQL)
|
81
|
+
SELECT true
|
82
|
+
FROM pg_partitioned_table, pg_class, pg_namespace
|
83
|
+
WHERE pg_class.oid = pg_partitioned_table.partrelid
|
84
|
+
AND pg_class.relnamespace = pg_namespace.oid
|
85
|
+
AND pg_class.relname = #{connection.quote(name)}
|
86
|
+
AND pg_namespace.nspname = #{connection.quote(schema)}
|
87
|
+
SQL
|
88
|
+
end
|
89
|
+
|
90
|
+
def partitions(include_sub_partitions: false, include_self: false)
|
91
|
+
tables = connection.structs_from_sql(self.class, <<~SQL)
|
92
|
+
SELECT child.relname AS name, child_ns.nspname AS schema, NULLIF('#{mode}', '') AS mode
|
93
|
+
FROM pg_inherits
|
94
|
+
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
|
95
|
+
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
|
96
|
+
JOIN pg_namespace parent_ns ON parent.relnamespace = parent_ns.oid
|
97
|
+
JOIN pg_namespace child_ns ON child.relnamespace = child_ns.oid
|
98
|
+
WHERE parent.relname = #{connection.quote(name)}
|
99
|
+
AND parent_ns.nspname = #{connection.quote(schema)}
|
100
|
+
ORDER BY child.oid -- Ensure consistent ordering for tests
|
101
|
+
SQL
|
102
|
+
|
103
|
+
if include_sub_partitions
|
104
|
+
sub_partitions = tables.each_with_object([]) do |table, arr|
|
105
|
+
arr.concat(table.partitions(include_sub_partitions: true))
|
106
|
+
end
|
107
|
+
|
108
|
+
tables.concat(sub_partitions)
|
109
|
+
end
|
110
|
+
|
111
|
+
tables.prepend(self) if include_self
|
112
|
+
|
113
|
+
tables
|
114
|
+
end
|
115
|
+
|
116
|
+
def check_constraints
|
117
|
+
connection.structs_from_sql(PgHaMigrations::CheckConstraint, <<~SQL)
|
118
|
+
SELECT conname AS name, pg_get_constraintdef(pg_constraint.oid) AS definition, convalidated AS validated
|
119
|
+
FROM pg_constraint, pg_class, pg_namespace
|
120
|
+
WHERE pg_class.oid = pg_constraint.conrelid
|
121
|
+
AND pg_class.relnamespace = pg_namespace.oid
|
122
|
+
AND pg_class.relname = #{connection.quote(name)}
|
123
|
+
AND pg_namespace.nspname = #{connection.quote(schema)}
|
124
|
+
AND pg_constraint.contype = 'c' -- 'c' stands for check constraints
|
125
|
+
SQL
|
126
|
+
end
|
127
|
+
|
128
|
+
def has_rows?
|
129
|
+
connection.select_value("SELECT EXISTS (SELECT 1 FROM #{fully_qualified_name} LIMIT 1)")
|
130
|
+
end
|
131
|
+
|
132
|
+
def total_bytes
|
133
|
+
connection.select_value(<<~SQL)
|
134
|
+
SELECT pg_total_relation_size(pg_class.oid)
|
135
|
+
FROM pg_class, pg_namespace
|
136
|
+
WHERE pg_class.relname = #{connection.quote(name)}
|
137
|
+
AND pg_namespace.nspname = #{connection.quote(schema)}
|
138
|
+
SQL
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
class Index < Relation
|
143
|
+
MAX_NAME_SIZE = 63 # bytes
|
144
|
+
|
145
|
+
def self.from_table_and_columns(table, columns)
|
146
|
+
name = connection.index_name(table.name, columns)
|
147
|
+
|
148
|
+
# modified from https://github.com/rails/rails/pull/47753
|
149
|
+
if name.bytesize > MAX_NAME_SIZE
|
150
|
+
hashed_identifier = "_#{OpenSSL::Digest::SHA256.hexdigest(name).first(10)}"
|
151
|
+
description = name.sub("index_#{table.name}_on", "idx_on")
|
152
|
+
|
153
|
+
short_limit = MAX_NAME_SIZE - hashed_identifier.bytesize
|
154
|
+
short_description = description.mb_chars.limit(short_limit).to_s
|
155
|
+
|
156
|
+
name = "#{short_description}#{hashed_identifier}"
|
157
|
+
end
|
158
|
+
|
159
|
+
new(name, table)
|
160
|
+
end
|
161
|
+
|
162
|
+
attr_accessor :table
|
163
|
+
|
164
|
+
def initialize(name, table)
|
165
|
+
super(name, table.schema)
|
166
|
+
|
167
|
+
self.table = table
|
168
|
+
|
169
|
+
connection.send(:validate_index_length!, table.name, name)
|
170
|
+
end
|
171
|
+
|
172
|
+
def valid?
|
173
|
+
!!connection.select_value(<<~SQL)
|
174
|
+
SELECT pg_index.indisvalid
|
175
|
+
FROM pg_index, pg_class, pg_namespace
|
176
|
+
WHERE pg_class.oid = pg_index.indexrelid
|
177
|
+
AND pg_class.relnamespace = pg_namespace.oid
|
178
|
+
AND pg_namespace.nspname = #{connection.quote(schema)}
|
179
|
+
AND pg_class.relname = #{connection.quote(name)}
|
180
|
+
SQL
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
class TableCollection
|
185
|
+
include Enumerable
|
186
|
+
|
187
|
+
attr_reader :raw_set
|
188
|
+
|
189
|
+
delegate :each, to: :raw_set
|
190
|
+
delegate :mode, to: :first
|
191
|
+
|
192
|
+
def self.from_table_names(tables, mode=nil)
|
193
|
+
new(tables) { |table| Table.from_table_name(table, mode) }
|
194
|
+
end
|
195
|
+
|
196
|
+
def initialize(tables, &blk)
|
197
|
+
@raw_set = tables.map(&blk).to_set
|
198
|
+
|
199
|
+
if raw_set.empty?
|
200
|
+
raise ArgumentError, "Expected a non-empty list of tables"
|
201
|
+
end
|
202
|
+
|
203
|
+
if raw_set.uniq(&:mode).size > 1
|
204
|
+
raise ArgumentError, "Expected all tables in collection to have the same lock mode"
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
def subset?(other)
|
209
|
+
raw_set.subset?(other.raw_set)
|
210
|
+
end
|
211
|
+
|
212
|
+
def to_sql
|
213
|
+
map(&:fully_qualified_name).join(", ")
|
214
|
+
end
|
215
|
+
|
216
|
+
def with_partitions
|
217
|
+
tables = flat_map do |table|
|
218
|
+
table.partitions(include_sub_partitions: true, include_self: true)
|
219
|
+
end
|
220
|
+
|
221
|
+
self.class.new(tables)
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|