pgslice 0.4.4 → 0.4.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -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