pgslice 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 09c384b3832fb897a3efde949c8d1d9c8e3e2d64
4
+ data.tar.gz: 6b28c7e711c74e88683a565af2f7b0b0f3de2f8a
5
+ SHA512:
6
+ metadata.gz: 219a357d1eef81483fd613ed19d4e24cfc8ae3c0082a9791f01b3160df9e7d30ec77dac317d25255c76fdeb775425e0cf4c4aa19000bf75e1402e33ba6b48033
7
+ data.tar.gz: 073a77f7ab738968c466bbc05ba6b4415b451dd6808b110373844f321d337a671d9a999c4a7ba373d97f531179469204422ec93e3fbd26b742504cc6ca431c49
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in pgslice.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # pgslice
2
+
3
+ Postgres partitioning as easy as pie
4
+
5
+ ## Install
6
+
7
+ Run
8
+
9
+ ```sh
10
+ gem install pgslice
11
+ ```
12
+
13
+ ## Steps
14
+
15
+ 1. Specify your database credentials
16
+
17
+ ```sh
18
+ export PGSLICE_URL=postgres://localhost/myapp_development
19
+ ```
20
+
21
+ 2. Create an intermediate table
22
+
23
+ ```sh
24
+ pgslice prep <table> <column> <period>
25
+ ```
26
+
27
+ Period can be `day` or `month`.
28
+
29
+ This creates a table named `<table>_intermediate` with the appropriate trigger for partitioning.
30
+
31
+ 3. Add partitions
32
+
33
+ ```sh
34
+ pgslice add_partitions <table> --intermediate --past 3 --future 3
35
+ ```
36
+
37
+ This creates child tables that inherit from the intermediate table.
38
+
39
+ Use the `--past` and `--future` options to control the number of partitions.
40
+
41
+ 4. *Optional, for tables with data* - Fill the partitions in batches with data from the original table
42
+
43
+ ```sh
44
+ pgslice fill <table>
45
+ ```
46
+
47
+ Use the `--batch-size` and `--sleep` options to control the speed.
48
+
49
+ 5. Swap the intermediate table with the original table
50
+
51
+ ```sh
52
+ pgslice swap <table>
53
+ ```
54
+
55
+ The original table is renamed `<table>_retired` and the intermediate table is renamed `<table>`.
56
+
57
+ 6. Fill the rest
58
+
59
+ ```sh
60
+ pgslice fill <table> --swapped
61
+ ```
62
+
63
+ 7. Archive and drop the original table
64
+
65
+ ## Adding Partitions
66
+
67
+ ```sh
68
+ pgslice add_partitions <table> --future 3
69
+ ```
70
+
71
+ ## Additional Commands
72
+
73
+ To undo prep and delete partitions, use:
74
+
75
+ ```sh
76
+ pgslice unprep <table>
77
+ ```
78
+
79
+ To undo swap, use:
80
+
81
+ ```sh
82
+ pgslice unswap <table>
83
+ ```
84
+
85
+ ## Upgrading
86
+
87
+ Run:
88
+
89
+ ```sh
90
+ gem install pgslice
91
+ ```
92
+
93
+ To use master, run:
94
+
95
+ ```sh
96
+ gem install specific_install
97
+ gem specific_install ankane/pgslice
98
+ ```
99
+
100
+ ## Contributing
101
+
102
+ Everyone is encouraged to help improve this project. Here are a few ways you can help:
103
+
104
+ - [Report bugs](https://github.com/ankane/pgslice/issues)
105
+ - Fix bugs and [submit pull requests](https://github.com/ankane/pgslice/pulls)
106
+ - Write, clarify, or fix documentation
107
+ - Suggest or add new features
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task default: :spec
data/exe/pgslice ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "pgslice"
4
+ begin
5
+ PgSlice::Client.new(ARGV).perform
6
+ rescue PgSlice::Error => e
7
+ abort e.message
8
+ rescue Interrupt => e
9
+ # do nothing
10
+ end
data/lib/pgslice.rb ADDED
@@ -0,0 +1,382 @@
1
+ require "pgslice/version"
2
+ require "slop"
3
+ require "pg"
4
+ require "active_support/all"
5
+
6
+ module PgSlice
7
+ class Error < StandardError; end
8
+
9
+ class Client
10
+ attr_reader :arguments, :options
11
+
12
+ SQL_FORMAT = {
13
+ day: "YYYYMMDD",
14
+ month: "YYYYMM"
15
+ }
16
+
17
+ def initialize(args)
18
+ parse_args(args)
19
+ @command = @arguments.shift
20
+ end
21
+
22
+ def perform
23
+ return if @exit
24
+
25
+ case @command
26
+ when "prep"
27
+ prep
28
+ when "add_partitions"
29
+ add_partitions
30
+ when "fill"
31
+ fill
32
+ when "swap"
33
+ swap
34
+ when "unswap"
35
+ unswap
36
+ when "unprep"
37
+ unprep
38
+ when nil
39
+ log "Commands: add_partitions, fill, prep, swap, unprep, unswap"
40
+ else
41
+ abort "Unknown command: #{@command}"
42
+ end
43
+ ensure
44
+ @connection.close if @connection
45
+ end
46
+
47
+ protected
48
+
49
+ # commands
50
+
51
+ def prep
52
+ table, column, period = arguments
53
+ intermediate_table = "#{table}_intermediate"
54
+ trigger_name = self.trigger_name(table)
55
+
56
+ abort "Usage: pgslice prep <table> <column> <period>" if arguments.length != 3
57
+ abort "Table not found: #{table}" unless table_exists?(table)
58
+ abort "Table already exists: #{intermediate_table}" if table_exists?(intermediate_table)
59
+ abort "Column not found: #{column}" unless columns(table).include?(column)
60
+ abort "Invalid period: #{period}" unless SQL_FORMAT[period.to_sym]
61
+
62
+ log "Creating #{intermediate_table} from #{table}"
63
+
64
+ queries = []
65
+
66
+ queries << <<-SQL
67
+ CREATE TABLE #{intermediate_table} (
68
+ LIKE #{table} INCLUDING INDEXES INCLUDING DEFAULTS
69
+ )
70
+ SQL
71
+
72
+ sql_format = SQL_FORMAT[period.to_sym]
73
+ queries << <<-SQL
74
+ CREATE FUNCTION #{trigger_name}()
75
+ RETURNS trigger AS $$
76
+ BEGIN
77
+ EXECUTE 'INSERT INTO public.#{table}_' || to_char(NEW.#{column}, '#{sql_format}') || ' VALUES ($1.*)' USING NEW;
78
+ RETURN NULL;
79
+ END;
80
+ $$ LANGUAGE plpgsql
81
+ SQL
82
+
83
+ queries << <<-SQL
84
+ CREATE TRIGGER #{trigger_name}
85
+ BEFORE INSERT ON #{intermediate_table}
86
+ FOR EACH ROW EXECUTE PROCEDURE #{trigger_name}()
87
+ SQL
88
+
89
+ run_queries(queries)
90
+ end
91
+
92
+ def unprep
93
+ table = arguments.first
94
+ intermediate_table = "#{table}_intermediate"
95
+ trigger_name = self.trigger_name(table)
96
+
97
+ abort "Usage: pgslice unprep <table>" if arguments.length != 1
98
+ abort "Table not found: #{intermediate_table}" unless table_exists?(intermediate_table)
99
+
100
+ log "Dropping #{intermediate_table}"
101
+
102
+ queries = [
103
+ "DROP TABLE #{intermediate_table} CASCADE",
104
+ "DROP FUNCTION #{trigger_name}()"
105
+ ]
106
+ run_queries(queries)
107
+ end
108
+
109
+ def add_partitions
110
+ original_table = arguments.first
111
+ table = options[:intermediate] ? "#{original_table}_intermediate" : original_table
112
+ trigger_name = self.trigger_name(original_table)
113
+
114
+ abort "Usage: pgslice add_partitions <table>" if arguments.length != 1
115
+ abort "Table not found: #{table}" unless table_exists?(table)
116
+
117
+ future = options[:future]
118
+ past = options[:past]
119
+ range = (-1 * past)..future
120
+
121
+ # ensure table has trigger
122
+ abort "No trigger on table: #{table}\nDid you mean to use --intermediate?" unless has_trigger?(trigger_name, table)
123
+
124
+ period, field, name_format, inc, today = settings_from_table(original_table, table)
125
+
126
+ days = range.map { |n| today + (n * inc) }
127
+ queries = []
128
+
129
+ days.each do |day|
130
+ partition_name = "#{original_table}_#{day.strftime(name_format)}"
131
+ next if table_exists?(partition_name)
132
+
133
+ log "Creating #{partition_name} from #{table}"
134
+ date_format = "%Y-%m-%d"
135
+
136
+ queries << <<-SQL
137
+ CREATE TABLE #{partition_name} (
138
+ LIKE #{table} INCLUDING INDEXES INCLUDING DEFAULTS,
139
+ CHECK (#{field} >= '#{day.strftime(date_format)}'::date AND #{field} < '#{(day + inc).strftime(date_format)}'::date)
140
+ ) INHERITS (#{table})
141
+ SQL
142
+ end
143
+
144
+ run_queries(queries) if queries.any?
145
+ end
146
+
147
+ def fill
148
+ table = arguments.first
149
+
150
+ abort "Usage: pgslice fill <table>" if arguments.length != 1
151
+
152
+ if options[:swapped]
153
+ source_table = retired_name(table)
154
+ dest_table = table
155
+ else
156
+ source_table = table
157
+ dest_table = intermediate_name(table)
158
+ end
159
+
160
+ abort "Table not found: #{source_table}" unless table_exists?(source_table)
161
+ abort "Table not found: #{dest_table}" unless table_exists?(dest_table)
162
+
163
+ period, field, name_format, inc, today = settings_from_table(table, dest_table)
164
+
165
+ date_format = "%Y-%m-%d"
166
+ existing_tables = self.existing_tables(like: "#{table}_%").select { |t| /#{Regexp.escape("#{table}_")}(\d{4,6})/.match(t) }
167
+ starting_time = DateTime.strptime(existing_tables.first.last(8), name_format)
168
+ ending_time = DateTime.strptime(existing_tables.last.last(8), name_format) + inc
169
+
170
+ primary_key = self.primary_key(table)
171
+ max_source_id = max_id(source_table, primary_key)
172
+ max_dest_id = max_id(dest_table, primary_key)
173
+
174
+ starting_id = max_dest_id + 1
175
+ fields = columns(source_table).join(", ")
176
+ batch_size = options[:batch_size]
177
+
178
+ if starting_id < max_source_id
179
+ log "#{primary_key}: #{starting_id} -> #{max_source_id}"
180
+ log "time: #{starting_time.to_date} -> #{ending_time.to_date}"
181
+
182
+ while starting_id < max_source_id
183
+ log "#{starting_id}..#{[starting_id + batch_size - 1, max_source_id].min}"
184
+
185
+ query = "INSERT INTO #{dest_table} (#{fields}) SELECT #{fields} FROM #{source_table} WHERE #{primary_key} >= #{starting_id} AND #{primary_key} < #{starting_id + batch_size} AND #{field} >= '#{starting_time.strftime(date_format)}'::date AND #{field} < '#{ending_time.strftime(date_format)}'::date"
186
+ log query if options[:debug]
187
+ execute(query)
188
+
189
+ starting_id += batch_size
190
+
191
+ if options[:sleep] && starting_id < max_source_id
192
+ sleep(options[:sleep])
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ def swap
199
+ table = arguments.first
200
+ intermediate_table = intermediate_name(table)
201
+ retired_table = retired_name(table)
202
+
203
+ abort "Usage: pgslice swap <table>" if arguments.length != 1
204
+ abort "Table not found: #{table}" unless table_exists?(table)
205
+ abort "Table not found: #{intermediate_table}" unless table_exists?(intermediate_table)
206
+ abort "Table already exists: #{retired_table}" if table_exists?(retired_table)
207
+
208
+ log "Renaming #{table} to #{retired_table}"
209
+ log "Renaming #{intermediate_table} to #{table}"
210
+
211
+ queries = [
212
+ "ALTER TABLE #{table} RENAME TO #{retired_table}",
213
+ "ALTER TABLE #{intermediate_table} RENAME TO #{table}"
214
+ ]
215
+ run_queries(queries)
216
+ end
217
+
218
+ def unswap
219
+ table = arguments.first
220
+ intermediate_table = intermediate_name(table)
221
+ retired_table = retired_name(table)
222
+
223
+ abort "Usage: pgslice unswap <table>" if arguments.length != 1
224
+ abort "Table not found: #{table}" unless table_exists?(table)
225
+ abort "Table not found: #{retired_table}" unless table_exists?(retired_table)
226
+ abort "Table already exists: #{intermediate_table}" if table_exists?(intermediate_table)
227
+
228
+ log "Renaming #{table} to #{intermediate_table}"
229
+ log "Renaming #{retired_table} to #{table}"
230
+
231
+ queries = [
232
+ "ALTER TABLE #{table} RENAME TO #{intermediate_table}",
233
+ "ALTER TABLE #{retired_table} RENAME TO #{table}"
234
+ ]
235
+ run_queries(queries)
236
+ end
237
+
238
+ # arguments
239
+
240
+ def parse_args(args)
241
+ opts = Slop.parse(args) do |o|
242
+ o.boolean "--intermediate"
243
+ o.boolean "--swapped"
244
+ o.boolean "--debug"
245
+ o.float "--sleep"
246
+ o.integer "--future", default: 3
247
+ o.integer "--past", default: 3
248
+ o.integer "--batch-size", default: 10000
249
+ o.on "-v", "--version", "print the version" do
250
+ log PgSlice::VERSION
251
+ @exit = true
252
+ end
253
+ end
254
+ @arguments = opts.arguments
255
+ @options = opts.to_hash
256
+ rescue Slop::Error => e
257
+ abort e.message
258
+ end
259
+
260
+ # output
261
+
262
+ def log(message)
263
+ $stderr.puts message
264
+ end
265
+
266
+ def abort(message)
267
+ raise PgSlice::Error, message
268
+ end
269
+
270
+ # database connection
271
+
272
+ def connection
273
+ @connection ||= begin
274
+ abort "Set PGSLICE_URL" unless ENV["PGSLICE_URL"]
275
+ uri = URI.parse(ENV["PGSLICE_URL"])
276
+ uri_parser = URI::Parser.new
277
+ config = {
278
+ host: uri.host,
279
+ port: uri.port,
280
+ dbname: uri.path.sub(/\A\//, ""),
281
+ user: uri.user,
282
+ password: uri.password,
283
+ connect_timeout: 1
284
+ }.reject { |_, value| value.to_s.empty? }
285
+ config.map { |key, value| config[key] = uri_parser.unescape(value) if value.is_a?(String) }
286
+ PG::Connection.new(config)
287
+ end
288
+ end
289
+
290
+ def execute(query, params = [])
291
+ connection.exec_params(query, params).to_a
292
+ end
293
+
294
+ def run_queries(queries)
295
+ connection.transaction do
296
+ execute("SET client_min_messages TO warning")
297
+ queries.map(&:squish).each do |query|
298
+ execute(query)
299
+ end
300
+ end
301
+ end
302
+
303
+ def existing_tables(like:)
304
+ query = "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = $1 AND tablename LIKE $2"
305
+ execute(query, ["public", like]).map { |r| r["tablename"] }.sort
306
+ end
307
+
308
+ def table_exists?(table)
309
+ existing_tables(like: table).any?
310
+ end
311
+
312
+ def columns(table)
313
+ execute("SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1", [table]).map{ |r| r["column_name"] }
314
+ end
315
+
316
+ # http://stackoverflow.com/a/20537829
317
+ def primary_key(table)
318
+ query = <<-SQL
319
+ SELECT
320
+ pg_attribute.attname,
321
+ format_type(pg_attribute.atttypid, pg_attribute.atttypmod)
322
+ FROM
323
+ pg_index, pg_class, pg_attribute, pg_namespace
324
+ WHERE
325
+ pg_class.oid = $2::regclass AND
326
+ indrelid = pg_class.oid AND
327
+ nspname = $1 AND
328
+ pg_class.relnamespace = pg_namespace.oid AND
329
+ pg_attribute.attrelid = pg_class.oid AND
330
+ pg_attribute.attnum = any(pg_index.indkey) AND
331
+ indisprimary
332
+ SQL
333
+ row = execute(query, ["public", table])[0]
334
+ row && row["attname"]
335
+ end
336
+
337
+ def max_id(table, primary_key)
338
+ execute("SELECT MAX(#{primary_key}) FROM #{table}")[0]["max"].to_i
339
+ end
340
+
341
+ def has_trigger?(trigger_name, table)
342
+ execute("SELECT 1 FROM pg_trigger WHERE tgname = $1 AND tgrelid = $2::regclass", [trigger_name, "public.#{table}"]).any?
343
+ end
344
+
345
+ # helpers
346
+
347
+ def trigger_name(table)
348
+ "#{table}_insert_trigger"
349
+ end
350
+
351
+ def intermediate_name(table)
352
+ "#{table}_intermediate"
353
+ end
354
+
355
+ def retired_name(table)
356
+ "#{table}_retired"
357
+ end
358
+
359
+ def settings_from_table(original_table, table)
360
+ trigger_name = self.trigger_name(original_table)
361
+ function_def = execute("select pg_get_functiondef(oid) from pg_proc where proname = $1", [trigger_name])[0]["pg_get_functiondef"]
362
+ sql_format = SQL_FORMAT.find { |_, f| function_def.include?("'#{f}'") }
363
+ abort "Could not read settings" unless sql_format
364
+ period = sql_format[0]
365
+ field = /to_char\(NEW\.(\w+),/.match(function_def)[1]
366
+
367
+ today = Time.now
368
+ case period
369
+ when :day
370
+ name_format = "%Y%m%d"
371
+ inc = 1.day
372
+ today = today.beginning_of_day
373
+ else
374
+ name_format = "%Y%m"
375
+ inc = 1.month
376
+ today = today.beginning_of_month
377
+ end
378
+
379
+ [period, field, name_format, inc, today]
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,3 @@
1
+ module PgSlice
2
+ VERSION = "0.1.0"
3
+ end
data/pgslice.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "pgslice/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "pgslice"
8
+ spec.version = PgSlice::VERSION
9
+ spec.authors = ["Andrew Kane"]
10
+ spec.email = ["andrew@chartkick.com"]
11
+
12
+ spec.summary = "Postgres partitioning as easy as pie"
13
+ spec.homepage = "https://github.com/ankane/pgslice"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
16
+ spec.bindir = "exe"
17
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
18
+ spec.require_paths = ["lib"]
19
+
20
+ spec.add_dependency "slop", ">= 4.2.0"
21
+ spec.add_dependency "pg"
22
+ spec.add_dependency "activesupport"
23
+
24
+ spec.add_development_dependency "bundler"
25
+ spec.add_development_dependency "rake"
26
+ end
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pgslice
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Kane
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-04-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: slop
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 4.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 4.2.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: pg
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activesupport
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: bundler
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description:
84
+ email:
85
+ - andrew@chartkick.com
86
+ executables:
87
+ - pgslice
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - ".gitignore"
92
+ - Gemfile
93
+ - README.md
94
+ - Rakefile
95
+ - exe/pgslice
96
+ - lib/pgslice.rb
97
+ - lib/pgslice/version.rb
98
+ - pgslice.gemspec
99
+ homepage: https://github.com/ankane/pgslice
100
+ licenses: []
101
+ metadata: {}
102
+ post_install_message:
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubyforge_project:
118
+ rubygems_version: 2.6.1
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: Postgres partitioning as easy as pie
122
+ test_files: []
123
+ has_rdoc: