pg_ha_migrations 1.8.0 → 2.1.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 +16 -8
- data/.ruby-version +1 -1
- data/Appraisals +6 -6
- data/Dockerfile +10 -2
- data/Gemfile +0 -1
- data/README.md +141 -64
- data/Rakefile +2 -0
- data/bin/setup +3 -1
- data/docker-compose.yml +2 -1
- data/gemfiles/rails_7.1.gemfile +1 -1
- data/gemfiles/{rails_6.1.gemfile → rails_7.2.gemfile} +1 -1
- data/gemfiles/{rails_7.0.gemfile → rails_8.0.gemfile} +1 -1
- data/lib/pg_ha_migrations/allowed_versions.rb +1 -1
- data/lib/pg_ha_migrations/constraint.rb +8 -0
- data/lib/pg_ha_migrations/extension.rb +35 -0
- data/lib/pg_ha_migrations/lock_mode.rb +12 -0
- data/lib/pg_ha_migrations/partman_config.rb +67 -5
- data/lib/pg_ha_migrations/partman_rename_adapter.rb +209 -0
- data/lib/pg_ha_migrations/relation.rb +100 -7
- data/lib/pg_ha_migrations/safe_statements.rb +225 -142
- data/lib/pg_ha_migrations/unsafe_statements.rb +183 -31
- data/lib/pg_ha_migrations/version.rb +1 -1
- data/lib/pg_ha_migrations.rb +26 -1
- data/pg_ha_migrations.gemspec +3 -3
- metadata +16 -16
|
@@ -1,11 +1,73 @@
|
|
|
1
1
|
# This is an internal class that is not meant to be used directly
|
|
2
2
|
class PgHaMigrations::PartmanConfig < ActiveRecord::Base
|
|
3
|
+
SUPPORTED_PARTITION_TYPES = %w[native range]
|
|
4
|
+
|
|
5
|
+
delegate :connection, to: :class
|
|
6
|
+
|
|
3
7
|
self.primary_key = :parent_table
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
def self.find(parent_table, partman_extension:)
|
|
10
|
+
unless partman_extension.installed?
|
|
11
|
+
raise PgHaMigrations::MissingExtensionError, "The pg_partman extension is not installed"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
self.table_name = "#{partman_extension.quoted_schema}.part_config"
|
|
15
|
+
|
|
16
|
+
super(parent_table)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The actual column type is TEXT and the value is determined by the
|
|
20
|
+
# intervalstyle in Postgres at the time create_parent is called.
|
|
21
|
+
# Rails hard codes this config when it builds connections for ease
|
|
22
|
+
# of parsing by ActiveSupport::Duration.parse. So in theory, we
|
|
23
|
+
# really only need to do the interval casting, but we're doing the
|
|
24
|
+
# SET LOCAL to be absolutely sure intervalstyle is correct.
|
|
25
|
+
#
|
|
26
|
+
# https://github.com/rails/rails/blob/v8.0.3/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L979-L980
|
|
27
|
+
def partition_interval_iso_8601
|
|
28
|
+
transaction do
|
|
29
|
+
connection.execute("SET LOCAL intervalstyle TO 'iso_8601'")
|
|
30
|
+
connection.select_value("SELECT #{connection.quote(partition_interval)}::interval")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def partition_rename_adapter
|
|
35
|
+
unless SUPPORTED_PARTITION_TYPES.include?(partition_type)
|
|
36
|
+
raise PgHaMigrations::InvalidPartmanConfigError,
|
|
37
|
+
"Expected partition_type to be in #{SUPPORTED_PARTITION_TYPES.inspect} " \
|
|
38
|
+
"but received #{partition_type.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
duration = ActiveSupport::Duration.parse(partition_interval_iso_8601)
|
|
42
|
+
|
|
43
|
+
if duration.parts.size != 1
|
|
44
|
+
raise PgHaMigrations::InvalidPartmanConfigError,
|
|
45
|
+
"Partition renaming for complex partition_interval #{duration.iso8601.inspect} not supported"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Quarterly and weekly have special meaning in Partman 4 with
|
|
49
|
+
# specific datetime strings that need to be handled separately.
|
|
50
|
+
#
|
|
51
|
+
# The intervals "1 week" and "3 months" will not match the first
|
|
52
|
+
# two conditionals and will fallthrough to standard adapters below.
|
|
53
|
+
if duration == 1.week && datetime_string == "IYYY\"w\"IW"
|
|
54
|
+
PgHaMigrations::WeeklyPartmanRenameAdapter.new(self)
|
|
55
|
+
elsif duration == 3.months && datetime_string == "YYYY\"q\"Q"
|
|
56
|
+
PgHaMigrations::QuarterlyPartmanRenameAdapter.new(self)
|
|
57
|
+
elsif duration >= 1.year
|
|
58
|
+
PgHaMigrations::YearToForeverPartmanRenameAdapter.new(self)
|
|
59
|
+
elsif duration >= 1.month && duration < 1.year
|
|
60
|
+
PgHaMigrations::MonthToYearPartmanRenameAdapter.new(self)
|
|
61
|
+
elsif duration >= 1.day && duration < 1.month
|
|
62
|
+
PgHaMigrations::DayToMonthPartmanRenameAdapter.new(self)
|
|
63
|
+
elsif duration >= 1.minute && duration < 1.day
|
|
64
|
+
PgHaMigrations::MinuteToDayPartmanRenameAdapter.new(self)
|
|
65
|
+
elsif duration >= 1.second && duration < 1.minute
|
|
66
|
+
PgHaMigrations::SecondToMinutePartmanRenameAdapter.new(self)
|
|
67
|
+
else
|
|
68
|
+
raise PgHaMigrations::InvalidPartmanConfigError,
|
|
69
|
+
"Expected partition_interval to be greater than 1 second " \
|
|
70
|
+
"but received #{duration.iso8601.inspect}"
|
|
71
|
+
end
|
|
10
72
|
end
|
|
11
73
|
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
module PgHaMigrations
|
|
2
|
+
class AbstractPartmanRenameAdapter
|
|
3
|
+
def initialize(part_config)
|
|
4
|
+
if part_config.datetime_string != source_datetime_string
|
|
5
|
+
raise PgHaMigrations::InvalidPartmanConfigError,
|
|
6
|
+
"Expected datetime_string to be #{source_datetime_string.inspect} " \
|
|
7
|
+
"but received #{part_config.datetime_string.inspect}"
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def alter_table_sql(partitions)
|
|
12
|
+
sql = partitions.filter_map do |partition|
|
|
13
|
+
next if partition.name =~ /\A.+_default\z/
|
|
14
|
+
|
|
15
|
+
if partition.name !~ source_name_suffix_pattern
|
|
16
|
+
raise PgHaMigrations::InvalidIdentifierError,
|
|
17
|
+
"Expected #{partition.name.inspect} to match #{source_name_suffix_pattern.inspect}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
"ALTER TABLE #{partition.fully_qualified_name} RENAME TO #{target_table_name(partition.name)};"
|
|
22
|
+
rescue Date::Error
|
|
23
|
+
raise PgHaMigrations::InvalidIdentifierError,
|
|
24
|
+
"Expected #{partition.name.inspect} suffix to be a parseable DateTime"
|
|
25
|
+
end
|
|
26
|
+
end.join("\n")
|
|
27
|
+
|
|
28
|
+
# This wraps the SQL in an anonymous function such that
|
|
29
|
+
# the statement timeout would apply to the entire batch of
|
|
30
|
+
# statements instead of each individual statement
|
|
31
|
+
"DO $$ BEGIN #{sql} END; $$;"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def target_table_name(table_name)
|
|
35
|
+
raise "#{__method__} should be implemented in subclass"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def source_datetime_string
|
|
39
|
+
raise "#{__method__} should be implemented in subclass"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def source_name_suffix_pattern
|
|
43
|
+
raise "#{__method__} should be implemented in subclass"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def target_datetime_string
|
|
47
|
+
raise "#{__method__} should be implemented in subclass"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class YearToForeverPartmanRenameAdapter < AbstractPartmanRenameAdapter
|
|
52
|
+
def target_table_name(table_name)
|
|
53
|
+
table_name + "0101"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def source_datetime_string
|
|
57
|
+
"YYYY"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def source_name_suffix_pattern
|
|
61
|
+
/\A.+_p\d{4}\z/
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def target_datetime_string
|
|
65
|
+
"YYYYMMDD"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class QuarterlyPartmanRenameAdapter < AbstractPartmanRenameAdapter
|
|
70
|
+
QUARTER_MONTH_MAPPING = {
|
|
71
|
+
"1" => "01",
|
|
72
|
+
"2" => "04",
|
|
73
|
+
"3" => "07",
|
|
74
|
+
"4" => "10",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
def target_table_name(table_name)
|
|
78
|
+
base_name = table_name[0...-6]
|
|
79
|
+
|
|
80
|
+
year = table_name.last(6).first(4)
|
|
81
|
+
|
|
82
|
+
month = QUARTER_MONTH_MAPPING.fetch(table_name.last(1))
|
|
83
|
+
|
|
84
|
+
base_name + year + month + "01"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def source_datetime_string
|
|
88
|
+
"YYYY\"q\"Q"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def source_name_suffix_pattern
|
|
92
|
+
/\A.+_p\d{4}q(1|2|3|4)\z/
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def target_datetime_string
|
|
96
|
+
"YYYYMMDD"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class MonthToYearPartmanRenameAdapter < AbstractPartmanRenameAdapter
|
|
101
|
+
def target_table_name(table_name)
|
|
102
|
+
base_name = table_name[0...-7]
|
|
103
|
+
|
|
104
|
+
partition_datetime = DateTime.strptime(table_name.last(7), "%Y_%m")
|
|
105
|
+
|
|
106
|
+
base_name + partition_datetime.strftime("%Y%m%d")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def source_datetime_string
|
|
110
|
+
"YYYY_MM"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def source_name_suffix_pattern
|
|
114
|
+
/\A.+_p\d{4}_\d{2}\z/
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def target_datetime_string
|
|
118
|
+
"YYYYMMDD"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
class WeeklyPartmanRenameAdapter < AbstractPartmanRenameAdapter
|
|
123
|
+
def target_table_name(table_name)
|
|
124
|
+
base_name = table_name[0...-7]
|
|
125
|
+
|
|
126
|
+
partition_datetime = DateTime.strptime(table_name.last(7), "%Gw%V")
|
|
127
|
+
|
|
128
|
+
base_name + partition_datetime.strftime("%Y%m%d")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def source_datetime_string
|
|
132
|
+
"IYYY\"w\"IW"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def source_name_suffix_pattern
|
|
136
|
+
/\A.+_p\d{4}w\d{2}\z/
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def target_datetime_string
|
|
140
|
+
"YYYYMMDD"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class DayToMonthPartmanRenameAdapter < AbstractPartmanRenameAdapter
|
|
145
|
+
def target_table_name(table_name)
|
|
146
|
+
base_name = table_name[0...-10]
|
|
147
|
+
|
|
148
|
+
partition_datetime = DateTime.strptime(table_name.last(10), "%Y_%m_%d")
|
|
149
|
+
|
|
150
|
+
base_name + partition_datetime.strftime("%Y%m%d")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def source_datetime_string
|
|
154
|
+
"YYYY_MM_DD"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def source_name_suffix_pattern
|
|
158
|
+
/\A.+_p\d{4}_\d{2}_\d{2}\z/
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def target_datetime_string
|
|
162
|
+
"YYYYMMDD"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
class MinuteToDayPartmanRenameAdapter < AbstractPartmanRenameAdapter
|
|
167
|
+
def target_table_name(table_name)
|
|
168
|
+
base_name = table_name[0...-15]
|
|
169
|
+
|
|
170
|
+
partition_datetime = DateTime.strptime(table_name.last(15), "%Y_%m_%d_%H%M")
|
|
171
|
+
|
|
172
|
+
base_name + partition_datetime.strftime("%Y%m%d_%H%M%S")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def source_datetime_string
|
|
176
|
+
"YYYY_MM_DD_HH24MI"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def source_name_suffix_pattern
|
|
180
|
+
/\A.+_p\d{4}_\d{2}_\d{2}_\d{4}\z/
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def target_datetime_string
|
|
184
|
+
"YYYYMMDD_HH24MISS"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
class SecondToMinutePartmanRenameAdapter < AbstractPartmanRenameAdapter
|
|
189
|
+
def target_table_name(table_name)
|
|
190
|
+
base_name = table_name[0...-17]
|
|
191
|
+
|
|
192
|
+
partition_datetime = DateTime.strptime(table_name.last(17), "%Y_%m_%d_%H%M%S")
|
|
193
|
+
|
|
194
|
+
base_name + partition_datetime.strftime("%Y%m%d_%H%M%S")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def source_datetime_string
|
|
198
|
+
"YYYY_MM_DD_HH24MISS"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def source_name_suffix_pattern
|
|
202
|
+
/\A.+_p\d{4}_\d{2}_\d{2}_\d{6}\z/
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def target_datetime_string
|
|
206
|
+
"YYYYMMDD_HH24MISS"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -1,4 +1,8 @@
|
|
|
1
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.
|
|
2
6
|
Relation = Struct.new(:name, :schema, :mode) do
|
|
3
7
|
def self.connection
|
|
4
8
|
ActiveRecord::Base.connection
|
|
@@ -13,12 +17,6 @@ module PgHaMigrations
|
|
|
13
17
|
self.mode = LockMode.new(mode) if mode.present?
|
|
14
18
|
end
|
|
15
19
|
|
|
16
|
-
def conflicts_with?(other)
|
|
17
|
-
self == other && (
|
|
18
|
-
mode.nil? || other.mode.nil? || mode.conflicts_with?(other.mode)
|
|
19
|
-
)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
20
|
def fully_qualified_name
|
|
23
21
|
@fully_qualified_name ||= [
|
|
24
22
|
PG::Connection.quote_ident(schema),
|
|
@@ -30,8 +28,26 @@ module PgHaMigrations
|
|
|
30
28
|
name.present? && schema.present?
|
|
31
29
|
end
|
|
32
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
|
+
|
|
33
45
|
def ==(other)
|
|
34
|
-
|
|
46
|
+
eql?(other)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hash
|
|
50
|
+
[name, schema].hash
|
|
35
51
|
end
|
|
36
52
|
end
|
|
37
53
|
|
|
@@ -97,6 +113,18 @@ module PgHaMigrations
|
|
|
97
113
|
tables
|
|
98
114
|
end
|
|
99
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
|
+
|
|
100
128
|
def has_rows?
|
|
101
129
|
connection.select_value("SELECT EXISTS (SELECT 1 FROM #{fully_qualified_name} LIMIT 1)")
|
|
102
130
|
end
|
|
@@ -111,6 +139,30 @@ module PgHaMigrations
|
|
|
111
139
|
end
|
|
112
140
|
end
|
|
113
141
|
|
|
142
|
+
class PartmanTable < Table
|
|
143
|
+
IDENTIFIER_REGEX = /^[a-z_][a-z_\d]*$/
|
|
144
|
+
|
|
145
|
+
def initialize(name, schema, mode=nil)
|
|
146
|
+
if name !~ IDENTIFIER_REGEX
|
|
147
|
+
raise InvalidIdentifierError, "Partman requires table names to be lowercase with underscores"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if schema !~ IDENTIFIER_REGEX
|
|
151
|
+
raise InvalidIdentifierError, "Partman requires schema names to be lowercase with underscores"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
super
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def fully_qualified_name
|
|
158
|
+
"#{schema}.#{name}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def part_config(partman_extension:)
|
|
162
|
+
PgHaMigrations::PartmanConfig.find(fully_qualified_name, partman_extension: partman_extension)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
114
166
|
class Index < Relation
|
|
115
167
|
MAX_NAME_SIZE = 63 # bytes
|
|
116
168
|
|
|
@@ -152,4 +204,45 @@ module PgHaMigrations
|
|
|
152
204
|
SQL
|
|
153
205
|
end
|
|
154
206
|
end
|
|
207
|
+
|
|
208
|
+
class TableCollection
|
|
209
|
+
include Enumerable
|
|
210
|
+
|
|
211
|
+
attr_reader :raw_set
|
|
212
|
+
|
|
213
|
+
delegate :each, to: :raw_set
|
|
214
|
+
delegate :mode, to: :first
|
|
215
|
+
|
|
216
|
+
def self.from_table_names(tables, mode=nil)
|
|
217
|
+
new(tables) { |table| Table.from_table_name(table, mode) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def initialize(tables, &blk)
|
|
221
|
+
@raw_set = tables.map(&blk).to_set
|
|
222
|
+
|
|
223
|
+
if raw_set.empty?
|
|
224
|
+
raise ArgumentError, "Expected a non-empty list of tables"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
if raw_set.uniq(&:mode).size > 1
|
|
228
|
+
raise ArgumentError, "Expected all tables in collection to have the same lock mode"
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def subset?(other)
|
|
233
|
+
raw_set.subset?(other.raw_set)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def to_sql
|
|
237
|
+
map(&:fully_qualified_name).join(", ")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def with_partitions
|
|
241
|
+
tables = flat_map do |table|
|
|
242
|
+
table.partitions(include_sub_partitions: true, include_self: true)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
self.class.new(tables)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
155
248
|
end
|