tablature 0.3.1 → 1.0.0.pre

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d2ff14d9d94a6ab38483d133cfc3679a1647888683ca767e8201f5ca1db5357
4
- data.tar.gz: dd65acaba0dae0cc4d659e3d9505c02483dac1bc5ae04d701741db3fad74f2ad
3
+ metadata.gz: 8db9f3d3c82e1667d5931739f53bdc0c218f01281c56e3e1cd2541b51264ae4f
4
+ data.tar.gz: 45fa5efd4a15199e22d5d15b8d0f84114de3ed634121171bb561ff206c5bcc28
5
5
  SHA512:
6
- metadata.gz: 25d6f5d66c1b72e7887eeae052476a2bdd161c59023c882edbc2cfe52673d279448102092810119990f9057ff6844c8f887921e7d5093903c5ebb2cabe333447
7
- data.tar.gz: 65aa6da3dbcd404efb155665f4ce88481b3dbe6858170b6259292d09563880718656ea22eff17a885c7d74120337a8d05d38927b1c68f36d09f00d7d0389af8a
6
+ metadata.gz: d30f66c7398da2249edc10d3b4a5d2239aa017647f78c266fb07708abc943ee9d79f950d57e93a508c1d4211a408de17d7f97c96b092e9237a8962abdcfba58d
7
+ data.tar.gz: db95e1b650a5337be05a61986ae99ed4adb829d9dd2d98642b34907bdd71416da8dbfe1f8ec66bdc9caa2ec0e0b178b433ec39afa625928afe6d1708c4912558
@@ -0,0 +1,68 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: master
6
+ pull_request:
7
+ branches: "*"
8
+
9
+ jobs:
10
+ build:
11
+ name: Ruby ${{ matrix.ruby }}, Rails ${{ matrix.rails }}, Postgres ${{ matrix.postgres }}
12
+
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ ruby: ["2.5", "2.7"]
17
+ rails: ["5.2", "6.0", "master"]
18
+ postgres: ["10.12", "11.7", "12.2"]
19
+ include:
20
+ - postgres: "10.12"
21
+ rspec_tag: --tag ~postgres_11
22
+ - rails: "master"
23
+ continue-on-error: true
24
+
25
+ runs-on: ubuntu-latest
26
+
27
+ services:
28
+ postgres:
29
+ image: postgres:${{ matrix.postgres }}-alpine
30
+ env:
31
+ POSTGRES_PASSWORD: postgres
32
+ ports:
33
+ - 5432:5432
34
+ options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
35
+
36
+ env:
37
+ RAILS_VERSION: ${{ matrix.rails }}
38
+ POSTGRES_USER: "postgres"
39
+ POSTGRES_PASSWORD: "postgres"
40
+ CI: "true"
41
+
42
+ steps:
43
+ - name: Checkout
44
+ uses: actions/checkout@v2
45
+
46
+ - name: Install dependent libraries
47
+ run: sudo apt-get install libpq-dev
48
+
49
+ - name: Install Ruby ${{ matrix.ruby }}
50
+ uses: ruby/setup-ruby@v1.31.0
51
+ with:
52
+ ruby-version: ${{ matrix.ruby }}
53
+
54
+ - name: Generate lockfile
55
+ run: bundle lock
56
+
57
+ - name: Cache dependencies
58
+ uses: actions/cache@v1
59
+ with:
60
+ path: vendor/bundle
61
+ key: bundle-${{ hashFiles('Gemfile.lock') }}
62
+
63
+ - name: Set up Tablature
64
+ run: bin/setup
65
+
66
+ - name: Run tests
67
+ run: bundle exec rspec ${{ matrix.rspec_tag }}
68
+ continue-on-error: ${{ matrix.continue-on-error }}
data/.gitignore CHANGED
@@ -9,3 +9,5 @@
9
9
 
10
10
  # rspec failure tracking
11
11
  .rspec_status
12
+
13
+ Gemfile.lock
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.3
2
+ TargetRubyVersion: 2.5
3
3
 
4
4
  Metrics/BlockLength:
5
5
  Exclude:
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ -m markdown
data/Gemfile CHANGED
@@ -1,3 +1,15 @@
1
+ source "https://rubygems.org"
1
2
  gemspec
2
3
 
3
4
  gem 'pry'
5
+ gem 'rubocop'
6
+
7
+ rails_version = ENV.fetch("RAILS_VERSION", "6.0")
8
+
9
+ if rails_version == "master"
10
+ rails_constraint = { github: "rails/rails" }
11
+ else
12
+ rails_constraint = "~> #{rails_version}.0"
13
+ end
14
+
15
+ gem "rails", rails_constraint
data/README.md CHANGED
@@ -7,7 +7,7 @@ It ships with Postgres support and can easily supports other databases through a
7
7
 
8
8
  ##### Requirements
9
9
 
10
- Tablature requires Rails 5+ and Postgres 10+.
10
+ Tablature requires Ruby 2.5+, Rails 5+ and Postgres 10+.
11
11
 
12
12
  ##### Installation
13
13
 
@@ -67,7 +67,6 @@ end
67
67
 
68
68
  ### Having a partition back a model
69
69
 
70
-
71
70
  In your migration:
72
71
  ```ruby
73
72
  # db/migrate/create_events.rb
@@ -93,7 +92,7 @@ end
93
92
 
94
93
  In your model, calling one of `range_partition` or `list_partition` to inject
95
94
  methods:
96
- ```
95
+ ```ruby
97
96
  # app/models/event.rb
98
97
  class Event < ApplicationRecord
99
98
  range_partition
@@ -118,6 +117,49 @@ You can also create new partitions directly from the model :
118
117
  # => ["events_y2018m12", "events_y2019m01", "events_y2019m02"]
119
118
  ```
120
119
 
120
+ ### Partitioning an existing table
121
+ Start by renaming your table and create the partition table:
122
+ ```ruby
123
+ class PartitionEvents < ActiveRecord::Migration
124
+ def change
125
+ # Get the bounds of the events.
126
+ min_month = Event.minimum(:timestamp).beginning_of_month.to_date
127
+ max_month = Event.maximum(:timestamp).beginning_of_month.to_date
128
+
129
+ # Create the partition bounds based on the existing data. In this example,
130
+ # we generate an array with the ranges.
131
+ months = min_month.upto(max_month).uniq(&:beginning_of_month)
132
+
133
+ # Rename the existing table.
134
+ rename_table :events, :old_events
135
+
136
+ # Create the partitioned table.
137
+ create_range_partition :events, partition_key: -> { '(timestamp::DATE)' } do |t|
138
+ t.string :event_type, null: false
139
+ t.integer :value, null: false
140
+ t.datetime :timestamp, null: false
141
+ t.timestamps
142
+ end
143
+
144
+ # Create the partitions based on the bounds generated before:
145
+ months.each do |month|
146
+ # Creates a name like "events_y2018m12"
147
+ partition_name = "events_y#{month.year}m#{month.month}"
148
+
149
+ create_range_partition_of :events,
150
+ name: partition_name, range_start: month, range_end: month.next_month
151
+ end
152
+
153
+ # Finally, add the rows from the old table to the new partitioned table.
154
+ # This might take some time depending on the size of your old table.
155
+ execute(<<~SQL)
156
+ INSERT INTO events
157
+ SELECT * FROM old_events
158
+ SQL
159
+ end
160
+ end
161
+ ```
162
+
121
163
  ## Development
122
164
 
123
165
  After checking out the repo, run `bin/setup` to install dependencies.
@@ -31,6 +31,13 @@ module Tablature
31
31
  postgresql_version >= 110_000
32
32
  end
33
33
 
34
+ # True if the connection supports default partitions.
35
+ #
36
+ # @return [Boolean]
37
+ def supports_default_partitions?
38
+ postgresql_version >= 110_000
39
+ end
40
+
34
41
  # An integer representing the version of Postgres we're connected to.
35
42
  #
36
43
  # +postgresql_version+ is public in Rails 5, but protected in earlier
@@ -1,6 +1,23 @@
1
1
  module Tablature
2
2
  module Adapters
3
3
  class Postgres
4
+ # Raised when a setting a partition as default on a database
5
+ # version that does not support default partitions.
6
+ #
7
+ # Default partitions are supported on Postgres 11 or newer.
8
+ class DefaultPartitionNotSupportedError < StandardError
9
+ def initialize
10
+ super('Default partitions require Postgres 11 or newer')
11
+ end
12
+ end
13
+
14
+ # Raised when trying to attach or detach a partition without supplying a name.
15
+ class MissingPartitionName < StandardError
16
+ def initialize
17
+ super('Missing partition name')
18
+ end
19
+ end
20
+
4
21
  # Raised when a list partition operation is attempted on a database
5
22
  # version that does not support list partitions.
6
23
  #
@@ -15,7 +32,7 @@ module Tablature
15
32
  # key.
16
33
  class MissingListPartitionValuesError < StandardError
17
34
  def initialize
18
- super('Missing values for of list partition')
35
+ super('Missing values for list partition')
19
36
  end
20
37
  end
21
38
 
@@ -33,7 +50,7 @@ module Tablature
33
50
  # key.
34
51
  class MissingRangePartitionBoundsError < StandardError
35
52
  def initialize
36
- super('Missing bounds for of range partition')
53
+ super('Missing bounds for range partition')
37
54
  end
38
55
  end
39
56
  end
@@ -13,6 +13,10 @@ module Tablature
13
13
 
14
14
  protected
15
15
 
16
+ def raise_unless_default_partition_supported
17
+ raise DefaultPartitionNotSupportedError unless connection.supports_default_partitions?
18
+ end
19
+
16
20
  def create_partition(table_name, id_options, table_options, &block)
17
21
  create_table(table_name, table_options) do |td|
18
22
  # TODO: Handle the id things here (depending on the postgres version)
@@ -29,15 +29,50 @@ module Tablature
29
29
 
30
30
  def create_list_partition_of(parent_table, options)
31
31
  values = options.fetch(:values, [])
32
- raise MissingListPartitionValuesError if values.blank?
32
+ as_default = options.fetch(:default, false)
33
+
34
+ raise_unless_default_partition_supported if as_default
35
+ raise MissingListPartitionValuesError if values.blank? && !as_default
33
36
 
34
37
  name = options.fetch(:name, partition_name(parent_table, values))
35
38
  # TODO: Call `create_table` here instead of running the query.
36
39
  # TODO: Pass the options to `create_table` to allow further configuration of the table,
37
40
  # e.g. sub-partitioning the table.
38
- query = <<~SQL.strip
41
+
42
+ query = <<~SQL
39
43
  CREATE TABLE #{quote_table_name(name)} PARTITION OF #{quote_table_name(parent_table)}
40
- FOR VALUES IN (#{quote_collection(values)})
44
+ SQL
45
+
46
+ query += if as_default
47
+ 'DEFAULT'
48
+ else
49
+ "FOR VALUES IN (#{quote_collection(values)})"
50
+ end
51
+
52
+ execute(query)
53
+ end
54
+
55
+ def attach_to_list_partition(parent_table, options)
56
+ values = options.fetch(:values, [])
57
+ as_default = options.fetch(:default, false)
58
+
59
+ raise_unless_default_partition_supported if as_default
60
+ raise MissingListPartitionValuesError if values.blank? && !as_default
61
+
62
+ name = options.fetch(:name) { raise MissingPartitionName }
63
+
64
+ if as_default
65
+ attach_default_partition(parent_table, name)
66
+ else
67
+ attach_partition(parent_table, name, values)
68
+ end
69
+ end
70
+
71
+ def detach_from_list_partition(parent_table, options)
72
+ name = options.fetch(:name) { raise MissingPartitionName }
73
+ query = <<~SQL.strip
74
+ ALTER TABLE #{quote_table_name(parent_table)}
75
+ DETACH PARTITION #{quote_table_name(name)}
41
76
  SQL
42
77
 
43
78
  execute(query)
@@ -59,6 +94,25 @@ module Tablature
59
94
  key = values.inspect
60
95
  "#{parent_table}_#{Digest::MD5.hexdigest(key)[0..6]}"
61
96
  end
97
+
98
+ def attach_default_partition(parent_table, partition_name)
99
+ query = <<~SQL.strip
100
+ ALTER TABLE #{quote_table_name(parent_table)}
101
+ ATTACH PARTITION #{quote_table_name(partition_name)} DEFAULT
102
+ SQL
103
+
104
+ execute(query)
105
+ end
106
+
107
+ def attach_partition(parent_table, partition_name, values)
108
+ query = <<~SQL.strip
109
+ ALTER TABLE #{quote_table_name(parent_table)}
110
+ ATTACH PARTITION #{quote_table_name(partition_name)}
111
+ FOR VALUES IN (#{quote_collection(values)})
112
+ SQL
113
+
114
+ execute(query)
115
+ end
62
116
  end
63
117
  end
64
118
  end
@@ -31,16 +31,56 @@ module Tablature
31
31
  def create_range_partition_of(parent_table, options)
32
32
  range_start = options.fetch(:range_start, nil)
33
33
  range_end = options.fetch(:range_end, nil)
34
+ as_default = options.fetch(:default, false)
34
35
 
35
- raise MissingRangePartitionBoundsError if range_start.nil? || range_end.nil?
36
+ raise_unless_default_partition_supported if as_default
37
+ if (range_start.nil? || range_end.nil?) && !as_default
38
+ raise MissingRangePartitionBoundsError
39
+ end
36
40
 
37
41
  name = options.fetch(:name, partition_name(parent_table, range_start, range_end))
38
42
  # TODO: Call `create_table` here instead of running the query.
39
43
  # TODO: Pass the options to `create_table` to allow further configuration of the table,
40
44
  # e.g. sub-partitioning the table.
41
- query = <<~SQL.strip
45
+
46
+ query = <<~SQL
42
47
  CREATE TABLE #{quote_table_name(name)} PARTITION OF #{quote_table_name(parent_table)}
43
- FOR VALUES FROM (#{quote(range_start)}) TO (#{quote(range_end)});
48
+ SQL
49
+
50
+ query += if as_default
51
+ 'DEFAULT'
52
+ else
53
+ "FOR VALUES FROM (#{quote(range_start)}) TO (#{quote(range_end)})"
54
+ end
55
+
56
+ execute(query)
57
+ end
58
+
59
+ def attach_to_range_partition(parent_table, options)
60
+ range_start = options.fetch(:range_start, nil)
61
+ range_end = options.fetch(:range_end, nil)
62
+ as_default = options.fetch(:default, false)
63
+
64
+ raise_unless_default_partition_supported if as_default
65
+ if (range_start.nil? || range_end.nil?) && !as_default
66
+ raise MissingRangePartitionBoundsError
67
+ end
68
+
69
+ name = options.fetch(:name) { raise MissingPartitionName }
70
+
71
+ if as_default
72
+ attach_default_partition(parent_table, name)
73
+ else
74
+ attach_partition(parent_table, name, range_start, range_end)
75
+ end
76
+ end
77
+
78
+ def detach_from_range_partition(parent_table, options)
79
+ name = options.fetch(:name) { raise MissingPartitionName }
80
+
81
+ query = <<~SQL.strip
82
+ ALTER TABLE #{quote_table_name(parent_table)}
83
+ DETACH PARTITION #{quote_table_name(name)}
44
84
  SQL
45
85
 
46
86
  execute(query)
@@ -61,6 +101,25 @@ module Tablature
61
101
  key = [range_start, range_end].join(', ')
62
102
  "#{parent_table}_#{Digest::MD5.hexdigest(key)[0..6]}"
63
103
  end
104
+
105
+ def attach_default_partition(parent_table, partition_name)
106
+ query = <<~SQL.strip
107
+ ALTER TABLE #{quote_table_name(parent_table)}
108
+ ATTACH PARTITION #{quote_table_name(partition_name)} DEFAULT
109
+ SQL
110
+
111
+ execute(query)
112
+ end
113
+
114
+ def attach_partition(parent_table, partition_name, range_start, range_end)
115
+ query = <<~SQL.strip
116
+ ALTER TABLE #{quote_table_name(parent_table)}
117
+ ATTACH PARTITION #{quote_table_name(partition_name)}
118
+ FOR VALUES FROM (#{quote(range_start)}) TO (#{quote(range_end)})
119
+ SQL
120
+
121
+ execute(query)
122
+ end
64
123
  end
65
124
  end
66
125
  end
@@ -23,9 +23,11 @@ module Tablature
23
23
  connection.execute(<<-SQL)
24
24
  SELECT
25
25
  c.oid,
26
+ i.inhrelid,
26
27
  c.relname AS table_name,
27
- p.partstrat AS type,
28
+ p.partstrat AS strategy,
28
29
  (i.inhrelid::REGCLASS)::TEXT AS partition_name,
30
+ #{connection.supports_default_partitions? ? 'i.inhrelid = p.partdefid AS is_default_partition,' : ''}
29
31
  pg_get_partkeydef(c.oid) AS partition_key_definition
30
32
  FROM pg_class c
31
33
  INNER JOIN pg_partitioned_table p ON c.oid = p.partrelid
@@ -39,26 +41,25 @@ module Tablature
39
41
  SQL
40
42
  end
41
43
 
42
- METHOD_MAP = {
44
+ STRATEGY_MAP = {
43
45
  'l' => :list,
44
46
  'r' => :range,
45
47
  'h' => :hash
46
48
  }.freeze
47
- private_constant :METHOD_MAP
49
+ private_constant :STRATEGY_MAP
48
50
 
49
51
  def to_tablature_table(table_name, rows)
50
52
  result = rows.first
51
- partitioning_method = METHOD_MAP.fetch(result['type'])
52
- partitions = rows.map { |row| row['partition_name'] }.compact.map(&method(:unquote))
53
+ partitioning_strategy = STRATEGY_MAP.fetch(result['strategy'])
53
54
  # This is very fragile code. This makes the assumption that:
54
55
  # - Postgres will always have a function `pg_get_partkeydef` that returns the partition
55
- # method with the partition key
56
- # - Postgres will never have a partition method with two words in its name.
56
+ # strategy with the partition key
57
+ # - Postgres will never have a partition strategy with two words in its name.
57
58
  _, partition_key = result['partition_key_definition'].split(' ', 2)
58
59
 
59
60
  Tablature::PartitionedTable.new(
60
- name: table_name, partitioning_method: partitioning_method,
61
- partitions: partitions, partition_key: partition_key
61
+ name: table_name, partitioning_strategy: partitioning_strategy,
62
+ partitions: rows, partition_key: partition_key
62
63
  )
63
64
  end
64
65
 
@@ -68,14 +68,44 @@ module Tablature
68
68
  # @option options [String, Symbol] :values The values appearing in the partition.
69
69
  # @option options [String, Symbol] :name The name of the partition. If it is not given, this
70
70
  # will be randomly generated.
71
+ # @option options [Boolean] :default Whether the partition is the default partition or not.
71
72
  #
72
73
  # @example
73
74
  # # With a table :events partitioned using the list method on the partition key `date`:
74
75
  # create_list_partition_of :events, name: "events_2018-W49", values: [
75
- # "2018-12-03", "2018-12-04", "2018-12-05", "2018-12-06", "2018-12-07", "2018-12-08", "2018-12-09"
76
+ # "2018-12-03", "2018-12-04", "2018-12-05", "2018-12-06", "2018-12-07", "2018-12-08",
77
+ # "2018-12-09"
76
78
  # ]
77
79
  delegate :create_list_partition_of, to: :list_handler
78
80
 
81
+ # @!method attach_to_list_partition(parent_table_name, options)
82
+ # Attaches a partition to a parent by specifying the key values appearing in the partition.
83
+ #
84
+ # @param parent_table_name [String, Symbol] The name of the parent table.
85
+ # @param [Hash] options The options to attach the partition.
86
+ # @option options [String, Symbol] :name The name of the partition.
87
+ # @option options [String, Symbol] :values The values appearing in the partition.
88
+ #
89
+ # @example
90
+ # # With a table :events partitioned using the list method on the partition key `date`:
91
+ # attach_to_list_partition :events, name: "events_2018-W49", values: [
92
+ # "2018-12-03", "2018-12-04", "2018-12-05", "2018-12-06", "2018-12-07", "2018-12-08",
93
+ # "2018-12-09"
94
+ # ]
95
+ delegate :attach_to_list_partition, to: :list_handler
96
+
97
+ # @!method detach_from_list_partition(parent_table_name, options)
98
+ # Detaches a partition from a parent.
99
+ #
100
+ # @param parent_table_name [String, Symbol] The name of the parent table.
101
+ # @param [Hash] options The options to create the partition.
102
+ # @option options [String, Symbol] :name The name of the partition.
103
+ #
104
+ # @example
105
+ # # With a table :events partitioned using the list method on the partition key `date`:
106
+ # detach_from_list_partition :events, name: "events_2018-W49"
107
+ delegate :detach_from_list_partition, to: :list_handler
108
+
79
109
  # @!method create_range_partition(table_name, options, &block)
80
110
  # Creates a partitioned table using the range partition method.
81
111
  #
@@ -102,12 +132,13 @@ module Tablature
102
132
  #
103
133
  # @param parent_table_name [String, Symbol] The name of the parent table.
104
134
  # @param [Hash] options The options to create the partition.
135
+ # @option options [String, Symbol] :name The name of the partition. If it is not given, this
136
+ # will be randomly generated.
105
137
  # @option options [String, Symbol] :range_start The start of the range of values appearing in
106
138
  # the partition.
107
139
  # @option options [String, Symbol] :range_end The end of the range of values appearing in
108
140
  # the partition.
109
- # @option options [String, Symbol] :name The name of the partition. If it is not given, this
110
- # will be randomly generated.
141
+ # @option options [Boolean] :default Whether the partition is the default partition or not.
111
142
  #
112
143
  # @example
113
144
  # # With a table :events partitioned using the range method on the partition key `date`:
@@ -115,6 +146,35 @@ module Tablature
115
146
  # range_end: '2018-12-10'
116
147
  delegate :create_range_partition_of, to: :range_handler
117
148
 
149
+ # @!method attach_to_range_partition(parent_table_name, options)
150
+ # Attaches a partition to a parent by specifying the key values appearing in the partition.
151
+ #
152
+ # @param parent_table_name [String, Symbol] The name of the parent table.
153
+ # @param [Hash] options The options to create the partition.
154
+ # @option options [String, Symbol] :name The name of the partition.
155
+ # @option options [String, Symbol] :range_start The start of the range of values appearing in
156
+ # the partition.
157
+ # @option options [String, Symbol] :range_end The end of the range of values appearing in
158
+ # the partition.
159
+ # @option options [Boolean] :default Whether the partition is the default partition or not.
160
+ #
161
+ # @example
162
+ # # With a table :events partitioned using the range method on the partition key `date`:
163
+ # attach_to_range_partition :events, name: "events_2018-W49", range_start: '2018-12-03',
164
+ # range_end: '2018-12-10'
165
+ delegate :attach_to_range_partition, to: :range_handler
166
+
167
+ # @!method detach_from_range_partition(parent_table_name, options)
168
+ # Detaches a partition from a parent.
169
+ #
170
+ # @param parent_table_name [String, Symbol] The name of the parent table.
171
+ # @param [Hash] options The options to detach the partition.
172
+ # @option options [String, Symbol] :name The name of the partition.
173
+ #
174
+ # @example
175
+ # detach_from_range_partition :events, name: "events_2018-W49"
176
+ delegate :detach_from_range_partition, to: :range_handler
177
+
118
178
  # Returns an array of partitioned tables in the database.
119
179
  #
120
180
  # This collection of tables is used by the [Tablature::SchemaDumper] to populate the schema.rb
@@ -1,4 +1,97 @@
1
1
  module Tablature
2
+ # @api private
2
3
  module CommandRecorder
4
+ def create_list_partition(*args)
5
+ record(:create_list_partition, args)
6
+ end
7
+
8
+ def create_list_partition_of(*args)
9
+ record(:create_list_partition_of, args)
10
+ end
11
+
12
+ def attach_to_list_partition(*args)
13
+ record(:attach_to_list_partition, args)
14
+ end
15
+
16
+ def detach_from_list_partition(*args)
17
+ record(:detach_from_list_partition, args)
18
+ end
19
+
20
+ def create_range_partition(*args)
21
+ record(:create_range_partition, args)
22
+ end
23
+
24
+ def create_range_partition_of(*args)
25
+ record(:create_range_partition_of, args)
26
+ end
27
+
28
+ def attach_to_range_partition(*args)
29
+ record(:attach_to_range_partition, args)
30
+ end
31
+
32
+ def detach_from_range_partition(*args)
33
+ record(:detach_from_range_partition, args)
34
+ end
35
+
36
+ def invert_create_partition(args)
37
+ [:drop_table, [args.first]]
38
+ end
39
+
40
+ alias :invert_create_list_partition :invert_create_partition
41
+ alias :invert_create_range_partition :invert_create_partition
42
+
43
+ def invert_create_partition_of(args)
44
+ _parent_table_name, options = args
45
+ partition_name = options[:name]
46
+
47
+ [:drop_table, [partition_name]]
48
+ end
49
+
50
+ alias :invert_create_list_partition_of :invert_create_partition_of
51
+ alias :invert_create_range_partition_of :invert_create_partition_of
52
+
53
+ def invert_attach_to_range_partition(args)
54
+ [:detach_from_range_partition, args]
55
+ end
56
+
57
+ def invert_detach_from_range_partition(args)
58
+ parent_table_name, options = args
59
+ options ||= {}
60
+ _partition_name = options[:name]
61
+
62
+ range_start = options[:range_start]
63
+ range_end = options[:range_end]
64
+ default = options[:default]
65
+
66
+ if (range_start.nil? || range_end.nil?) && default.blank?
67
+ message = <<-MESSAGE
68
+ invert_detach_from_range_partition is reversible only if given bounds or the default option
69
+ MESSAGE
70
+ raise ActiveRecord::IrreversibleMigration, message
71
+ end
72
+
73
+ [:attach_to_range_partition, [parent_table_name, options]]
74
+ end
75
+
76
+ def invert_attach_to_list_partition(args)
77
+ [:detach_from_list_partition, args]
78
+ end
79
+
80
+ def invert_detach_from_list_partition(args)
81
+ partitioned_table, options = args
82
+ options ||= {}
83
+
84
+ default = options[:default]
85
+ values = options[:values] || []
86
+
87
+ if values.blank? && default.blank?
88
+ message = <<-MESSAGE
89
+ invert_detach_from_list_partition is reversible only if given the value list or the default option
90
+ MESSAGE
91
+ raise ActiveRecord::IrreversibleMigration, message
92
+ end
93
+
94
+ [:attach_to_list_partition, [partitioned_table, options]]
95
+ end
3
96
  end
4
97
  end
@@ -15,7 +15,7 @@ module Tablature
15
15
  module ClassMethods
16
16
  extend Forwardable
17
17
 
18
- def_delegators :tablature_partition, :partitions, :partition_key, :partitioning_method
18
+ def_delegators :tablature_partition, :partitions, :partition_key, :partitioning_strategy
19
19
 
20
20
  def partitioned?
21
21
  begin
@@ -28,12 +28,14 @@ module Tablature
28
28
  end
29
29
 
30
30
  def tablature_partition
31
- partition = Tablature.database.partitioned_tables.find do |pt|
31
+ return @tablature_partition if defined?(@tablature_partition)
32
+
33
+ @tablature_partition = Tablature.database.partitioned_tables.find do |pt|
32
34
  pt.name == partition_name.to_s
33
35
  end
34
- raise Tablature::MissingPartition if partition.nil?
36
+ raise Tablature::MissingPartition if @tablature_partition.nil?
35
37
 
36
- partition
38
+ @tablature_partition
37
39
  end
38
40
 
39
41
  def list_partition(partition_name = table_name)
@@ -0,0 +1,23 @@
1
+ module Tablature
2
+ # The in-memory representation of a partition.
3
+ #
4
+ # **This object is used internally by adapters and the schema dumper and is
5
+ # not intended to be used by application code. It is documented here for
6
+ # use by adapter gems.**
7
+ #
8
+ # @api private
9
+ class Partition
10
+ attr_reader :name
11
+ attr_reader :parent_table_name
12
+
13
+ def initialize(name:, parent_table_name:, default_partition: false)
14
+ @name = name
15
+ @parent_table_name = parent_table_name
16
+ @default_partition = default_partition
17
+ end
18
+
19
+ def default_partition?
20
+ @default_partition
21
+ end
22
+ end
23
+ end
@@ -5,35 +5,54 @@ module Tablature
5
5
  # not intended to be used by application code. It is documented here for
6
6
  # use by adapter gems.**
7
7
  #
8
- # @api extension
8
+ # @api private
9
9
  class PartitionedTable
10
10
  # The name of the partitioned table
11
11
  # @return [String]
12
+ # @api private
12
13
  attr_reader :name
13
14
 
14
- # The partitioning method of the table
15
+ # The partitioning strategy of the table
15
16
  # @return [Symbol]
16
- attr_reader :partitioning_method
17
+ # @api private
18
+ attr_reader :partitioning_strategy
17
19
 
18
20
  # The partitions of the table.
19
21
  # @return [Array]
22
+ # @api private
20
23
  attr_reader :partitions
21
24
 
22
25
  # The partition key expression.
23
26
  # @return [String]
27
+ # @api private
24
28
  attr_reader :partition_key
25
29
 
26
30
  # Returns a new instance of PartitionTable.
27
31
  #
28
32
  # @param name [String] The name of the view.
29
- # @param partitioning_method [:symbol] One of :range, :list or :hash
33
+ # @param partitioning_strategy [:symbol] One of :range, :list or :hash
30
34
  # @param partitions [Array] The partitions of the table.
31
35
  # @param partition_key [String] The partition key expression.
32
- def initialize(name:, partitioning_method:, partitions: [], partition_key:)
36
+ # @api private
37
+ def initialize(name:, partitioning_strategy:, partitions: [], partition_key:)
33
38
  @name = name
34
- @partitioning_method = partitioning_method
35
- @partitions = partitions
39
+ @partitioning_strategy = partitioning_strategy
40
+ @partitions = partitions.map do |row|
41
+ Tablature::Partition.new(
42
+ name: row['partition_name'],
43
+ parent_table_name: row['table_name'],
44
+ default_partition: row['is_default_partition']
45
+ )
46
+ end
36
47
  @partition_key = partition_key
37
48
  end
49
+
50
+ # Returns the representation of the default partition if present.
51
+ #
52
+ # @return [Tablature::Partition]
53
+ # @api private
54
+ def default_partition
55
+ partitions.find(&:default_partition?)
56
+ end
38
57
  end
39
58
  end
@@ -16,7 +16,7 @@ module Tablature
16
16
  end
17
17
 
18
18
  def partitions
19
- dumpable_partitioned_tables.flat_map(&:partitions)
19
+ dumpable_partitioned_tables.flat_map { |t| t.partitions.map(&:name) }
20
20
  end
21
21
  end
22
22
  end
@@ -3,15 +3,15 @@ module Tablature
3
3
  module Statements
4
4
  # Creates a partitioned table using the list partition method.
5
5
  #
6
- # @param name [String, Symbol] The name of the partition.
6
+ # @param table_name [String, Symbol] The name of the partition.
7
7
  # @param options [Hash] The options to create the partition.
8
8
  # @yield [td] A TableDefinition object. This allows creating the table columns the same way
9
9
  # as Rails's +create_table+ does.
10
10
  # @see Tablature::Adapters::Postgres#create_list_partition
11
- def create_list_partition(name, options, &block)
11
+ def create_list_partition(table_name, options, &block)
12
12
  raise ArgumentError, 'partition_key must be defined' if options[:partition_key].nil?
13
13
 
14
- Tablature.database.create_list_partition(name, options, &block)
14
+ Tablature.database.create_list_partition(table_name, options, &block)
15
15
  end
16
16
 
17
17
  # Creates a partition of a parent by specifying the key values appearing in the partition.
@@ -21,23 +21,52 @@ module Tablature
21
21
  #
22
22
  # @see Tablature::Adapters::Postgres#create_list_partition_of
23
23
  def create_list_partition_of(parent_table_name, options)
24
- raise ArgumentError, 'values must be defined' if options[:values].nil?
24
+ if options[:values].blank? && options[:default].blank?
25
+ raise ArgumentError, 'values or default must be defined'
26
+ end
25
27
 
26
28
  Tablature.database.create_list_partition_of(parent_table_name, options)
27
29
  end
28
30
 
31
+ # Attaches a partition to a parent by specifying the key values appearing in the partition.
32
+ #
33
+ # @param parent_table_name [String, Symbol] The name of the parent table.
34
+ # @param [Hash] options The options to attach the partition.
35
+ #
36
+ # @see Tablature::Adapters::Postgres#attach_to_list_partition
37
+ def attach_to_list_partition(parent_table_name, options)
38
+ raise ArgumentError, 'name must be defined' if options[:name].blank?
39
+
40
+ Tablature.database.attach_to_list_partition(parent_table_name, options)
41
+ end
42
+
43
+ # Detaches a partition from a parent.
44
+ #
45
+ # @param parent_table_name [String, Symbol] The name of the parent table.
46
+ # @param [Hash] options The options to create the partition.
47
+ #
48
+ # @see Tablature::Adapters::Postgres#detach_from_list_partition
49
+ def detach_from_list_partition(parent_table_name, options)
50
+ raise ArgumentError, 'name must be defined' if options[:name].blank?
51
+ if options[:values].blank? && options[:default].blank?
52
+ raise ArgumentError, 'values or default must be defined'
53
+ end
54
+
55
+ Tablature.database.attach_to_list_partition(parent_table_name, options)
56
+ end
57
+
29
58
  # Creates a partitioned table using the range partition method.
30
59
  #
31
- # @param name [String, Symbol] The name of the partition.
60
+ # @param table_name [String, Symbol] The name of the partition.
32
61
  # @param options [Hash] The options to create the partition.
33
62
  # @yield [td] A TableDefinition object. This allows creating the table columns the same way
34
63
  # as Rails's +create_table+ does.
35
64
  #
36
65
  # @see Tablature::Adapters::Postgres#create_range_partition
37
- def create_range_partition(name, options, &block)
66
+ def create_range_partition(table_name, options, &block)
38
67
  raise ArgumentError, 'partition_key must be defined' if options[:partition_key].nil?
39
68
 
40
- Tablature.database.create_range_partition(name, options, &block)
69
+ Tablature.database.create_range_partition(table_name, options, &block)
41
70
  end
42
71
 
43
72
  # Creates a partition of a parent by specifying the key values appearing in the partition.
@@ -46,12 +75,39 @@ module Tablature
46
75
  # @param [Hash] options The options to create the partition.
47
76
  #
48
77
  # @see Tablature::Adapters::Postgres#create_range_partition_of
49
- def create_range_partition_of(parent_table, options)
50
- if options[:range_start].nil? || options[:range_end].nil?
51
- raise ArgumentError, 'range_start and range_end must be defined'
78
+ def create_range_partition_of(parent_table_name, options)
79
+ if (options[:range_start].nil? || options[:range_end].nil?) && options[:default].blank?
80
+ raise ArgumentError, 'range_start and range_end or default must be defined'
52
81
  end
53
82
 
54
- Tablature.database.create_range_partition_of(parent_table, options)
83
+ Tablature.database.create_range_partition_of(parent_table_name, options)
84
+ end
85
+
86
+ # Attaches a partition to a parent by specifying the key values appearing in the partition.
87
+ #
88
+ # @param parent_table_name [String, Symbol] The name of the parent table.
89
+ # @param [Hash] options The options to create the partition.
90
+ #
91
+ # @see Tablature::Adapters::Postgres#attach_to_range_partition
92
+ def attach_to_range_partition(parent_table_name, options)
93
+ raise ArgumentError, 'name must be defined' if options[:name].blank?
94
+ if (options[:range_start].nil? || options[:range_end].nil?) && options[:default].blank?
95
+ raise ArgumentError, 'range_start and range_end or default must be defined'
96
+ end
97
+
98
+ Tablature.database.attach_to_range_partition(parent_table_name, options)
99
+ end
100
+
101
+ # Detaches a partition from a parent.
102
+ #
103
+ # @param parent_table_name [String, Symbol] The name of the parent table.
104
+ # @param [Hash] options The options to detach the partition.
105
+ #
106
+ # @see Tablature::Adapters::Postgres#detach_from_range_partition
107
+ def detach_from_range_partition(parent_table_name, options)
108
+ raise ArgumentError, 'name must be defined' if options[:name].blank?
109
+
110
+ Tablature.database.detach_from_range_partition(parent_table_name, options)
55
111
  end
56
112
  end
57
113
  end
@@ -1,3 +1,3 @@
1
1
  module Tablature
2
- VERSION = '0.3.1'.freeze
2
+ VERSION = '1.0.0.pre'.freeze
3
3
  end
data/lib/tablature.rb CHANGED
@@ -2,6 +2,7 @@ require 'tablature/adapters/postgres'
2
2
  require 'tablature/command_recorder'
3
3
  require 'tablature/configuration'
4
4
  require 'tablature/model'
5
+ require 'tablature/partition'
5
6
  require 'tablature/partitioned_table'
6
7
  require 'tablature/railtie'
7
8
  require 'tablature/schema_dumper'
data/tablature.gemspec CHANGED
@@ -20,10 +20,12 @@ Gem::Specification.new do |spec|
20
20
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
21
  spec.require_paths = ['lib']
22
22
 
23
- spec.add_development_dependency 'bundler', '~> 1.16'
23
+ spec.required_ruby_version = '>= 2.5.0'
24
+
25
+ spec.add_development_dependency 'bundler'
24
26
  spec.add_development_dependency 'database_cleaner'
25
- spec.add_development_dependency 'pg', '~> 0.19'
26
- spec.add_development_dependency 'rake', '~> 10.0'
27
+ spec.add_development_dependency 'pg', '~> 1.1'
28
+ spec.add_development_dependency 'rake', '~> 13.0'
27
29
  spec.add_development_dependency 'rspec', '~> 3.0'
28
30
  spec.add_development_dependency 'rspec-instafail'
29
31
 
metadata CHANGED
@@ -1,29 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tablature
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 1.0.0.pre
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aliou Diallo
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2019-01-18 00:00:00.000000000 Z
11
+ date: 2020-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '1.16'
19
+ version: '0'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
- - - "~>"
24
+ - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '1.16'
26
+ version: '0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: database_cleaner
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -44,28 +44,28 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '0.19'
47
+ version: '1.1'
48
48
  type: :development
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '0.19'
54
+ version: '1.1'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: rake
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '10.0'
61
+ version: '13.0'
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '10.0'
68
+ version: '13.0'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: rspec
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -129,13 +129,13 @@ executables: []
129
129
  extensions: []
130
130
  extra_rdoc_files: []
131
131
  files:
132
- - ".circleci/config.yml"
132
+ - ".github/workflows/ci.yml"
133
133
  - ".gitignore"
134
134
  - ".rspec"
135
135
  - ".rubocop.yml"
136
136
  - ".travis.yml"
137
+ - ".yardopts"
137
138
  - Gemfile
138
- - Gemfile.lock
139
139
  - LICENSE.txt
140
140
  - README.md
141
141
  - Rakefile
@@ -152,6 +152,7 @@ files:
152
152
  - lib/tablature/command_recorder.rb
153
153
  - lib/tablature/configuration.rb
154
154
  - lib/tablature/model.rb
155
+ - lib/tablature/partition.rb
155
156
  - lib/tablature/partitioned_table.rb
156
157
  - lib/tablature/railtie.rb
157
158
  - lib/tablature/schema_dumper.rb
@@ -170,15 +171,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
170
171
  requirements:
171
172
  - - ">="
172
173
  - !ruby/object:Gem::Version
173
- version: '0'
174
+ version: 2.5.0
174
175
  required_rubygems_version: !ruby/object:Gem::Requirement
175
176
  requirements:
176
- - - ">="
177
+ - - ">"
177
178
  - !ruby/object:Gem::Version
178
- version: '0'
179
+ version: 1.3.1
179
180
  requirements: []
180
- rubyforge_project:
181
- rubygems_version: 2.7.6
181
+ rubygems_version: 3.0.3
182
182
  signing_key:
183
183
  specification_version: 4
184
184
  summary: Rails + Postgres Partitions
data/.circleci/config.yml DELETED
@@ -1,81 +0,0 @@
1
- # TODO: Simplify this
2
- version: 2
3
- jobs:
4
- postgres_10:
5
- working_directory: ~/tablature-rb
6
- docker:
7
- - image: circleci/ruby:2.4
8
- environment:
9
- DATABASE_URL: "postgres://postgres@localhost:5432/dummy_test"
10
- - image: circleci/postgres:10-alpine
11
- environment:
12
- POSTGRES_USER: postgres
13
- POSTGRES_DB: dummy_test
14
- POSTGRES_PASSWORD: ""
15
- steps:
16
- - checkout
17
-
18
- - type: cache-restore
19
- name: Restore bundle cache
20
- key: tablature-{{ checksum "tablature.gemspec" }}-{{ checksum "Gemfile" }}
21
-
22
- - run:
23
- name: Install dependencies
24
- command: bundle install --path vendor/bundle
25
-
26
- - type: cache-save
27
- name: Store bundle cache
28
- key: tablature-{{ checksum "tablature.gemspec" }}-{{ checksum "Gemfile" }}
29
- paths:
30
- - vendor/bundle
31
-
32
- - run:
33
- name: Wait for Postgres
34
- command: dockerize -wait tcp://localhost:5432 -timeout 1m
35
-
36
- - run:
37
- name: Run tests
38
- command: bundle exec rspec --tag ~postgres_11
39
-
40
- postgres_11:
41
- working_directory: ~/tablature-rb
42
- docker:
43
- - image: circleci/ruby:2.4
44
- environment:
45
- DATABASE_URL: "postgres://postgres@localhost:5432/dummy_test"
46
- - image: circleci/postgres:11-alpine
47
- environment:
48
- POSTGRES_USER: postgres
49
- POSTGRES_DB: dummy_test
50
- POSTGRES_PASSWORD: ""
51
- steps:
52
- - checkout
53
-
54
- - type: cache-restore
55
- name: Restore bundle cache
56
- key: tablature-{{ checksum "tablature.gemspec" }}-{{ checksum "Gemfile" }}
57
-
58
- - run:
59
- name: Install dependencies
60
- command: bundle install --path vendor/bundle
61
-
62
- - type: cache-save
63
- name: Store bundle cache
64
- key: tablature-{{ checksum "tablature.gemspec" }}-{{ checksum "Gemfile" }}
65
- paths:
66
- - vendor/bundle
67
-
68
- - run:
69
- name: Wait for Postgres
70
- command: dockerize -wait tcp://localhost:5432 -timeout 1m
71
-
72
- - run:
73
- name: Run tests
74
- command: bundle exec rspec
75
-
76
- workflows:
77
- version: 2
78
- tablature:
79
- jobs:
80
- - postgres_10
81
- - postgres_11
data/Gemfile.lock DELETED
@@ -1,106 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- tablature (0.3.1)
5
- activerecord (>= 5.0.0)
6
- railties (>= 5.0.0)
7
-
8
- GEM
9
- remote: https://rubygems.org/
10
- specs:
11
- actionpack (5.2.2)
12
- actionview (= 5.2.2)
13
- activesupport (= 5.2.2)
14
- rack (~> 2.0)
15
- rack-test (>= 0.6.3)
16
- rails-dom-testing (~> 2.0)
17
- rails-html-sanitizer (~> 1.0, >= 1.0.2)
18
- actionview (5.2.2)
19
- activesupport (= 5.2.2)
20
- builder (~> 3.1)
21
- erubi (~> 1.4)
22
- rails-dom-testing (~> 2.0)
23
- rails-html-sanitizer (~> 1.0, >= 1.0.3)
24
- activemodel (5.2.2)
25
- activesupport (= 5.2.2)
26
- activerecord (5.2.2)
27
- activemodel (= 5.2.2)
28
- activesupport (= 5.2.2)
29
- arel (>= 9.0)
30
- activesupport (5.2.2)
31
- concurrent-ruby (~> 1.0, >= 1.0.2)
32
- i18n (>= 0.7, < 2)
33
- minitest (~> 5.1)
34
- tzinfo (~> 1.1)
35
- arel (9.0.0)
36
- builder (3.2.3)
37
- coderay (1.1.2)
38
- concurrent-ruby (1.1.4)
39
- crass (1.0.4)
40
- database_cleaner (1.7.0)
41
- diff-lcs (1.3)
42
- erubi (1.8.0)
43
- i18n (1.5.1)
44
- concurrent-ruby (~> 1.0)
45
- loofah (2.2.3)
46
- crass (~> 1.0.2)
47
- nokogiri (>= 1.5.9)
48
- method_source (0.9.0)
49
- mini_portile2 (2.4.0)
50
- minitest (5.11.3)
51
- nokogiri (1.10.1)
52
- mini_portile2 (~> 2.4.0)
53
- pg (0.21.0)
54
- pry (0.12.2)
55
- coderay (~> 1.1.0)
56
- method_source (~> 0.9.0)
57
- rack (2.0.6)
58
- rack-test (1.1.0)
59
- rack (>= 1.0, < 3)
60
- rails-dom-testing (2.0.3)
61
- activesupport (>= 4.2.0)
62
- nokogiri (>= 1.6)
63
- rails-html-sanitizer (1.0.4)
64
- loofah (~> 2.2, >= 2.2.2)
65
- railties (5.2.2)
66
- actionpack (= 5.2.2)
67
- activesupport (= 5.2.2)
68
- method_source
69
- rake (>= 0.8.7)
70
- thor (>= 0.19.0, < 2.0)
71
- rake (10.5.0)
72
- rspec (3.7.0)
73
- rspec-core (~> 3.7.0)
74
- rspec-expectations (~> 3.7.0)
75
- rspec-mocks (~> 3.7.0)
76
- rspec-core (3.7.1)
77
- rspec-support (~> 3.7.0)
78
- rspec-expectations (3.7.0)
79
- diff-lcs (>= 1.2.0, < 2.0)
80
- rspec-support (~> 3.7.0)
81
- rspec-instafail (1.0.0)
82
- rspec
83
- rspec-mocks (3.7.0)
84
- diff-lcs (>= 1.2.0, < 2.0)
85
- rspec-support (~> 3.7.0)
86
- rspec-support (3.7.1)
87
- thor (0.20.3)
88
- thread_safe (0.3.6)
89
- tzinfo (1.2.5)
90
- thread_safe (~> 0.1)
91
-
92
- PLATFORMS
93
- ruby
94
-
95
- DEPENDENCIES
96
- bundler (~> 1.16)
97
- database_cleaner
98
- pg (~> 0.19)
99
- pry
100
- rake (~> 10.0)
101
- rspec (~> 3.0)
102
- rspec-instafail
103
- tablature!
104
-
105
- BUNDLED WITH
106
- 1.17.2