pgslice 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: