pgdice 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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