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.
@@ -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
- # This method is called by unsafe_partman_update_config to set the fully
6
- # qualified table name, as partman is often installed in a schema that
7
- # is not included the application's search path
8
- def self.schema=(schema)
9
- self.table_name = "#{schema}.part_config"
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
- other.is_a?(Relation) && name == other.name && schema == other.schema
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