tablature 0.1.1 → 1.0.0.pre2

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