tablature 0.1.1 → 1.0.0.pre2

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.
@@ -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
@@ -0,0 +1,96 @@
1
+ module Tablature
2
+ module Adapters
3
+ class Postgres
4
+ # Fetches indexes on objects from the Postgres connection.
5
+ #
6
+ # @api private
7
+ class Indexes
8
+ def initialize(connection)
9
+ @connection = connection
10
+ end
11
+
12
+ def on(name)
13
+ indexes_on(name).map(&method(:index_from_database))
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :connection
19
+
20
+ def indexes_on(name)
21
+ connection.exec_query(<<-SQL, 'SCHEMA').to_a
22
+ SELECT DISTINCT
23
+ i.relname AS index_name,
24
+ d.indisunique AS is_unique,
25
+ d.indkey AS index_keys,
26
+ pg_get_indexdef(d.indexrelid) AS definition,
27
+ t.oid AS oid,
28
+ pg_catalog.obj_description(i.oid, 'pg_class') AS comment,
29
+ t.relname AS table_name,
30
+ string_agg(a.attname, ',') OVER (PARTITION BY i.relname) AS column_names
31
+ FROM pg_class t
32
+ INNER JOIN pg_index d ON t.oid = d.indrelid
33
+ INNER JOIN pg_class i ON d.indexrelid = i.oid
34
+ LEFT JOIN pg_namespace n ON n.oid = i.relnamespace
35
+ LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY (d.indkey)
36
+ WHERE i.relkind = 'I'
37
+ AND d.indisprimary = 'f'
38
+ AND t.relname = '#{name}'
39
+ AND n.nspname = ANY (current_schemas(false))
40
+ ORDER BY i.relname
41
+ SQL
42
+ end
43
+
44
+ def index_from_database(result)
45
+ result = format_result(result)
46
+
47
+ if rails_version >= Gem::Version.new('5.2')
48
+ ActiveRecord::ConnectionAdapters::IndexDefinition.new(
49
+ result['table_name'], result['index_name'], result['is_unique'], result['columns'],
50
+ lengths: {}, orders: result['orders'], opclasses: result['opclasses'],
51
+ where: result['where'], using: result['using'].to_sym,
52
+ comment: result['comment'].presence
53
+ )
54
+ elsif rails_version >= Gem::Version.new('5.0')
55
+ ActiveRecord::ConnectionAdapters::IndexDefinition.new(
56
+ result['table_name'], result['index_name'], result['is_unique'], result['columns'],
57
+ {}, result['orders'], result['where'], nil, result['using'].to_sym,
58
+ result['comment'].presence
59
+ )
60
+ end
61
+ end
62
+
63
+ INDEX_PATTERN = /(?<column>\w+)"?\s?(?<opclass>\w+_ops)?\s?(?<desc>DESC)?\s?(?<nulls>NULLS (?:FIRST|LAST))?/.freeze
64
+ private_constant :INDEX_PATTERN
65
+
66
+ USING_PATTERN = / USING (\w+?) \((.+?)\)(?: WHERE (.+))?\z/m.freeze
67
+ private_constant :USING_PATTERN
68
+
69
+ def format_result(result)
70
+ result['index_keys'] = result['index_keys'].split.map(&:to_i)
71
+ result['column_names'] = result['column_names'].split(',')
72
+ result['using'], expressions, result['where'] = result['definition'].scan(USING_PATTERN).flatten
73
+ result['columns'] = result['index_keys'].include?(0) ? expressions : result['column_names']
74
+
75
+ result['orders'] = {}
76
+ result['opclasses'] = {}
77
+
78
+ expressions.scan(INDEX_PATTERN).each do |column, opclass, desc, nulls|
79
+ result['opclasses'][column] = opclass.to_sym if opclass
80
+ if nulls
81
+ result['orders'][column] = [desc, nulls].compact.join(' ')
82
+ elsif desc
83
+ result['orders'][column] = :desc
84
+ end
85
+ end
86
+
87
+ result
88
+ end
89
+
90
+ def rails_version
91
+ @rails_version ||= Gem::Version.new(Rails.version)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -19,13 +19,17 @@ module Tablature
19
19
 
20
20
  attr_reader :connection
21
21
 
22
+ # rubocop:disable Metrics/MethodLength
22
23
  def partitions
23
- connection.execute(<<-SQL)
24
+ result = connection.exec_query(<<-SQL, 'SCHEMA')
24
25
  SELECT
25
26
  c.oid,
27
+ i.inhrelid,
26
28
  c.relname AS table_name,
27
- p.partstrat AS type,
28
- (i.inhrelid::REGCLASS)::TEXT AS partition_name
29
+ p.partstrat AS strategy,
30
+ (i.inhrelid::REGCLASS)::TEXT AS partition_name,
31
+ #{connection.supports_default_partitions? ? 'i.inhrelid = p.partdefid AS is_default_partition,' : ''}
32
+ pg_get_partkeydef(c.oid) AS partition_key_definition
29
33
  FROM pg_class c
30
34
  INNER JOIN pg_partitioned_table p ON c.oid = p.partrelid
31
35
  LEFT JOIN pg_namespace n ON n.oid = c.relnamespace
@@ -36,22 +40,30 @@ module Tablature
36
40
  AND n.nspname = ANY (current_schemas(false))
37
41
  ORDER BY c.oid
38
42
  SQL
43
+
44
+ result.to_a
39
45
  end
46
+ # rubocop:enable Metrics/MethodLength
40
47
 
41
- METHOD_MAP = {
48
+ STRATEGY_MAP = {
42
49
  'l' => :list,
43
50
  'r' => :range,
44
51
  'h' => :hash
45
52
  }.freeze
46
- private_constant :METHOD_MAP
53
+ private_constant :STRATEGY_MAP
47
54
 
48
55
  def to_tablature_table(table_name, rows)
49
56
  result = rows.first
50
- partioning_method = METHOD_MAP.fetch(result['type'])
51
- partitions = rows.map { |row| row['partition_name'] }.compact.map(&method(:unquote))
57
+ partitioning_strategy = STRATEGY_MAP.fetch(result['strategy'])
58
+ # This is very fragile code. This makes the assumption that:
59
+ # - Postgres will always have a function `pg_get_partkeydef` that returns the partition
60
+ # strategy with the partition key
61
+ # - Postgres will never have a partition strategy with two words in its name.
62
+ _, partition_key = result['partition_key_definition'].split(' ', 2)
52
63
 
53
64
  Tablature::PartitionedTable.new(
54
- name: table_name, partioning_method: partioning_method, partitions: partitions
65
+ name: table_name, partitioning_strategy: partitioning_strategy,
66
+ partitions: rows, partition_key: partition_key
55
67
  )
56
68
  end
57
69
 
@@ -4,6 +4,11 @@ module Tablature
4
4
  # @api private
5
5
  module Quoting
6
6
  def quote_partition_key(key)
7
+ return key.call.to_s if key.respond_to?(:call)
8
+ # Don't bother quoting the key if it is already quoted (when loading the schema for
9
+ # example).
10
+ return key if key.to_s.include?("'") || key.to_s.include?('"')
11
+
7
12
  key.to_s.split('::').map(&method(:quote_column_name)).join('::')
8
13
  end
9
14
 
@@ -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
@@ -1,4 +1,74 @@
1
+ require 'forwardable'
2
+
1
3
  module Tablature
2
4
  module Model
5
+ module ListPartitionMethods
6
+ def create_list_partition(options)
7
+ Tablature.database.create_list_partition_of(tablature_partition.name, options)
8
+ end
9
+ end
10
+
11
+ module RangePartitionMethods
12
+ def create_range_partition(options)
13
+ Tablature.database.create_range_partition_of(tablature_partition.name, options)
14
+ end
15
+ end
16
+
17
+ module ClassMethods
18
+ extend Forwardable
19
+
20
+ def_delegators :tablature_partition, :partitions, :partition_key, :partitioning_strategy
21
+
22
+ def partitioned?
23
+ begin
24
+ tablature_partition
25
+ rescue Tablature::MissingPartition
26
+ return false
27
+ end
28
+
29
+ true
30
+ end
31
+
32
+ def tablature_partition
33
+ return @tablature_partition if defined?(@tablature_partition)
34
+
35
+ @tablature_partition = Tablature.database.partitioned_tables.find do |pt|
36
+ pt.name == partition_name.to_s
37
+ end
38
+ raise Tablature::MissingPartition if @tablature_partition.nil?
39
+
40
+ @tablature_partition
41
+ end
42
+
43
+ def list_partition(partition_name = table_name)
44
+ setup_partition(partition_name)
45
+ extend(ListPartitionMethods)
46
+ end
47
+
48
+ def range_partition(partition_name = table_name)
49
+ setup_partition(partition_name)
50
+ extend(RangePartitionMethods)
51
+ end
52
+
53
+ # @api private
54
+ def inspect
55
+ return super unless partitioned?
56
+
57
+ # Copied from the Rails source.
58
+ attr_list = attribute_types.map { |name, type| "#{name}: #{type.type}" } * ', '
59
+ "#{self}(#{attr_list})"
60
+ end
61
+
62
+ private
63
+
64
+ def setup_partition(partition_name)
65
+ self.partition_name = partition_name
66
+ end
67
+ end
68
+
69
+ def self.included(klass)
70
+ klass.extend ClassMethods
71
+ klass.class_attribute(:partition_name)
72
+ end
3
73
  end
4
74
  end