torque-postgresql 4.0.0 → 4.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +24 -0
  3. data/lib/torque/postgresql/adapter/database_statements.rb +89 -7
  4. data/lib/torque/postgresql/adapter/inheritance_statements.rb +297 -0
  5. data/lib/torque/postgresql/adapter/oid/array.rb +17 -0
  6. data/lib/torque/postgresql/adapter/oid/box.rb +1 -1
  7. data/lib/torque/postgresql/adapter/oid/circle.rb +1 -1
  8. data/lib/torque/postgresql/adapter/oid/composite.rb +116 -0
  9. data/lib/torque/postgresql/adapter/oid/line.rb +1 -1
  10. data/lib/torque/postgresql/adapter/oid/lquery.rb +53 -0
  11. data/lib/torque/postgresql/adapter/oid/ltree.rb +53 -0
  12. data/lib/torque/postgresql/adapter/oid/segment.rb +1 -1
  13. data/lib/torque/postgresql/adapter/oid/struct.rb +150 -0
  14. data/lib/torque/postgresql/adapter/oid/struct_list.rb +52 -0
  15. data/lib/torque/postgresql/adapter/oid/struct_set.rb +38 -0
  16. data/lib/torque/postgresql/adapter/quoting.rb +11 -2
  17. data/lib/torque/postgresql/adapter/schema_definitions.rb +46 -0
  18. data/lib/torque/postgresql/adapter/schema_dumper.rb +28 -2
  19. data/lib/torque/postgresql/adapter/schema_statements.rb +207 -0
  20. data/lib/torque/postgresql/adapter.rb +2 -5
  21. data/lib/torque/postgresql/arel/nodes.rb +63 -1
  22. data/lib/torque/postgresql/arel/visitors.rb +19 -8
  23. data/lib/torque/postgresql/associations/join_dependency.rb +55 -0
  24. data/lib/torque/postgresql/associations.rb +3 -0
  25. data/lib/torque/postgresql/attributes/base.rb +94 -0
  26. data/lib/torque/postgresql/attributes/composite.rb +102 -0
  27. data/lib/torque/postgresql/attributes/enum.rb +13 -2
  28. data/lib/torque/postgresql/attributes/lquery.rb +180 -0
  29. data/lib/torque/postgresql/attributes/ltree.rb +169 -0
  30. data/lib/torque/postgresql/attributes/simple_enum.rb +72 -0
  31. data/lib/torque/postgresql/attributes/struct.rb +137 -0
  32. data/lib/torque/postgresql/base.rb +17 -14
  33. data/lib/torque/postgresql/config.rb +81 -14
  34. data/lib/torque/postgresql/inheritance/expander.rb +66 -0
  35. data/lib/torque/postgresql/inheritance/record.rb +52 -0
  36. data/lib/torque/postgresql/inheritance.rb +138 -65
  37. data/lib/torque/postgresql/migration/command_recorder.rb +76 -0
  38. data/lib/torque/postgresql/predicate_builder/composite_handler.rb +109 -0
  39. data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +44 -0
  40. data/lib/torque/postgresql/predicate_builder/struct_handler.rb +68 -0
  41. data/lib/torque/postgresql/predicate_builder.rb +26 -0
  42. data/lib/torque/postgresql/predicate_table.rb +61 -0
  43. data/lib/torque/postgresql/railtie.rb +35 -0
  44. data/lib/torque/postgresql/relation/inheritance.rb +146 -28
  45. data/lib/torque/postgresql/relation/join_series.rb +8 -6
  46. data/lib/torque/postgresql/relation/merger.rb +23 -7
  47. data/lib/torque/postgresql/relation.rb +12 -11
  48. data/lib/torque/postgresql/schema_cache.rb +1 -0
  49. data/lib/torque/postgresql/validations.rb +29 -0
  50. data/lib/torque/postgresql/version.rb +1 -1
  51. data/lib/torque/postgresql/versioned_commands/command_migration.rb +1 -1
  52. data/lib/torque/postgresql.rb +6 -0
  53. data/spec/initialize.rb +20 -2
  54. data/spec/mocks/cache_query.rb +12 -0
  55. data/spec/models/author.rb +1 -1
  56. data/spec/models/comment.rb +2 -0
  57. data/spec/models/place.rb +2 -0
  58. data/spec/models/profile.rb +31 -0
  59. data/spec/schema.rb +34 -4
  60. data/spec/tests/arel_spec.rb +5 -3
  61. data/spec/tests/composite_spec.rb +800 -0
  62. data/spec/tests/ltree_spec.rb +552 -0
  63. data/spec/tests/struct_spec.rb +968 -0
  64. data/spec/tests/table_inheritance_spec.rb +1027 -56
  65. metadata +49 -7
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module Adapter
6
+ module OID
7
+ class Lquery < ActiveModel::Type::Value
8
+
9
+ def type
10
+ :lquery
11
+ end
12
+
13
+ def cast(value)
14
+ return if value.blank?
15
+ return value if value.is_a?(LQuery)
16
+
17
+ LQuery.new(value)
18
+ end
19
+
20
+ def deserialize(value)
21
+ return if value.nil?
22
+
23
+ LQuery.load(value)
24
+ end
25
+
26
+ def serialize(value)
27
+ cast(value)&.to_s
28
+ end
29
+
30
+ def type_cast_for_schema(value)
31
+ cast(value).to_s.inspect
32
+ end
33
+
34
+ def changed_in_place?(raw_old_value, new_value)
35
+ raw_old_value != serialize(new_value)
36
+ end
37
+
38
+ # Types resolved through the type map are deduplicated and frozen, so
39
+ # they need to be comparable
40
+ def ==(other)
41
+ other.is_a?(self.class)
42
+ end
43
+ alias eql? ==
44
+
45
+ def hash
46
+ self.class.hash
47
+ end
48
+
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module Adapter
6
+ module OID
7
+ class Ltree < ActiveModel::Type::Value
8
+
9
+ def type
10
+ :ltree
11
+ end
12
+
13
+ def cast(value)
14
+ return if value.blank?
15
+ return value if value.is_a?(LTree)
16
+
17
+ LTree.new(value)
18
+ end
19
+
20
+ def deserialize(value)
21
+ return if value.nil?
22
+
23
+ LTree.load(value)
24
+ end
25
+
26
+ def serialize(value)
27
+ cast(value)&.to_s
28
+ end
29
+
30
+ def type_cast_for_schema(value)
31
+ cast(value).to_s.inspect
32
+ end
33
+
34
+ def changed_in_place?(raw_old_value, new_value)
35
+ raw_old_value != serialize(new_value)
36
+ end
37
+
38
+ # Types resolved through the type map are deduplicated and frozen, so
39
+ # they need to be comparable
40
+ def ==(other)
41
+ other.is_a?(self.class)
42
+ end
43
+ alias eql? ==
44
+
45
+ def hash
46
+ self.class.hash
47
+ end
48
+
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Torque
4
4
  module PostgreSQL
5
- class Segment < Struct.new(:point0, :point1)
5
+ class Segment < ::Struct.new(:point0, :point1)
6
6
  def x1=(value)
7
7
  self.point0 = new_point(value, y1)
8
8
  end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module Adapter
6
+ module OID
7
+ class Struct < ActiveRecord::Type::Json
8
+ attr_reader :klass, :type, :backfill
9
+
10
+ # A comparable representation of a struct-backed value, based on the
11
+ # values it exposes instead of the document it generates, so that
12
+ # non-deterministic types like encrypted attributes stay stable
13
+ def self.state(value)
14
+ case value
15
+ when nil then nil
16
+ when ::Array then value.map { |item| state(item) }
17
+ else value.respond_to?(:attributes) ? [value.class, value.attributes] : value
18
+ end
19
+ end
20
+
21
+ def initialize(klass, type: :jsonb, backfill: nil)
22
+ super()
23
+ @klass = klass
24
+ @type = type
25
+ @backfill = backfill
26
+ end
27
+
28
+ def cast(value)
29
+ case value
30
+ when klass then value
31
+ when ::Hash then build(value)
32
+ when ::String then cast(parse(value))
33
+ else value
34
+ end
35
+ end
36
+
37
+ def deserialize(value)
38
+ build(parse(value), from_database: true, blank: true)
39
+ end
40
+
41
+ def serialize(value)
42
+ case value
43
+ when klass then empty?(value) ? nil : super(document(value))
44
+ when nil then nil
45
+ else super
46
+ end
47
+ end
48
+
49
+ def changed?(old_value, new_value, _new_value_before_cast)
50
+ Struct.state(old_value) != Struct.state(new_value)
51
+ end
52
+
53
+ def changed_in_place?(raw_old_value, new_value)
54
+ Struct.state(without_backfill.deserialize(raw_old_value)) != Struct.state(new_value)
55
+ end
56
+
57
+ # The same type, but reading documents exactly as they are stored, so
58
+ # that backfilled properties are seen as changes
59
+ def without_backfill
60
+ return self if backfill.nil?
61
+ @without_backfill ||= self.class.new(klass, type: type)
62
+ end
63
+
64
+ def empty?(value)
65
+ document(value).empty?
66
+ end
67
+
68
+ # The value as it goes inside another document, which is a hash, and
69
+ # not the encoded JSON that a column receives
70
+ def document_for(value)
71
+ value.nil? ? nil : document(value)
72
+ end
73
+
74
+ # The hash that represents the value on the database, holding only the
75
+ # properties that were actually written to it. Properties backed by
76
+ # another struct are kept as documents, so that they are not stored as
77
+ # an encoded string inside this one
78
+ def document(value)
79
+ set = value.instance_variable_get(:@attributes)
80
+ set.keys.to_h do |name|
81
+ attribute = set[name]
82
+ type = attribute.type
83
+
84
+ next [name, type.document_for(attribute.value)] if type.is_a?(Struct)
85
+ [name, attribute.value_for_database]
86
+ end
87
+ end
88
+
89
+ def blank_document
90
+ {}
91
+ end
92
+
93
+ def ==(other)
94
+ other.class == self.class && other.klass == klass && other.type == type
95
+ end
96
+ alias eql? ==
97
+
98
+ def hash
99
+ [self.class, klass, type].hash
100
+ end
101
+
102
+ private
103
+
104
+ def parse(value)
105
+ return value unless value.is_a?(::String)
106
+ ActiveSupport::JSON.decode(value) rescue nil
107
+ end
108
+
109
+ def build(data, from_database: false, blank: false)
110
+ instance = klass.new
111
+ blank!(instance) if blank
112
+ return instance unless data.is_a?(::Hash)
113
+
114
+ from_database ? load(instance, data) : instance.assign_attributes(data)
115
+ instance
116
+ end
117
+
118
+ # Reset the instance to the state of an empty document, keeping the
119
+ # class-level defaults that were marked to be backfilled
120
+ def blank!(instance)
121
+ return if backfill == true
122
+
123
+ keep = ::Array.wrap(backfill).map(&:to_s)
124
+ set = instance.instance_variable_get(:@attributes).map do |attribute|
125
+ next attribute if keep.include?(attribute.name)
126
+ ActiveModel::Attribute.uninitialized(attribute.name, attribute.type)
127
+ end
128
+
129
+ instance.instance_variable_set(:@attributes, set)
130
+ end
131
+
132
+ # Properties that the class doesn't declare are kept as they are
133
+ # stored, regardless of the class being strict about them or not
134
+ def load(instance, data)
135
+ default = ActiveModel::Type.default_value
136
+ set = instance.instance_variable_get(:@attributes)
137
+
138
+ data.each do |key, value|
139
+ if klass.attribute_names.include?(key = key.to_s)
140
+ set.write_from_database(key, value)
141
+ else
142
+ set[key] = ActiveModel::Attribute.from_database(key, value, default)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module Adapter
6
+ module OID
7
+ class StructList < Struct
8
+ def cast(value)
9
+ case value
10
+ when ::String then cast(parse(value))
11
+ when ::Array then value.map { |item| super(item) }
12
+ else value
13
+ end
14
+ end
15
+
16
+ def deserialize(value)
17
+ ::Array.wrap(parse(value)).map do |item|
18
+ build(item, from_database: true, blank: true)
19
+ end
20
+ end
21
+
22
+ def serialize(value)
23
+ case value
24
+ when ::Array then value.empty? ? nil : encode(value)
25
+ when nil then nil
26
+ else super
27
+ end
28
+ end
29
+
30
+ def document_for(value)
31
+ ::Array.wrap(value).map { |item| item.is_a?(klass) ? document(item) : item }
32
+ end
33
+
34
+ def empty?(value)
35
+ value.is_a?(::Array) ? value.empty? : super
36
+ end
37
+
38
+ def blank_document
39
+ []
40
+ end
41
+
42
+ private
43
+
44
+ def encode(items)
45
+ hashes = items.map { |item| item.is_a?(klass) ? document(item) : item }
46
+ ActiveSupport::JSON.encode(hashes)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module Adapter
6
+ module OID
7
+ class StructSet < ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array
8
+ def deserialize(value)
9
+ value.nil? ? [] : super
10
+ end
11
+
12
+ def serialize(value)
13
+ return if value.is_a?(::Array) && value.empty?
14
+ super
15
+ end
16
+
17
+ def changed_in_place?(raw_old_value, new_value)
18
+ Struct.state(without_backfill.deserialize(raw_old_value)) !=
19
+ Struct.state(new_value)
20
+ end
21
+
22
+ def without_backfill
23
+ return self if subtype.backfill.nil?
24
+ @without_backfill ||= self.class.new(subtype.without_backfill, delimiter)
25
+ end
26
+
27
+ def empty?(value)
28
+ value.is_a?(::Array) ? value.empty? : subtype.empty?(value)
29
+ end
30
+
31
+ def blank_document
32
+ []
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -13,7 +13,7 @@ module Torque
13
13
 
14
14
  # Quotes type names for use in SQL queries.
15
15
  def quote_type_name(name, *args)
16
- QUOTED_TYPE_NAMES[args] ||= begin
16
+ QUOTED_TYPE_NAMES[[name.to_s, *args]] ||= begin
17
17
  name = name.to_s
18
18
  args << 'public' if args.empty? && !name.include?('.')
19
19
  quote_identifier_name(name, *args)
@@ -36,11 +36,20 @@ module Torque
36
36
  lookup_cast_type(column.sql_type)
37
37
  elsif column.is_a?(Column) && column.array?
38
38
  # When using +change_column_default+
39
- lookup_cast_type_from_column(column)
39
+ lookup_cast_type_for_column(column)
40
40
  end
41
41
 
42
42
  type.nil? ? super : quote(type.serialize(value.to_a))
43
43
  end
44
+
45
+ # Rails 8.1 dropped +lookup_cast_type_from_column+, and the sql_type of
46
+ # an array column does not carry the array part, so only the oid can
47
+ # resolve the real type
48
+ def lookup_cast_type_for_column(column)
49
+ return lookup_cast_type_from_column(column) unless AR810
50
+
51
+ type_map.lookup(column.oid, column.fmod, column.sql_type)
52
+ end
44
53
  end
45
54
  end
46
55
  end
@@ -18,6 +18,26 @@ module Torque
18
18
  names.each { |name| column(name, :virtual, **options) }
19
19
  end
20
20
 
21
+ # Add a composite type column to the table
22
+ def composite(*names, composite_type:, **options)
23
+ raise ArgumentError, "Missing column name(s) for composite" if names.empty?
24
+ names.each { |name| column(name, :composite, composite_type: composite_type, **options) }
25
+ end
26
+
27
+ # Add a label tree column to the table. Tree operators are only indexed
28
+ # by GiST, as in +index: { using: :gist }+, whose default operator class
29
+ # is already the right one for both a path and an array of paths
30
+ def ltree(*names, **options)
31
+ raise ArgumentError, "Missing column name(s) for ltree" if names.empty?
32
+ names.each { |name| column(name, :ltree, **options) }
33
+ end
34
+
35
+ # Add a label tree pattern column to the table
36
+ def lquery(*names, **options)
37
+ raise ArgumentError, "Missing column name(s) for lquery" if names.empty?
38
+ names.each { |name| column(name, :lquery, **options) }
39
+ end
40
+
21
41
  end
22
42
 
23
43
  module TableDefinition
@@ -49,6 +69,32 @@ module Torque
49
69
  end
50
70
  end
51
71
 
72
+ # Composite types are defined with the same DSL as tables, where declaring
73
+ # a column adds one, plus the operations that +change_table+ provides
74
+ class CompositeTypeDefinition < ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition
75
+ attr_reader :changes, :removals, :renames
76
+
77
+ def initialize(*args, **xargs)
78
+ super
79
+
80
+ @changes = []
81
+ @removals = []
82
+ @renames = []
83
+ end
84
+
85
+ def change(name, type, **options)
86
+ @changes << new_column_definition(name, type, **options)
87
+ end
88
+
89
+ def remove(*names)
90
+ @removals.concat(names.map(&:to_s))
91
+ end
92
+
93
+ def rename(name, new_name)
94
+ @renames << [name.to_s, new_name.to_s]
95
+ end
96
+ end
97
+
52
98
  # Add exclusive support for versioned commands when importing from schema
53
99
  # dump. This ensures that such methods are not available in regular
54
100
  # migrations.
@@ -34,10 +34,35 @@ module Torque
34
34
  def types(stream) # :nodoc:
35
35
  super
36
36
 
37
+ composite_types(stream)
38
+
37
39
  versioned_commands(stream, :type)
38
40
  versioned_commands(stream, :function)
39
41
  end
40
42
 
43
+ # Dump user defined composite types, sorted by their dependencies
44
+ def composite_types(stream)
45
+ return unless PostgreSQL.config.composite.enabled
46
+
47
+ list = @connection.composite_types
48
+ list -= @versioned_commands.versions_of('type').map(&:first) \
49
+ if with_versioned_commands?
50
+ return if list.empty?
51
+
52
+ stream.puts " # Custom composite types defined in this database."
53
+ list.each do |name|
54
+ stream.puts " create_composite_type #{name.inspect}, force: :cascade do |t|"
55
+ @connection.columns(name).each do |column|
56
+ type, colspec = column_spec(column)
57
+ stream.print " t.#{type} #{column.name.inspect}"
58
+ stream.print ", #{format_colspec(colspec)}" if colspec.present?
59
+ stream.puts
60
+ end
61
+ stream.puts " end"
62
+ stream.puts
63
+ end
64
+ end
65
+
41
66
  def tables(stream) # :nodoc:
42
67
  around_tables(stream) { dump_tables(stream) }
43
68
  end
@@ -126,13 +151,14 @@ module Torque
126
151
  def prepare_column_options(column)
127
152
  options = super
128
153
  parse_search_vector_options(column, options) if column.type == :tsvector
154
+ options[:composite_type] = column.sql_type.inspect if column.type == :composite
129
155
  options
130
156
  end
131
157
 
132
158
  # Parse the search vector operation into a readable format
133
159
  def parse_search_vector_options(column, options)
134
- settings = options[:as].scan(SEARCH_VECTOR_SCANNER)
135
- return if settings.empty?
160
+ settings = options[:as]&.scan(SEARCH_VECTOR_SCANNER)
161
+ return if settings.blank?
136
162
 
137
163
  languages = settings.map(&:shift).uniq
138
164
  return if languages.many?