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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +41 -126
- data/exe/pgslice +1 -1
- data/lib/pgslice.rb +13 -3
- data/lib/pgslice/cli.rb +32 -0
- data/lib/pgslice/cli/add_partitions.rb +130 -0
- data/lib/pgslice/cli/analyze.rb +13 -0
- data/lib/pgslice/cli/fill.rb +103 -0
- data/lib/pgslice/cli/prep.rb +97 -0
- data/lib/pgslice/cli/swap.rb +28 -0
- data/lib/pgslice/cli/unprep.rb +18 -0
- data/lib/pgslice/cli/unswap.rb +25 -0
- data/lib/pgslice/helpers.rb +176 -0
- data/lib/pgslice/table.rb +139 -23
- data/lib/pgslice/version.rb +1 -1
- metadata +11 -4
- data/lib/pgslice/client.rb +0 -584
- data/lib/pgslice/generic_table.rb +0 -89
data/lib/pgslice/table.rb
CHANGED
@@ -1,24 +1,93 @@
|
|
1
1
|
module PgSlice
|
2
|
-
class Table
|
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("#{
|
73
|
+
self.class.new(schema, "#{name}_intermediate")
|
5
74
|
end
|
6
75
|
|
7
76
|
def retired_table
|
8
|
-
self.class.new("#{
|
77
|
+
self.class.new(schema, "#{name}_retired")
|
9
78
|
end
|
10
79
|
|
11
80
|
def trigger_name
|
12
|
-
"#{
|
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
|
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
|
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
|
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
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
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
|
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
|
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"
|
data/lib/pgslice/version.rb
CHANGED
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
|
+
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-
|
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/
|
96
|
-
- lib/pgslice/
|
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
|
data/lib/pgslice/client.rb
DELETED
@@ -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
|