pgdice 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.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for configuration
4
+ module PgDice
5
+ class << self
6
+ attr_accessor :configuration
7
+
8
+ def configure
9
+ self.configuration ||= PgDice::Configuration.new
10
+ yield(configuration)
11
+ end
12
+ end
13
+
14
+ # Configuration class which holds all configurable values
15
+ class Configuration
16
+ def self.days_ago(days)
17
+ Time.now.utc - days * 24 * 60 * 60
18
+ end
19
+
20
+ VALUES = { logger: Logger.new(STDOUT),
21
+ database_url: nil,
22
+ additional_validators: [],
23
+ approved_tables: [],
24
+ older_than: PgDice::Configuration.days_ago(90),
25
+ dry_run: false,
26
+ table_drop_batch_size: 7 }.freeze
27
+
28
+ attr_writer :logger,
29
+ :database_url,
30
+ :additional_validators,
31
+ :approved_tables,
32
+ :older_than,
33
+ :dry_run,
34
+ :table_drop_batch_size,
35
+ :database_connection,
36
+ :pg_connection
37
+
38
+ attr_accessor :table_dropper,
39
+ :pg_slice_manager,
40
+ :partition_manager,
41
+ :partition_helper
42
+
43
+ def initialize(existing_configuration = nil)
44
+ VALUES.each do |key, value|
45
+ initialize_value(key, value, existing_configuration)
46
+ end
47
+ initialize_objects
48
+ end
49
+
50
+ def logger
51
+ return @logger unless @logger.nil?
52
+
53
+ raise PgDice::InvalidConfigurationError, 'logger must be present!'
54
+ end
55
+
56
+ def database_url
57
+ return @database_url unless @database_url.nil?
58
+
59
+ raise PgDice::InvalidConfigurationError, 'database_url must be present!'
60
+ end
61
+
62
+ def database_connection
63
+ return @database_connection unless @database_connection.nil?
64
+
65
+ raise PgDice::InvalidConfigurationError, 'database_connection must be present!'
66
+ end
67
+
68
+ def additional_validators
69
+ return @additional_validators if @additional_validators.is_a?(Array)
70
+
71
+ raise PgDice::InvalidConfigurationError, 'additional_validators must be an Array!'
72
+ end
73
+
74
+ def approved_tables
75
+ return @approved_tables if @approved_tables.is_a?(Array)
76
+
77
+ raise PgDice::InvalidConfigurationError, 'approved_tables must be an Array of strings!'
78
+ end
79
+
80
+ def older_than
81
+ return @older_than if @older_than.is_a?(Time)
82
+
83
+ raise PgDice::InvalidConfigurationError, 'older_than must be a Time!'
84
+ end
85
+
86
+ def dry_run
87
+ return @dry_run if [true, false].include?(@dry_run)
88
+
89
+ raise PgDice::InvalidConfigurationError, 'dry_run must be either true or false!'
90
+ end
91
+
92
+ def table_drop_batch_size
93
+ return @table_drop_batch_size.to_i if @table_drop_batch_size.to_i >= 0
94
+
95
+ raise PgDice::InvalidConfigurationError, 'table_drop_batch_size must be a non-negative Integer!'
96
+ end
97
+
98
+ # Lazily initialized
99
+ def pg_connection
100
+ @pg_connection ||= PG::Connection.new(database_url)
101
+ return @pg_connection if @pg_connection.respond_to?(:exec)
102
+
103
+ raise PgDice::InvalidConfigurationError, 'pg_connection must be present!'
104
+ end
105
+
106
+ def deep_clone
107
+ PgDice::Configuration.new(self)
108
+ end
109
+
110
+ private
111
+
112
+ def initialize_value(variable_name, default_value, existing_configuration)
113
+ instance_variable_set("@#{variable_name}", existing_configuration&.send(variable_name)&.clone || default_value)
114
+ end
115
+
116
+ def initialize_objects
117
+ @database_connection = PgDice::DatabaseConnection.new(self)
118
+ @partition_manager = PgDice::PartitionManager.new(self)
119
+ @table_dropper = PgDice::TableDropper.new(self)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for DatabaseConnection
4
+ module PgDice
5
+ # Wrapper class around database connection handlers
6
+ class DatabaseConnection
7
+ extend Forwardable
8
+ def_delegators :@configuration, :logger, :dry_run, :pg_connection
9
+
10
+ def initialize(configuration = PgDice::Configuration.new)
11
+ @configuration = configuration
12
+ end
13
+
14
+ def execute(query)
15
+ logger.debug { "DatabaseConnection to execute query: #{query}" }
16
+ if dry_run
17
+ PgDicePgResponse.new
18
+ else
19
+ pg_connection.exec(query)
20
+ end
21
+ end
22
+ end
23
+
24
+ # Null-object pattern for PG::Result since that object isn't straightforward to initialize
25
+ class PgDicePgResponse
26
+ def values
27
+ []
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for PartitionHelper
4
+ module PgDice
5
+ # Helps do high-level tasks like getting tables partitioned
6
+ class PartitionHelper
7
+ extend Forwardable
8
+ def_delegators :@configuration, :logger
9
+
10
+ attr_reader :pg_slice_manager, :validation_helper
11
+
12
+ def initialize(configuration = PgDice::Configuration.new)
13
+ @configuration = configuration
14
+ @pg_slice_manager = PgDice::PgSliceManager.new(configuration)
15
+ @validation_helper = PgDice::Validation.new(configuration)
16
+ end
17
+
18
+ def partition_table!(params = {})
19
+ params[:column_name] ||= 'created_at'
20
+ params[:period] ||= :day
21
+
22
+ validation_helper.validate_parameters(params)
23
+
24
+ logger.info { "Preparing database with params: #{params}" }
25
+
26
+ prep_and_fill(params)
27
+ swap_and_fill(params)
28
+ end
29
+
30
+ def undo_partitioning!(params = {})
31
+ table_name = params.fetch(:table_name)
32
+
33
+ validation_helper.validate_parameters(params)
34
+ logger.info { "Cleaning up database with params: #{table_name}" }
35
+
36
+ pg_slice_manager.analyze(table_name: table_name, swapped: true)
37
+ pg_slice_manager.unswap!(table_name: table_name)
38
+ pg_slice_manager.unprep!(table_name: table_name)
39
+ end
40
+
41
+ def partition_table(params = {})
42
+ partition_table!(params)
43
+ rescue PgDice::PgSliceError => error
44
+ logger.error { "Rescued PgSliceError: #{error}" }
45
+ false
46
+ end
47
+
48
+ def undo_partitioning(params = {})
49
+ undo_partitioning!(params)
50
+ rescue PgDice::PgSliceError => error
51
+ logger.error { "Rescued PgSliceError: #{error}" }
52
+ false
53
+ end
54
+
55
+ private
56
+
57
+ def prep_and_fill(params)
58
+ pg_slice_manager.prep(params)
59
+ pg_slice_manager.add_partitions(params.merge!(intermediate: true))
60
+ pg_slice_manager.fill(params) if params[:fill]
61
+ end
62
+
63
+ def swap_and_fill(params)
64
+ pg_slice_manager.analyze(params)
65
+ pg_slice_manager.swap(params)
66
+ pg_slice_manager.fill(params.merge!(swapped: true)) if params[:fill]
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for PartitionManager
4
+ module PgDice
5
+ # PartitionManager is a class used to fulfill high-level tasks for partitioning
6
+ class PartitionManager
7
+ extend Forwardable
8
+ def_delegators :@configuration, :logger, :older_than, :table_drop_batch_size
9
+
10
+ attr_reader :validation, :pg_slice_manager, :database_connection
11
+
12
+ def initialize(configuration = PgDice::Configuration.new)
13
+ @configuration = configuration
14
+ @validation = PgDice::Validation.new(configuration)
15
+ @pg_slice_manager = PgDice::PgSliceManager.new(configuration)
16
+ @database_connection = PgDice::DatabaseConnection.new(configuration)
17
+ end
18
+
19
+ def add_new_partitions(params = {})
20
+ logger.info { "add_new_partitions has been called with params: #{params}" }
21
+
22
+ validation.validate_parameters(params)
23
+ pg_slice_manager.add_partitions(params)
24
+ end
25
+
26
+ def drop_old_partitions(params = {})
27
+ logger.info { "drop_old_partitions has been called with params: #{params}" }
28
+
29
+ validation.validate_parameters(params)
30
+ old_partitions = list_old_partitions(params)
31
+ logger.warn { "Partitions to be deleted are: #{old_partitions}" }
32
+
33
+ old_partitions.each do |old_partition|
34
+ @configuration.table_dropper.call(old_partition, logger)
35
+ end
36
+ old_partitions
37
+ end
38
+
39
+ def list_old_partitions(params = {})
40
+ params[:older_than] ||= older_than
41
+ logger.info { "Listing old partitions with params: #{params}" }
42
+
43
+ validation.validate_parameters(params)
44
+
45
+ partition_tables = fetch_partition_tables(params)
46
+
47
+ filter_partitions(partition_tables, params[:table_name], params[:older_than])
48
+ end
49
+
50
+ # Grabs only tables that start with the base_table_name and end in numbers
51
+ def fetch_partition_tables(params = {})
52
+ schema = params[:schema] ||= 'public'
53
+ logger.info { "Fetching partition tables with params: #{params}" }
54
+
55
+ sql = build_partition_table_fetch_sql(params)
56
+
57
+ partition_tables = database_connection.execute(sql).values.flatten
58
+ logger.debug { "Table: #{schema}.#{params[:table_name]} has partition_tables: #{partition_tables}" }
59
+ partition_tables
60
+ end
61
+
62
+ private
63
+
64
+ def filter_partitions(partition_tables, base_table_name, partitions_older_than_time)
65
+ partition_tables.select do |partition_name|
66
+ partition_created_at_date = Date.parse(partition_name.gsub(/#{base_table_name}_/, '')).to_time
67
+ partition_created_at_date < partitions_older_than_time
68
+ end
69
+ end
70
+
71
+ def build_partition_table_fetch_sql(params = {})
72
+ schema = params.fetch(:schema)
73
+ base_table_name = params.fetch(:table_name)
74
+ limit = params.fetch(:limit, table_drop_batch_size)
75
+
76
+ <<~SQL
77
+ SELECT tablename
78
+ FROM pg_tables
79
+ WHERE schemaname = '#{schema}'
80
+ AND tablename ~ '^#{base_table_name}_\\d+$'
81
+ ORDER BY tablename
82
+ LIMIT #{limit}
83
+ SQL
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for PgSliceManager
4
+ module PgDice
5
+ # PgSliceManager is a wrapper around PgSlice
6
+ class PgSliceManager
7
+ extend Forwardable
8
+ def_delegators :@configuration, :logger, :database_url, :dry_run
9
+
10
+ def initialize(configuration = PgDice::Configuration.new)
11
+ @configuration = configuration
12
+ end
13
+
14
+ def prep(params = {})
15
+ table_name = params.fetch(:table_name)
16
+ column_name = params.fetch(:column_name)
17
+ period = params.fetch(:period)
18
+ run_pgslice("prep #{table_name} #{column_name} #{period}")
19
+ end
20
+
21
+ def fill(params = {})
22
+ table_name = params.fetch(:table_name)
23
+ swapped = params.fetch(:swapped, '')
24
+ swapped = '--swapped' if swapped.to_s.casecmp('true').zero?
25
+
26
+ run_pgslice("fill #{table_name} #{swapped}")
27
+ end
28
+
29
+ def analyze(params = {})
30
+ table_name = params.fetch(:table_name)
31
+ swapped = params.fetch(:swapped, '')
32
+ swapped = '--swapped' if swapped.to_s.casecmp('true').zero?
33
+
34
+ run_pgslice("analyze #{table_name} #{swapped}")
35
+ end
36
+
37
+ def swap(params = {})
38
+ table_name = params.fetch(:table_name)
39
+ run_pgslice("swap #{table_name}")
40
+ end
41
+
42
+ def add_partitions(params = {})
43
+ table_name = params.fetch(:table_name)
44
+ future_tables = params.fetch(:future, nil)
45
+ future_tables = "--future #{Integer(future_tables)}" if future_tables
46
+
47
+ past_tables = params.fetch(:past, nil)
48
+ past_tables = "--past #{Integer(past_tables)}" if past_tables
49
+
50
+ intermediate = params.fetch(:intermediate, nil)
51
+ intermediate = '--intermediate' if intermediate.to_s.casecmp('true').zero?
52
+
53
+ run_pgslice("add_partitions #{table_name} #{intermediate} #{future_tables} #{past_tables}")
54
+ end
55
+
56
+ def unprep!(params = {})
57
+ table_name = params.fetch(:table_name)
58
+
59
+ run_pgslice("unprep #{table_name}")
60
+ end
61
+
62
+ def unswap!(params = {})
63
+ table_name = params.fetch(:table_name)
64
+
65
+ run_pgslice("unswap #{table_name}")
66
+ end
67
+
68
+ def unprep(params = {})
69
+ table_name = params.fetch(:table_name)
70
+
71
+ run_pgslice("unprep #{table_name}")
72
+ rescue PgSliceError => error
73
+ logger.error { "Rescued PgSliceError: #{error}" }
74
+ false
75
+ end
76
+
77
+ def unswap(params = {})
78
+ table_name = params.fetch(:table_name)
79
+
80
+ run_pgslice("unswap #{table_name}")
81
+ rescue PgSliceError => error
82
+ logger.error { "Rescued PgSliceError: #{error}" }
83
+ false
84
+ end
85
+
86
+ private
87
+
88
+ def run_pgslice(argument_string)
89
+ parameters = build_pg_slice_command(argument_string)
90
+
91
+ stdout, stderr, status = Open3.capture3(parameters)
92
+ log_result(stdout, stderr, status)
93
+
94
+ if status.exitstatus.to_i.positive?
95
+ raise PgDice::PgSliceError,
96
+ "pgslice with arguments: '#{argument_string}' failed with status: '#{status.exitstatus}' "\
97
+ "STDOUT: '#{stdout}' STDERR: '#{stderr}'"
98
+ end
99
+ true
100
+ end
101
+
102
+ def build_pg_slice_command(argument_string)
103
+ argument_string = argument_string.strip
104
+ logger.info { "Running pgslice command: '#{argument_string}'" }
105
+ $stdout.flush
106
+ $stderr.flush
107
+ command = "pgslice #{argument_string} "
108
+ command += '--dry-run true ' if dry_run
109
+ command += "--url #{database_url}"
110
+ command
111
+ end
112
+
113
+ def log_result(stdout, stderr, status)
114
+ logger.debug { "pgslice STDERR: #{stderr}" } if stderr
115
+ logger.debug { "pgslice STDOUT: #{stdout}" } if stdout
116
+ logger.debug { "pgslice exit status: #{status.exitstatus}" } if status
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for TableDropperHelper
4
+ module PgDice
5
+ # Simple class used to provide a mechanism that users can hook into if they want to override this
6
+ # default behavior for dropping a table.
7
+ class TableDropper
8
+ def initialize(configuration = PgDice::Configuration.new)
9
+ @configuration = configuration
10
+ end
11
+
12
+ def call(table_to_drop, _logger)
13
+ @configuration.database_connection.execute(drop_partition(table_to_drop))
14
+ end
15
+
16
+ private
17
+
18
+ def drop_partition(table_name)
19
+ <<~SQL
20
+ BEGIN;
21
+ DROP TABLE IF EXISTS #{table_name} CASCADE;
22
+ COMMIT;
23
+ SQL
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ # Collection of utilities that provide ways for users to ensure things are working properly
5
+ class Validation
6
+ extend Forwardable
7
+ def_delegators :@configuration, :logger, :additional_validators, :database_connection, :approved_tables
8
+
9
+ def initialize(configuration = PgDice::Configuration.new)
10
+ @configuration = configuration
11
+ end
12
+
13
+ def assert_tables(params = {})
14
+ unless params[:future] || params[:past]
15
+ raise ArgumentError, 'You must provide either a future or past number of tables to assert on.'
16
+ end
17
+
18
+ table_name = validate_table_name(params)
19
+ period = resolve_period(params)
20
+
21
+ assert_future_tables(table_name, params[:future], period) if params[:future]
22
+ assert_past_tables(table_name, params[:past], period) if params[:past]
23
+ true
24
+ end
25
+
26
+ def validate_parameters(params)
27
+ validate_table_name(params)
28
+ validate_period(params)
29
+
30
+ run_additional_validators(params)
31
+ true
32
+ end
33
+
34
+ private
35
+
36
+ def resolve_period(params)
37
+ period = validate_period(params) || fetch_period_from_table_comment(params.fetch(:table_name))
38
+
39
+ # If the user doesn't supply a period and we fail to find one on the table then it's a pretty good bet
40
+ # this table is not partitioned at all.
41
+ unless period
42
+ raise TableNotPartitionedError,
43
+ "Table: #{params.fetch(:table_name)} is not partitioned! Cannot validate partitions that don't exist!"
44
+ end
45
+ period
46
+ end
47
+
48
+ def run_additional_validators(params)
49
+ return true if additional_validators.all? { |validator| validator.call(params, logger) }
50
+
51
+ raise PgDice::CustomValidationError.new(params, additional_validators)
52
+ rescue StandardError => error
53
+ raise PgDice::CustomValidationError.new(params, additional_validators, error)
54
+ end
55
+
56
+ def validate_table_name(params)
57
+ table_name = params.fetch(:table_name)
58
+ unless approved_tables.include?(table_name)
59
+ raise PgDice::IllegalTableError, "Table: #{table_name} is not in the list of approved tables!"
60
+ end
61
+
62
+ table_name
63
+ end
64
+
65
+ def validate_period(params)
66
+ return unless params[:period]
67
+
68
+ unless PgDice::SUPPORTED_PERIODS.include?(params[:period])
69
+ raise ArgumentError,
70
+ "Period must be one of: #{PgDice::SUPPORTED_PERIODS.keys}. Value: #{params[:period]} is not valid."
71
+ end
72
+
73
+ params[:period].to_sym
74
+ end
75
+
76
+ def assert_future_tables(table_name, future, period)
77
+ sql = build_assert_sql(table_name, future, period, :future)
78
+
79
+ response = database_connection.execute(sql)
80
+
81
+ return true if response.values.size == 1
82
+
83
+ raise PgDice::InsufficientFutureTablesError.new(table_name, future, period)
84
+ end
85
+
86
+ def assert_past_tables(table_name, past, period)
87
+ sql = build_assert_sql(table_name, past, period, :past)
88
+
89
+ response = database_connection.execute(sql)
90
+
91
+ return true if response.values.size == 1
92
+
93
+ raise PgDice::InsufficientPastTablesError.new(table_name, past, period)
94
+ end
95
+
96
+ def build_assert_sql(table_name, table_count, period, direction)
97
+ add_or_subtract = { future: '+', past: '-' }.fetch(direction, '+')
98
+ <<~SQL
99
+ SELECT 1
100
+ FROM pg_catalog.pg_class pg_class
101
+ INNER JOIN pg_catalog.pg_namespace pg_namespace ON pg_namespace.oid = pg_class.relnamespace
102
+ WHERE pg_class.relkind = 'r'
103
+ AND pg_namespace.nspname = 'public'
104
+ AND pg_class.relname = '#{table_name}_' || to_char(NOW()
105
+ #{add_or_subtract} INTERVAL '#{table_count} #{period}', '#{SUPPORTED_PERIODS[period.to_sym]}')
106
+ SQL
107
+ end
108
+
109
+ def fetch_period_from_table_comment(table_name)
110
+ sql = build_table_comment_sql(table_name, 'public')
111
+ values = database_connection.execute(sql).values.flatten.compact
112
+ convert_comment_to_hash(values.first)[:period]
113
+ end
114
+
115
+ def convert_comment_to_hash(comment)
116
+ return {} unless comment
117
+
118
+ partition_template = {}
119
+ comment.split(',').each do |key_value_pair|
120
+ key, value = key_value_pair.split(':')
121
+ partition_template[key.to_sym] = value
122
+ end
123
+ partition_template
124
+ end
125
+
126
+ def build_table_comment_sql(table_name, schema)
127
+ <<~SQL
128
+ SELECT obj_description('#{schema}.#{table_name}'::REGCLASS) AS comment
129
+ SQL
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ VERSION = '0.1.0'
5
+ end
data/lib/pgdice.rb ADDED
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pg'
4
+ require 'open3'
5
+ require 'logger'
6
+ require 'pgslice'
7
+ require 'pgdice/version'
8
+ require 'pgdice/validation'
9
+ require 'pgdice/table_dropper'
10
+ require 'pgdice/configuration'
11
+ require 'pgdice/pg_slice_manager'
12
+ require 'pgdice/partition_manager'
13
+ require 'pgdice/partition_helper'
14
+ require 'pgdice/database_connection'
15
+
16
+ # This is a stupid comment
17
+ module PgDice
18
+ class Error < StandardError
19
+ end
20
+ class PgSliceError < Error
21
+ end
22
+ class ValidationError < Error
23
+ end
24
+ class IllegalTableError < ValidationError
25
+ end
26
+ class TableNotPartitionedError < Error
27
+ end
28
+
29
+ # Rubocop is stupid
30
+ class InsufficientTablesError < Error
31
+ def initialize(direction, table_name, table_count, period)
32
+ super("Insufficient #{direction} tables exist for table: #{table_name}. "\
33
+ "Expected: #{table_count} having period of: #{period}")
34
+ end
35
+ end
36
+
37
+ # Rubocop is stupid
38
+ class InsufficientFutureTablesError < InsufficientTablesError
39
+ def initialize(table_name, table_count, period)
40
+ super('future', table_name, table_count, period)
41
+ end
42
+ end
43
+
44
+ # Rubocop is stupid
45
+ class InsufficientPastTablesError < InsufficientTablesError
46
+ def initialize(table_name, table_count, period)
47
+ super('past', table_name, table_count, period)
48
+ end
49
+ end
50
+
51
+ # Rubocop is stupid
52
+ class CustomValidationError < ValidationError
53
+ def initialize(params, validators, error = nil)
54
+ error_message = "Custom validation failed with params: #{params}. "
55
+ error_message += "Caused by error: #{error} " if error
56
+ error_message += "Validators: #{validators.map { |validator| source_location(validator) }.flatten}"
57
+ super(error_message)
58
+ end
59
+
60
+ private
61
+
62
+ # Helps users know what went wrong in their custom validators
63
+ def source_location(proc)
64
+ return proc.source_location if proc.respond_to?(:source_location)
65
+
66
+ proc.to_s
67
+ end
68
+ end
69
+
70
+ class ConfigurationError < Error
71
+ end
72
+
73
+ # Rubocop is stupid
74
+ class NotConfiguredError < ConfigurationError
75
+ def initialize(method_name)
76
+ super("Cannot use #{method_name} before PgDice has been configured! "\
77
+ 'See README.md for configuration help.')
78
+ end
79
+ end
80
+
81
+ # Rubocop is stupid
82
+ class InvalidConfigurationError < ConfigurationError
83
+ def initialize(message)
84
+ super("PgDice is not configured properly. #{message}")
85
+ end
86
+ end
87
+
88
+ SUPPORTED_PERIODS = { day: 'YYYYMMDD', month: 'YYYYMM', year: 'YYYY' }.freeze
89
+
90
+ class << self
91
+ def partition_manager
92
+ raise PgDice::NotConfiguredError, 'partition_manager' unless configuration
93
+
94
+ @partition_manager ||= PgDice::PartitionManager.new(configuration)
95
+ end
96
+
97
+ def partition_helper
98
+ raise PgDice::NotConfiguredError, 'partition_helper' unless configuration
99
+
100
+ @partition_helper ||= PgDice::PartitionHelper.new(configuration)
101
+ end
102
+
103
+ def validation
104
+ raise PgDice::NotConfiguredError, 'validation' unless configuration
105
+
106
+ @validation ||= PgDice::Validation.new(configuration)
107
+ end
108
+ end
109
+ end