pgslice 0.4.4 → 0.4.5

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,24 +1,93 @@
1
1
  module PgSlice
2
- class Table < GenericTable
2
+ class Table
3
+ attr_reader :schema, :name
4
+
5
+ def initialize(schema, name)
6
+ @schema = schema
7
+ @name = name
8
+ end
9
+
10
+ def to_s
11
+ [schema, name].join(".")
12
+ end
13
+
14
+ def exists?
15
+ execute("SELECT COUNT(*) FROM pg_catalog.pg_tables WHERE schemaname = $1 AND tablename = $2", [schema, name]).first["count"].to_i > 0
16
+ end
17
+
18
+ def columns
19
+ execute("SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2", [schema, name]).map{ |r| r["column_name"] }
20
+ end
21
+
22
+ # http://www.dbforums.com/showthread.php?1667561-How-to-list-sequences-and-the-columns-by-SQL
23
+ def sequences
24
+ query = <<-SQL
25
+ SELECT
26
+ a.attname as related_column,
27
+ s.relname as sequence_name
28
+ FROM pg_class s
29
+ JOIN pg_depend d ON d.objid = s.oid
30
+ JOIN pg_class t ON d.objid = s.oid AND d.refobjid = t.oid
31
+ JOIN pg_attribute a ON (d.refobjid, d.refobjsubid) = (a.attrelid, a.attnum)
32
+ JOIN pg_namespace n ON n.oid = s.relnamespace
33
+ WHERE s.relkind = 'S'
34
+ AND n.nspname = $1
35
+ AND t.relname = $2
36
+ SQL
37
+ execute(query, [schema, name])
38
+ end
39
+
40
+ def foreign_keys
41
+ execute("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = #{regclass} AND contype ='f'").map { |r| r["pg_get_constraintdef"] }
42
+ end
43
+
44
+ # http://stackoverflow.com/a/20537829
45
+ def primary_key
46
+ query = <<-SQL
47
+ SELECT
48
+ pg_attribute.attname,
49
+ format_type(pg_attribute.atttypid, pg_attribute.atttypmod)
50
+ FROM
51
+ pg_index, pg_class, pg_attribute, pg_namespace
52
+ WHERE
53
+ nspname = $1 AND
54
+ relname = $2 AND
55
+ indrelid = pg_class.oid AND
56
+ pg_class.relnamespace = pg_namespace.oid AND
57
+ pg_attribute.attrelid = pg_class.oid AND
58
+ pg_attribute.attnum = any(pg_index.indkey) AND
59
+ indisprimary
60
+ SQL
61
+ execute(query, [schema, name]).map { |r| r["attname"] }
62
+ end
63
+
64
+ def index_defs
65
+ execute("SELECT pg_get_indexdef(indexrelid) FROM pg_index WHERE indrelid = #{regclass} AND indisprimary = 'f'").map { |r| r["pg_get_indexdef"] }
66
+ end
67
+
68
+ def quote_table
69
+ [quote_ident(schema), quote_ident(name)].join(".")
70
+ end
71
+
3
72
  def intermediate_table
4
- self.class.new("#{table}_intermediate")
73
+ self.class.new(schema, "#{name}_intermediate")
5
74
  end
6
75
 
7
76
  def retired_table
8
- self.class.new("#{table}_retired")
77
+ self.class.new(schema, "#{name}_retired")
9
78
  end
10
79
 
11
80
  def trigger_name
12
- "#{table.split(".")[-1]}_insert_trigger"
81
+ "#{name}_insert_trigger"
13
82
  end
14
83
 
15
84
  def column_cast(column)
16
- data_type = execute("SELECT data_type FROM information_schema.columns WHERE table_schema || '.' || table_name = $1 AND column_name = $2", [table, column])[0]["data_type"]
85
+ data_type = execute("SELECT data_type FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 AND column_name = $3", [schema, name, column])[0]["data_type"]
17
86
  data_type == "timestamp with time zone" ? "timestamptz" : "date"
18
87
  end
19
88
 
20
89
  def max_id(primary_key, below: nil, where: nil)
21
- query = "SELECT MAX(#{quote_ident(primary_key)}) FROM #{quote_table(table)}"
90
+ query = "SELECT MAX(#{quote_ident(primary_key)}) FROM #{quote_table}"
22
91
  conditions = []
23
92
  conditions << "#{quote_ident(primary_key)} <= #{below}" if below
24
93
  conditions << where if where
@@ -27,7 +96,7 @@ module PgSlice
27
96
  end
28
97
 
29
98
  def min_id(primary_key, column, cast, starting_time, where)
30
- query = "SELECT MIN(#{quote_ident(primary_key)}) FROM #{quote_table(table)}"
99
+ query = "SELECT MIN(#{quote_ident(primary_key)}) FROM #{quote_table}"
31
100
  conditions = []
32
101
  conditions << "#{quote_ident(column)} >= #{sql_date(starting_time, cast)}" if starting_time
33
102
  conditions << where if where
@@ -35,32 +104,79 @@ module PgSlice
35
104
  (execute(query)[0]["min"] || 1).to_i
36
105
  end
37
106
 
38
- def existing_partitions(period = nil)
39
- count =
40
- case period
41
- when "day"
42
- 8
43
- when "month"
44
- 6
45
- when "year"
46
- 4
47
- else
48
- "6,8"
49
- end
50
-
51
- existing_tables(like: "#{table}_%").select { |t| /\A#{Regexp.escape("#{table}_")}\d{#{count}}\z/.match(t) }
107
+ def partitions
108
+ query = <<-SQL
109
+ SELECT
110
+ nmsp_child.nspname AS schema,
111
+ child.relname AS name
112
+ FROM pg_inherits
113
+ JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
114
+ JOIN pg_class child ON pg_inherits.inhrelid = child.oid
115
+ JOIN pg_namespace nmsp_parent ON nmsp_parent.oid = parent.relnamespace
116
+ JOIN pg_namespace nmsp_child ON nmsp_child.oid = child.relnamespace
117
+ WHERE
118
+ nmsp_parent.nspname = $1 AND
119
+ parent.relname = $2
120
+ SQL
121
+ execute(query, [schema, name]).map { |r| Table.new(r["schema"], r["name"]) }
52
122
  end
53
123
 
54
124
  def fetch_comment
55
- execute("SELECT obj_description(#{regclass(table)}) AS comment")[0]
125
+ execute("SELECT obj_description(#{regclass}) AS comment")[0]
56
126
  end
57
127
 
58
128
  def fetch_trigger(trigger_name)
59
- execute("SELECT obj_description(oid, 'pg_trigger') AS comment FROM pg_trigger WHERE tgname = $1 AND tgrelid = #{regclass(table)}", [trigger_name])[0]
129
+ execute("SELECT obj_description(oid, 'pg_trigger') AS comment FROM pg_trigger WHERE tgname = $1 AND tgrelid = #{regclass}", [trigger_name])[0]
130
+ end
131
+
132
+ # legacy
133
+ def fetch_settings(trigger_name)
134
+ needs_comment = false
135
+ trigger_comment = fetch_trigger(trigger_name)
136
+ comment = trigger_comment || fetch_comment
137
+ if comment
138
+ field, period, cast, version = comment["comment"].split(",").map { |v| v.split(":").last } rescue []
139
+ version = version.to_i if version
140
+ end
141
+
142
+ unless period
143
+ needs_comment = true
144
+ function_def = execute("SELECT pg_get_functiondef(oid) FROM pg_proc WHERE proname = $1", [trigger_name])[0]
145
+ return [] unless function_def
146
+ function_def = function_def["pg_get_functiondef"]
147
+ sql_format = SQL_FORMAT.find { |_, f| function_def.include?("'#{f}'") }
148
+ return [] unless sql_format
149
+ period = sql_format[0]
150
+ field = /to_char\(NEW\.(\w+),/.match(function_def)[1]
151
+ end
152
+
153
+ # backwards compatibility with 0.2.3 and earlier (pre-timestamptz support)
154
+ unless cast
155
+ cast = "date"
156
+ # update comment to explicitly define cast
157
+ needs_comment = true
158
+ end
159
+
160
+ version ||= trigger_comment ? 1 : 2
161
+ declarative = version > 1
162
+
163
+ [period, field, cast, needs_comment, declarative, version]
60
164
  end
61
165
 
62
166
  protected
63
167
 
168
+ def execute(*args)
169
+ PgSlice::CLI.instance.send(:execute, *args)
170
+ end
171
+
172
+ def quote_ident(value)
173
+ PG::Connection.quote_ident(value)
174
+ end
175
+
176
+ def regclass
177
+ "'#{quote_table}'::regclass"
178
+ end
179
+
64
180
  def sql_date(time, cast, add_cast = true)
65
181
  if cast == "timestamptz"
66
182
  fmt = "%Y-%m-%d %H:%M:%S UTC"
@@ -1,3 +1,3 @@
1
1
  module PgSlice
2
- VERSION = "0.4.4"
2
+ VERSION = "0.4.5"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgslice
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.4
4
+ version: 0.4.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-08-18 00:00:00.000000000 Z
11
+ date: 2018-10-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -92,8 +92,15 @@ files:
92
92
  - README.md
93
93
  - exe/pgslice
94
94
  - lib/pgslice.rb
95
- - lib/pgslice/client.rb
96
- - lib/pgslice/generic_table.rb
95
+ - lib/pgslice/cli.rb
96
+ - lib/pgslice/cli/add_partitions.rb
97
+ - lib/pgslice/cli/analyze.rb
98
+ - lib/pgslice/cli/fill.rb
99
+ - lib/pgslice/cli/prep.rb
100
+ - lib/pgslice/cli/swap.rb
101
+ - lib/pgslice/cli/unprep.rb
102
+ - lib/pgslice/cli/unswap.rb
103
+ - lib/pgslice/helpers.rb
97
104
  - lib/pgslice/table.rb
98
105
  - lib/pgslice/version.rb
99
106
  homepage: https://github.com/ankane/pgslice
@@ -1,584 +0,0 @@
1
- module PgSlice
2
- class Client < Thor
3
- check_unknown_options!
4
-
5
- class_option :url, desc: "Database URL"
6
- class_option :dry_run, type: :boolean, default: false, desc: "Print statements without executing"
7
-
8
- map %w[--version -v] => :version
9
-
10
- def self.exit_on_failure?
11
- true
12
- end
13
-
14
- SQL_FORMAT = {
15
- day: "YYYYMMDD",
16
- month: "YYYYMM",
17
- year: "YYYY"
18
- }
19
-
20
- def initialize(*args)
21
- $client = self
22
- $stdout.sync = true
23
- $stderr.sync = true
24
- super
25
- end
26
-
27
- desc "prep TABLE [COLUMN] [PERIOD]", "Create an intermediate table for partitioning"
28
- option :partition, type: :boolean, default: true, desc: "Partition the table"
29
- option :trigger_based, type: :boolean, default: false, desc: "Use trigger-based partitioning"
30
- def prep(table, column=nil, period=nil)
31
- table = qualify_table(table)
32
- intermediate_table = table.intermediate_table
33
- trigger_name = table.trigger_name
34
-
35
- unless options[:partition]
36
- abort "Usage: \"pgslice prep TABLE --no-partition\"" if column || period
37
- abort "Can't use --trigger-based and --no-partition" if options[:trigger_based]
38
- end
39
- abort "Table not found: #{table}" unless table.exists?
40
- abort "Table already exists: #{intermediate_table}" if intermediate_table.exists?
41
-
42
- if options[:partition]
43
- abort "Usage: \"pgslice prep TABLE COLUMN PERIOD\"" if !(column && period)
44
- abort "Column not found: #{column}" unless table.columns.include?(column)
45
- abort "Invalid period: #{period}" unless SQL_FORMAT[period.to_sym]
46
- end
47
-
48
- queries = []
49
-
50
- declarative = server_version_num >= 100000 && !options[:trigger_based]
51
-
52
- if declarative && options[:partition]
53
- queries << <<-SQL
54
- CREATE TABLE #{quote_table(intermediate_table)} (LIKE #{quote_table(table)} INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING STORAGE INCLUDING COMMENTS) PARTITION BY RANGE (#{quote_table(column)});
55
- SQL
56
-
57
- if server_version_num >= 110000
58
- index_defs = table.index_defs
59
- index_defs.each do |index_def|
60
- queries << index_def.sub(/ ON \S+ USING /, " ON #{quote_table(intermediate_table)} USING ").sub(/ INDEX .+ ON /, " INDEX ON ") + ";"
61
- end
62
- end
63
-
64
- # add comment
65
- cast = table.column_cast(column)
66
- queries << <<-SQL
67
- COMMENT ON TABLE #{quote_table(intermediate_table)} is 'column:#{column},period:#{period},cast:#{cast}';
68
- SQL
69
- else
70
- queries << <<-SQL
71
- CREATE TABLE #{quote_table(intermediate_table)} (LIKE #{quote_table(table)} INCLUDING ALL);
72
- SQL
73
-
74
- table.foreign_keys.each do |fk_def|
75
- queries << "ALTER TABLE #{quote_table(intermediate_table)} ADD #{fk_def};"
76
- end
77
- end
78
-
79
- if options[:partition] && !declarative
80
- queries << <<-SQL
81
- CREATE FUNCTION #{quote_ident(trigger_name)}()
82
- RETURNS trigger AS $$
83
- BEGIN
84
- RAISE EXCEPTION 'Create partitions first.';
85
- END;
86
- $$ LANGUAGE plpgsql;
87
- SQL
88
-
89
- queries << <<-SQL
90
- CREATE TRIGGER #{quote_ident(trigger_name)}
91
- BEFORE INSERT ON #{quote_table(intermediate_table)}
92
- FOR EACH ROW EXECUTE PROCEDURE #{quote_ident(trigger_name)}();
93
- SQL
94
-
95
- cast = table.column_cast(column)
96
- queries << <<-SQL
97
- COMMENT ON TRIGGER #{quote_ident(trigger_name)} ON #{quote_table(intermediate_table)} is 'column:#{column},period:#{period},cast:#{cast}';
98
- SQL
99
- end
100
-
101
- run_queries(queries)
102
- end
103
-
104
- desc "unprep TABLE", "Undo prep"
105
- def unprep(table)
106
- table = qualify_table(table)
107
- intermediate_table = table.intermediate_table
108
- trigger_name = table.trigger_name
109
-
110
- abort "Table not found: #{intermediate_table}" unless intermediate_table.exists?
111
-
112
- queries = [
113
- "DROP TABLE #{quote_table(intermediate_table)} CASCADE;",
114
- "DROP FUNCTION IF EXISTS #{quote_ident(trigger_name)}();"
115
- ]
116
- run_queries(queries)
117
- end
118
-
119
- desc "add_partitions TABLE", "Add partitions"
120
- option :intermediate, type: :boolean, default: false, desc: "Add to intermediate table"
121
- option :past, type: :numeric, default: 0, desc: "Number of past partitions to add"
122
- option :future, type: :numeric, default: 0, desc: "Number of future partitions to add"
123
- def add_partitions(table)
124
- original_table = qualify_table(table)
125
- table = options[:intermediate] ? original_table.intermediate_table : original_table
126
- trigger_name = original_table.trigger_name
127
-
128
- abort "Table not found: #{table}" unless table.exists?
129
-
130
- future = options[:future]
131
- past = options[:past]
132
- range = (-1 * past)..future
133
-
134
- period, field, cast, needs_comment, declarative = settings_from_trigger(original_table, table)
135
- unless period
136
- message = "No settings found: #{table}"
137
- message = "#{message}\nDid you mean to use --intermediate?" unless options[:intermediate]
138
- abort message
139
- end
140
-
141
- queries = []
142
-
143
- if needs_comment
144
- queries << "COMMENT ON TRIGGER #{quote_ident(trigger_name)} ON #{quote_table(table)} is 'column:#{field},period:#{period},cast:#{cast}';"
145
- end
146
-
147
- # today = utc date
148
- today = round_date(DateTime.now.new_offset(0).to_date, period)
149
-
150
- schema_table =
151
- if !declarative
152
- table
153
- elsif options[:intermediate]
154
- original_table
155
- else
156
- Table.new(original_table.existing_partitions(period).last)
157
- end
158
-
159
- # indexes automatically propagate in Postgres 11+
160
- index_defs =
161
- if !declarative || server_version_num < 110000
162
- schema_table.index_defs
163
- else
164
- []
165
- end
166
-
167
- fk_defs = schema_table.foreign_keys
168
- primary_key = schema_table.primary_key
169
-
170
- added_partitions = []
171
- range.each do |n|
172
- day = advance_date(today, period, n)
173
-
174
- partition_name = Table.new("#{original_table}_#{day.strftime(name_format(period))}")
175
- next if partition_name.exists?
176
- added_partitions << partition_name.to_s
177
-
178
- if declarative
179
- queries << <<-SQL
180
- CREATE TABLE #{quote_table(partition_name)} PARTITION OF #{quote_table(table)} FOR VALUES FROM (#{sql_date(day, cast, false)}) TO (#{sql_date(advance_date(day, period, 1), cast, false)});
181
- SQL
182
- else
183
- queries << <<-SQL
184
- CREATE TABLE #{quote_table(partition_name)}
185
- (CHECK (#{quote_ident(field)} >= #{sql_date(day, cast)} AND #{quote_ident(field)} < #{sql_date(advance_date(day, period, 1), cast)}))
186
- INHERITS (#{quote_table(table)});
187
- SQL
188
- end
189
-
190
- queries << "ALTER TABLE #{quote_table(partition_name)} ADD PRIMARY KEY (#{primary_key.map { |k| quote_ident(k) }.join(", ")});" if primary_key.any?
191
-
192
- index_defs.each do |index_def|
193
- queries << index_def.sub(/ ON \S+ USING /, " ON #{quote_table(partition_name)} USING ").sub(/ INDEX .+ ON /, " INDEX ON ") + ";"
194
- end
195
-
196
- fk_defs.each do |fk_def|
197
- queries << "ALTER TABLE #{quote_table(partition_name)} ADD #{fk_def};"
198
- end
199
- end
200
-
201
- unless declarative
202
- # update trigger based on existing partitions
203
- current_defs = []
204
- future_defs = []
205
- past_defs = []
206
- name_format = self.name_format(period)
207
- existing_tables = original_table.existing_partitions(period)
208
- existing_tables = (existing_tables + added_partitions).uniq.sort
209
-
210
- existing_tables.each do |existing_table|
211
- day = DateTime.strptime(existing_table.split("_").last, name_format)
212
- partition_name = "#{original_table}_#{day.strftime(name_format(period))}"
213
-
214
- sql = "(NEW.#{quote_ident(field)} >= #{sql_date(day, cast)} AND NEW.#{quote_ident(field)} < #{sql_date(advance_date(day, period, 1), cast)}) THEN
215
- INSERT INTO #{quote_table(partition_name)} VALUES (NEW.*);"
216
-
217
- if day.to_date < today
218
- past_defs << sql
219
- elsif advance_date(day, period, 1) < today
220
- current_defs << sql
221
- else
222
- future_defs << sql
223
- end
224
- end
225
-
226
- # order by current period, future periods asc, past periods desc
227
- trigger_defs = current_defs + future_defs + past_defs.reverse
228
-
229
- if trigger_defs.any?
230
- queries << <<-SQL
231
- CREATE OR REPLACE FUNCTION #{quote_ident(trigger_name)}()
232
- RETURNS trigger AS $$
233
- BEGIN
234
- IF #{trigger_defs.join("\n ELSIF ")}
235
- ELSE
236
- RAISE EXCEPTION 'Date out of range. Ensure partitions are created.';
237
- END IF;
238
- RETURN NULL;
239
- END;
240
- $$ LANGUAGE plpgsql;
241
- SQL
242
- end
243
- end
244
-
245
- run_queries(queries) if queries.any?
246
- end
247
-
248
- desc "fill TABLE", "Fill the partitions in batches"
249
- option :batch_size, type: :numeric, default: 10000, desc: "Batch size"
250
- option :swapped, type: :boolean, default: false, desc: "Use swapped table"
251
- option :source_table, desc: "Source table"
252
- option :dest_table, desc: "Destination table"
253
- option :start, type: :numeric, desc: "Primary key to start"
254
- option :where, desc: "Conditions to filter"
255
- option :sleep, type: :numeric, desc: "Seconds to sleep between batches"
256
- def fill(table)
257
- table = qualify_table(table)
258
- source_table = qualify_table(options[:source_table]) if options[:source_table]
259
- dest_table = qualify_table(options[:dest_table]) if options[:dest_table]
260
-
261
- if options[:swapped]
262
- source_table ||= table.retired_table
263
- dest_table ||= table
264
- else
265
- source_table ||= table
266
- dest_table ||= table.intermediate_table
267
- end
268
-
269
- abort "Table not found: #{source_table}" unless source_table.exists?
270
- abort "Table not found: #{dest_table}" unless dest_table.exists?
271
-
272
- period, field, cast, _needs_comment, declarative = settings_from_trigger(table, dest_table)
273
-
274
- if period
275
- name_format = self.name_format(period)
276
-
277
- existing_tables = table.existing_partitions(period)
278
- if existing_tables.any?
279
- starting_time = DateTime.strptime(existing_tables.first.split("_").last, name_format)
280
- ending_time = advance_date(DateTime.strptime(existing_tables.last.split("_").last, name_format), period, 1)
281
- end
282
- end
283
-
284
- schema_table = period && declarative ? Table.new(existing_tables.last) : table
285
-
286
- primary_key = schema_table.primary_key[0]
287
- abort "No primary key" unless primary_key
288
-
289
- max_source_id = nil
290
- begin
291
- max_source_id = source_table.max_id(primary_key)
292
- rescue PG::UndefinedFunction
293
- abort "Only numeric primary keys are supported"
294
- end
295
-
296
- max_dest_id =
297
- if options[:start]
298
- options[:start]
299
- elsif options[:swapped]
300
- dest_table.max_id(primary_key, where: options[:where], below: max_source_id)
301
- else
302
- dest_table.max_id(primary_key, where: options[:where])
303
- end
304
-
305
- if max_dest_id == 0 && !options[:swapped]
306
- min_source_id = source_table.min_id(primary_key, field, cast, starting_time, options[:where])
307
- max_dest_id = min_source_id - 1 if min_source_id
308
- end
309
-
310
- starting_id = max_dest_id
311
- fields = source_table.columns.map { |c| quote_ident(c) }.join(", ")
312
- batch_size = options[:batch_size]
313
-
314
- i = 1
315
- batch_count = ((max_source_id - starting_id) / batch_size.to_f).ceil
316
-
317
- if batch_count == 0
318
- log_sql "/* nothing to fill */"
319
- end
320
-
321
- while starting_id < max_source_id
322
- where = "#{quote_ident(primary_key)} > #{starting_id} AND #{quote_ident(primary_key)} <= #{starting_id + batch_size}"
323
- if starting_time
324
- where << " AND #{quote_ident(field)} >= #{sql_date(starting_time, cast)} AND #{quote_ident(field)} < #{sql_date(ending_time, cast)}"
325
- end
326
- if options[:where]
327
- where << " AND #{options[:where]}"
328
- end
329
-
330
- query = <<-SQL
331
- /* #{i} of #{batch_count} */
332
- INSERT INTO #{quote_table(dest_table)} (#{fields})
333
- SELECT #{fields} FROM #{quote_table(source_table)}
334
- WHERE #{where}
335
- SQL
336
-
337
- run_query(query)
338
-
339
- starting_id += batch_size
340
- i += 1
341
-
342
- if options[:sleep] && starting_id <= max_source_id
343
- sleep(options[:sleep])
344
- end
345
- end
346
- end
347
-
348
- desc "swap TABLE", "Swap the intermediate table with the original table"
349
- option :lock_timeout, default: "5s", desc: "Lock timeout"
350
- def swap(table)
351
- table = qualify_table(table)
352
- intermediate_table = table.intermediate_table
353
- retired_table = table.retired_table
354
-
355
- abort "Table not found: #{table}" unless table.exists?
356
- abort "Table not found: #{intermediate_table}" unless intermediate_table.exists?
357
- abort "Table already exists: #{retired_table}" if retired_table.exists?
358
-
359
- queries = [
360
- "ALTER TABLE #{quote_table(table)} RENAME TO #{quote_no_schema(retired_table)};",
361
- "ALTER TABLE #{quote_table(intermediate_table)} RENAME TO #{quote_no_schema(table)};"
362
- ]
363
-
364
- table.sequences.each do |sequence|
365
- queries << "ALTER SEQUENCE #{quote_ident(sequence["sequence_name"])} OWNED BY #{quote_table(table)}.#{quote_ident(sequence["related_column"])};"
366
- end
367
-
368
- queries.unshift("SET LOCAL lock_timeout = '#{options[:lock_timeout]}';") if server_version_num >= 90300
369
-
370
- run_queries(queries)
371
- end
372
-
373
- desc "unswap TABLE", "Undo swap"
374
- def unswap(table)
375
- table = qualify_table(table)
376
- intermediate_table = table.intermediate_table
377
- retired_table = table.retired_table
378
-
379
- abort "Table not found: #{table}" unless table.exists?
380
- abort "Table not found: #{retired_table}" unless retired_table.exists?
381
- abort "Table already exists: #{intermediate_table}" if intermediate_table.exists?
382
-
383
- queries = [
384
- "ALTER TABLE #{quote_table(table)} RENAME TO #{quote_no_schema(intermediate_table)};",
385
- "ALTER TABLE #{quote_table(retired_table)} RENAME TO #{quote_no_schema(table)};"
386
- ]
387
-
388
- table.sequences.each do |sequence|
389
- queries << "ALTER SEQUENCE #{quote_ident(sequence["sequence_name"])} OWNED BY #{quote_table(table)}.#{quote_ident(sequence["related_column"])};"
390
- end
391
-
392
- run_queries(queries)
393
- end
394
-
395
- desc "analyze TABLE", "Analyze tables"
396
- option :swapped, type: :boolean, default: false, desc: "Use swapped table"
397
- def analyze(table)
398
- table = qualify_table(table)
399
- parent_table = options[:swapped] ? table : table.intermediate_table
400
-
401
- existing_tables = table.existing_partitions
402
- analyze_list = existing_tables + [parent_table]
403
- run_queries_without_transaction(analyze_list.map { |t| "ANALYZE VERBOSE #{quote_table(t)};" })
404
- end
405
-
406
- desc "version", "Show version"
407
- def version
408
- log("pgslice #{PgSlice::VERSION}")
409
- end
410
-
411
- protected
412
-
413
- # output
414
-
415
- def log(message = nil)
416
- error message
417
- end
418
-
419
- def log_sql(message = nil)
420
- say message
421
- end
422
-
423
- def abort(message)
424
- raise Thor::Error, message
425
- end
426
-
427
- # database connection
428
-
429
- def connection
430
- @connection ||= begin
431
- url = options[:url] || ENV["PGSLICE_URL"]
432
- abort "Set PGSLICE_URL or use the --url option" unless url
433
-
434
- uri = URI.parse(url)
435
- params = CGI.parse(uri.query.to_s)
436
- # remove schema
437
- @schema = Array(params.delete("schema") || "public")[0]
438
- uri.query = URI.encode_www_form(params)
439
-
440
- ENV["PGCONNECT_TIMEOUT"] ||= "1"
441
- PG::Connection.new(uri.to_s)
442
- end
443
- rescue PG::ConnectionBad => e
444
- abort e.message
445
- rescue URI::InvalidURIError
446
- abort "Invalid url"
447
- end
448
-
449
- def schema
450
- connection # ensure called first
451
- @schema
452
- end
453
-
454
- def execute(query, params = [])
455
- connection.exec_params(query, params).to_a
456
- end
457
-
458
- def run_queries(queries)
459
- connection.transaction do
460
- execute("SET LOCAL client_min_messages TO warning") unless options[:dry_run]
461
- log_sql "BEGIN;"
462
- log_sql
463
- run_queries_without_transaction(queries)
464
- log_sql "COMMIT;"
465
- end
466
- end
467
-
468
- def run_query(query)
469
- log_sql query
470
- unless options[:dry_run]
471
- begin
472
- execute(query)
473
- rescue PG::ServerError => e
474
- abort("#{e.class.name}: #{e.message}")
475
- end
476
- end
477
- log_sql
478
- end
479
-
480
- def run_queries_without_transaction(queries)
481
- queries.each do |query|
482
- run_query(query)
483
- end
484
- end
485
-
486
- def server_version_num
487
- execute("SHOW server_version_num")[0]["server_version_num"].to_i
488
- end
489
-
490
- # helpers
491
-
492
- def sql_date(time, cast, add_cast = true)
493
- if cast == "timestamptz"
494
- fmt = "%Y-%m-%d %H:%M:%S UTC"
495
- else
496
- fmt = "%Y-%m-%d"
497
- end
498
- str = "'#{time.strftime(fmt)}'"
499
- add_cast ? "#{str}::#{cast}" : str
500
- end
501
-
502
- def name_format(period)
503
- case period.to_sym
504
- when :day
505
- "%Y%m%d"
506
- when :month
507
- "%Y%m"
508
- else
509
- "%Y"
510
- end
511
- end
512
-
513
- def round_date(date, period)
514
- date = date.to_date
515
- case period.to_sym
516
- when :day
517
- date
518
- when :month
519
- Date.new(date.year, date.month)
520
- else
521
- Date.new(date.year)
522
- end
523
- end
524
-
525
- def advance_date(date, period, count = 1)
526
- date = date.to_date
527
- case period.to_sym
528
- when :day
529
- date.next_day(count)
530
- when :month
531
- date.next_month(count)
532
- else
533
- date.next_year(count)
534
- end
535
- end
536
-
537
- def quote_ident(value)
538
- PG::Connection.quote_ident(value)
539
- end
540
-
541
- def quote_table(table)
542
- table.to_s.split(".", 2).map { |v| quote_ident(v) }.join(".")
543
- end
544
-
545
- def quote_no_schema(table)
546
- quote_ident(table.to_s.split(".", 2)[-1])
547
- end
548
-
549
- def qualify_table(table)
550
- Table.new(table.to_s.include?(".") ? table : [schema, table].join("."))
551
- end
552
-
553
- def settings_from_trigger(original_table, table)
554
- trigger_name = original_table.trigger_name
555
-
556
- needs_comment = false
557
- trigger_comment = table.fetch_trigger(trigger_name)
558
- comment = trigger_comment || table.fetch_comment
559
- if comment
560
- field, period, cast = comment["comment"].split(",").map { |v| v.split(":").last } rescue [nil, nil, nil]
561
- end
562
-
563
- unless period
564
- needs_comment = true
565
- function_def = execute("SELECT pg_get_functiondef(oid) FROM pg_proc WHERE proname = $1", [trigger_name])[0]
566
- return [] unless function_def
567
- function_def = function_def["pg_get_functiondef"]
568
- sql_format = SQL_FORMAT.find { |_, f| function_def.include?("'#{f}'") }
569
- return [] unless sql_format
570
- period = sql_format[0]
571
- field = /to_char\(NEW\.(\w+),/.match(function_def)[1]
572
- end
573
-
574
- # backwards compatibility with 0.2.3 and earlier (pre-timestamptz support)
575
- unless cast
576
- cast = "date"
577
- # update comment to explicitly define cast
578
- needs_comment = true
579
- end
580
-
581
- [period, field, cast, needs_comment, !trigger_comment]
582
- end
583
- end
584
- end