torque-postgresql 4.0.1 → 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 (64) 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 +26 -0
  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/merger.rb +21 -5
  46. data/lib/torque/postgresql/relation.rb +12 -11
  47. data/lib/torque/postgresql/schema_cache.rb +1 -0
  48. data/lib/torque/postgresql/validations.rb +29 -0
  49. data/lib/torque/postgresql/version.rb +1 -1
  50. data/lib/torque/postgresql/versioned_commands/command_migration.rb +1 -1
  51. data/lib/torque/postgresql.rb +6 -0
  52. data/spec/initialize.rb +20 -2
  53. data/spec/mocks/cache_query.rb +12 -0
  54. data/spec/models/author.rb +1 -1
  55. data/spec/models/comment.rb +2 -0
  56. data/spec/models/place.rb +2 -0
  57. data/spec/models/profile.rb +31 -0
  58. data/spec/schema.rb +33 -4
  59. data/spec/tests/arel_spec.rb +5 -3
  60. data/spec/tests/composite_spec.rb +800 -0
  61. data/spec/tests/ltree_spec.rb +552 -0
  62. data/spec/tests/struct_spec.rb +968 -0
  63. data/spec/tests/table_inheritance_spec.rb +1027 -56
  64. metadata +47 -2
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module PredicateBuilder
6
+ # Turns a hash of columns into a proper set of conditions over a composite
7
+ # column, handing each of them back to the predicate builder so that the
8
+ # whole +where+ vocabulary works inside a composite
9
+ class CompositeHandler
10
+ class << self
11
+ # Values that this handler knows how to deal with, checked before the
12
+ # attribute itself because it is way cheaper
13
+ def candidate?(value, type)
14
+ return false unless Adapter::OID::Composite.from(type)
15
+
16
+ case value
17
+ when ::Hash, Attributes::Composite then true
18
+ when ::Array then value.any? { |entry| candidate?(entry, type) }
19
+ else false
20
+ end
21
+ end
22
+
23
+ # Whether the value describes columns, instead of whole records
24
+ def columns?(value)
25
+ value.is_a?(::Hash) || (value.is_a?(::Array) && value.all?(::Hash))
26
+ end
27
+ end
28
+
29
+ def initialize(predicate_builder)
30
+ @predicate_builder = predicate_builder
31
+ end
32
+
33
+ def call(attribute, type, value)
34
+ @composite = Adapter::OID::Composite.from(type)
35
+ array = type.is_a?(ARRAY_OID)
36
+ record = !self.class.columns?(value)
37
+
38
+ return call_for_record(attribute, value, array) if record
39
+ return call_for_array(attribute, value) if array
40
+
41
+ conditions_for(attribute, value)
42
+ end
43
+
44
+ # The type of a single column of the composite type
45
+ def type_of(name)
46
+ columns = @composite.columns
47
+ name = name.to_s
48
+
49
+ raise ArgumentError, <<~MSG.squish unless columns.key?(name)
50
+ Unable to build a condition for "#{name}" because it is not a
51
+ column of the "#{@composite.name}" composite type.
52
+ MSG
53
+
54
+ [columns[name]]
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :predicate_builder, :composite
60
+
61
+ # Whole records cannot be sent as anonymous binds, because the
62
+ # comparison operators resolve them to the +record+ pseudo type, so
63
+ # they are always casted to the type they belong to
64
+ def call_for_record(attribute, value, array)
65
+ name = attribute.name
66
+ cast = composite.name
67
+
68
+ return attribute.eq(FN.bind(name, value, composite).pg_cast(cast)) unless array
69
+
70
+ if value.is_a?(::Array)
71
+ list = FN.bind(name, value, ARRAY_OID.new(composite))
72
+ return attribute.overlaps(list.pg_cast(cast, true))
73
+ end
74
+
75
+ entry = FN.bind(name, value, composite).pg_cast(cast)
76
+ FN.infix(:"=", entry, FN.any(attribute))
77
+ end
78
+
79
+ # Entries of an array of composite values can only be matched one by
80
+ # one, so the conditions are checked against each unnested entry
81
+ def call_for_array(attribute, value)
82
+ source = Arel::Nodes::Ref.new(composite.name)
83
+ entries = ::Arel::Nodes::TableAlias.new(FN.unnest(attribute), composite.name)
84
+
85
+ manager = ::Arel::SelectManager.new
86
+ manager.from(entries)
87
+ manager.project(::Arel.sql('1'))
88
+ manager.where(conditions_for(source, value))
89
+ manager.exists
90
+ end
91
+
92
+ # A list of values means that any of them is a match
93
+ def conditions_for(source, value)
94
+ return group_for(source, value) unless value.is_a?(::Array)
95
+
96
+ groups = value.map { |entry| group_for(source, entry) }
97
+ groups.reduce(:or)
98
+ end
99
+
100
+ def group_for(source, entry)
101
+ entry = entry.to_h unless entry.is_a?(::Hash)
102
+ table = PredicateTable.new(self, source, Arel::Nodes::Column)
103
+ nodes = predicate_builder.with(table).build_from_hash(entry.stringify_keys)
104
+ ::Arel::Nodes::Grouping.new(nodes.reduce(:and))
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module PredicateBuilder
6
+ # A condition over an ltree column is rarely a plain equality, so the value
7
+ # decides which operator to use: a plain path is compared with +=+, while
8
+ # anything carrying an lquery marker is matched with +~+
9
+ class LtreeHandler
10
+ class << self
11
+ # An Array is always a single value here, either the labels of a path
12
+ # or the items of a pattern, so it never means a list of values the
13
+ # way the regular handlers would take it. Objects that describe their
14
+ # own path are claimed as well, otherwise a record would be reduced to
15
+ # its primary key before it had the chance to describe itself
16
+ def candidate?(value, type)
17
+ return false unless type.is_a?(Adapter::OID::Ltree)
18
+
19
+ value.is_a?(::Array) || value.is_a?(LQuery) ||
20
+ LTree.compatible?(value) || LQuery.marker?(value)
21
+ end
22
+ end
23
+
24
+ def initialize(predicate_builder)
25
+ @predicate_builder = predicate_builder
26
+ end
27
+
28
+ def call(attribute, type, value)
29
+ return attribute.eq(FN.bind_with(attribute, type.cast(value))) \
30
+ unless LQuery.marker?(value)
31
+
32
+ attribute.matches_lquery(pattern_for(attribute, value))
33
+ end
34
+
35
+ private
36
+
37
+ def pattern_for(attribute, value)
38
+ type = Adapter::OID::Lquery.new
39
+ FN.bind(attribute.name, type.cast(value), type).pg_cast('lquery')
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module PredicateBuilder
6
+ # Turns a hash of properties into a proper set of conditions over the
7
+ # document that holds them, handing each of them back to the predicate
8
+ # builder so that the whole +where+ vocabulary works inside a struct
9
+ class StructHandler
10
+ # Properties are extracted as text, so only the ones whose type has a
11
+ # counterpart on the database are casted before being compared
12
+ PLAIN_TYPES = %i[string text].freeze
13
+
14
+ class << self
15
+ # Only a hash describes properties, everything else is left alone so
16
+ # that whole documents are still compared as documents
17
+ def candidate?(value, type)
18
+ value.is_a?(::Hash) && type.is_a?(Adapter::OID::Struct) &&
19
+ !type.is_a?(Adapter::OID::StructList)
20
+ end
21
+ end
22
+
23
+ def initialize(predicate_builder)
24
+ @predicate_builder = predicate_builder
25
+ end
26
+
27
+ def call(attribute, struct, value)
28
+ raise ArgumentError, <<~MSG.squish unless struct.type == :jsonb
29
+ Unable to build a condition over "#{attribute.name}" because json
30
+ columns cannot be queried by their properties. Use jsonb instead.
31
+ MSG
32
+
33
+ @struct = struct
34
+ table = PredicateTable.new(self, attribute, Arel::Nodes::Property)
35
+ nodes = predicate_builder.with(table).build_from_hash(value.stringify_keys)
36
+
37
+ ::Arel::Nodes::Grouping.new(nodes.reduce(:and))
38
+ end
39
+
40
+ # The type of a single property, plus what it has to be casted to once
41
+ # it is extracted from the document as text
42
+ def type_of(name)
43
+ klass = @struct.klass
44
+ name = name.to_s
45
+
46
+ raise ArgumentError, <<~MSG.squish if klass.strict? && !klass.attribute_names.include?(name)
47
+ Unable to build a condition for "#{name}" because it is not a
48
+ declared property of #{klass.name}.
49
+ MSG
50
+
51
+ type = klass.attribute_types[name]
52
+ [type, cast_for(type)]
53
+ end
54
+
55
+ private
56
+
57
+ attr_reader :predicate_builder
58
+
59
+ def cast_for(type)
60
+ name = type&.type
61
+ return if name.nil? || PLAIN_TYPES.include?(name)
62
+
63
+ ActiveRecord::Base.connection.type_to_sql(name)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require_relative 'predicate_builder/array_handler'
4
4
 
5
+ require_relative 'predicate_builder/composite_handler'
6
+ require_relative 'predicate_builder/ltree_handler'
7
+ require_relative 'predicate_builder/struct_handler'
5
8
  require_relative 'predicate_builder/regexp_handler'
6
9
  require_relative 'predicate_builder/arel_attribute_handler'
7
10
  require_relative 'predicate_builder/enumerator_lazy_handler'
@@ -28,6 +31,29 @@ module Torque
28
31
  register_handler(::Arel::Attributes::Attribute, ArelAttributeHandler.new(self))
29
32
  end
30
33
  end
34
+
35
+ # Values described as a hash of columns or properties are turned into
36
+ # conditions over the column that holds them. Whole values are left to
37
+ # the regular handlers, so that they are compared as a single value
38
+ def build(attribute, value, operator = nil)
39
+ type = table.type(attribute.name)
40
+ handler = handler_for_document(type, value)
41
+ return super if handler.nil?
42
+
43
+ handler.new(self).call(attribute, type, value)
44
+ end
45
+
46
+ private
47
+
48
+ def handler_for_document(type, value)
49
+ if PostgreSQL.config.composite.enabled && CompositeHandler.candidate?(value, type)
50
+ CompositeHandler
51
+ elsif PostgreSQL.config.struct.enabled && StructHandler.candidate?(value, type)
52
+ StructHandler
53
+ elsif PostgreSQL.config.ltree.enabled && LtreeHandler.candidate?(value, type)
54
+ LtreeHandler
55
+ end
56
+ end
31
57
  end
32
58
 
33
59
  ::ActiveRecord::PredicateBuilder.prepend(PredicateBuilder)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ # Mimics ActiveRecord::TableMetadata so that the parts of a value that holds
6
+ # other values, like the columns of a composite type or the properties of a
7
+ # document, can be treated as the columns of the value they belong to. That
8
+ # is what allows each of them to be handed back to the predicate builder
9
+ #
10
+ # The handler is the one that knows how to resolve a name, and the node is
11
+ # the one that knows how to reach it
12
+ class PredicateTable
13
+ def initialize(handler, source, node)
14
+ @handler = handler
15
+ @source = source
16
+ @node = node
17
+ end
18
+
19
+ def arel_table
20
+ self
21
+ end
22
+
23
+ def [](name)
24
+ @node.new(@source, name, *@handler.type_of(name))
25
+ end
26
+
27
+ def type(name)
28
+ @handler.type_of(name).first
29
+ end
30
+
31
+ # Anything that gets here is meant to be resolved as a part of the value,
32
+ # which is what +type_of+ is there to accept or reject
33
+ def has_column?(*)
34
+ true
35
+ end
36
+
37
+ def primary_key
38
+ nil
39
+ end
40
+
41
+ # Rails 8.1 renamed this, so both names answer for the supported versions
42
+ def associated_with(*)
43
+ false
44
+ end
45
+
46
+ alias associated_with? associated_with
47
+
48
+ def aggregated_with?(*)
49
+ false
50
+ end
51
+
52
+ def polymorphic_association?
53
+ false
54
+ end
55
+
56
+ def through_association?
57
+ false
58
+ end
59
+ end
60
+ end
61
+ end
@@ -85,6 +85,32 @@ module Torque
85
85
  end
86
86
  end
87
87
 
88
+ ## Struct Enabled Setup
89
+ if (config = torque_config.struct).enabled
90
+ require_relative 'adapter/oid/struct'
91
+ require_relative 'adapter/oid/struct_list'
92
+ require_relative 'adapter/oid/struct_set'
93
+
94
+ require_relative 'attributes/struct'
95
+
96
+ Attributes::Struct.include_on(ActiveRecord::Base)
97
+ end
98
+
99
+ ## Composite Enabled Setup
100
+ if (config = torque_config.composite).enabled
101
+ require_relative 'adapter/oid/composite'
102
+ require_relative 'attributes/composite'
103
+
104
+ ar_type.register(:composite, Adapter::OID::Composite, adapter: :postgresql)
105
+
106
+ config.namespace ||= ::Object.const_set('Composite', Module.new)
107
+
108
+ # Define a method to find composite classes based on the namespace
109
+ config.namespace.define_singleton_method(:const_missing) do |name|
110
+ Attributes::Composite.lookup(name)
111
+ end
112
+ end
113
+
88
114
  ## Geometry Enabled Setup
89
115
  if (config = torque_config.geometry).enabled
90
116
  require_relative 'adapter/oid/box'
@@ -110,6 +136,15 @@ module Torque
110
136
  ar_type.register(:interval, Adapter::OID::Interval, adapter: :postgresql)
111
137
  end
112
138
 
139
+ ## LTree Enabled Setup
140
+ if (config = torque_config.ltree).enabled
141
+ require_relative 'adapter/oid/ltree'
142
+ require_relative 'adapter/oid/lquery'
143
+
144
+ ar_type.register(:ltree, Adapter::OID::Ltree, adapter: :postgresql)
145
+ ar_type.register(:lquery, Adapter::OID::Lquery, adapter: :postgresql)
146
+ end
147
+
113
148
  ## Full Text Search Enabled Setup
114
149
  if (config = torque_config.full_text_search).enabled
115
150
  require_relative 'attributes/full_text_search'
@@ -6,13 +6,33 @@ module Torque
6
6
  module Inheritance
7
7
 
8
8
  # :nodoc:
9
- def cast_records_values
10
- @values.fetch(:cast_records, FROZEN_EMPTY_ARRAY)
9
+ def expand_records_values
10
+ @values.fetch(:expand_records, FROZEN_EMPTY_ARRAY)
11
11
  end
12
12
  # :nodoc:
13
- def cast_records_values=(value)
13
+ def expand_records_values=(value)
14
14
  assert_modifiable!
15
- @values[:cast_records] = value
15
+ @values[:expand_records] = value
16
+ end
17
+
18
+ # :nodoc:
19
+ def expand_records_eager_load_value
20
+ @values.fetch(:expand_records_eager_load, nil)
21
+ end
22
+ # :nodoc:
23
+ def expand_records_eager_load_value=(value)
24
+ assert_modifiable!
25
+ @values[:expand_records_eager_load] = value
26
+ end
27
+
28
+ # :nodoc:
29
+ def expand_records_scoped_value
30
+ @values.fetch(:expand_records_scoped, nil) || {}
31
+ end
32
+ # :nodoc:
33
+ def expand_records_scoped_value=(value)
34
+ assert_modifiable!
35
+ @values[:expand_records_scoped] = value
16
36
  end
17
37
 
18
38
  # :nodoc:
@@ -25,6 +45,8 @@ module Torque
25
45
  @values[:itself_only] = value
26
46
  end
27
47
 
48
+ RECORD_CLASS_TOKEN = :_regclass
49
+
28
50
  delegate :quote_table_name, :quote_column_name, to: :connection
29
51
 
30
52
  # Specify that the results should come only from the table that the
@@ -38,46 +60,90 @@ module Torque
38
60
 
39
61
  # Like #itself_only, but modifies relation in place.
40
62
  def itself_only!(*)
63
+ raise_itself_only_conflict! if expand_records_values.present?
64
+
41
65
  self.itself_only_value = true
42
66
  self
43
67
  end
44
68
 
45
- # Enables the casting of all returned records. The result will include
46
- # all the information needed to instantiate the inherited models
69
+ # Load the columns that only exist on the inherited tables, so that
70
+ # records come out complete and writable. Defaults to every dependent
71
+ # that adds columns
72
+ #
73
+ # Activity.expand_records
74
+ # # Runs one additional query per inherited table
47
75
  #
48
- # Activity.cast_records
49
- # # The result list will have many different classes, for all
50
- # # inherited models of activities
51
- def cast_records(*types, **options)
52
- spawn.cast_records!(*types, **options)
76
+ # Activity.expand_records(ActivityBook, eager_load: true)
77
+ # # Runs a single query using outer joins
78
+ def expand_records(*types, **options)
79
+ spawn.expand_records!(*types, **options)
53
80
  end
54
81
 
55
- # Like #cast_records, but modifies relation in place
56
- def cast_records!(*types, **options)
57
- where!(regclass.pg_cast(:varchar).in(types.map(&:table_name))) if options[:filter]
58
- self.select_extra_values += [regclass.as(_record_class_attribute.to_s)]
59
- self.cast_records_values = (types.present? ? types : model.casted_dependents.values)
82
+ # Like #expand_records, but modifies relation in place
83
+ def expand_records!(*types, eager_load: false, filter: false)
84
+ raise_itself_only_conflict! if itself_only_value === true
85
+
86
+ types = types.presence || model.inheritance_expandable_dependents.values
87
+
88
+ where!(regclass.pg_cast(:varchar).in(types.map(&:table_name))) if filter
89
+ self.expand_records_values = types
90
+ self.expand_records_eager_load_value = eager_load
60
91
  self
61
92
  end
62
93
 
94
+ # Accept the record class marker as a column, which is the only way an
95
+ # explicit selection can still produce casted records
96
+ #
97
+ # Activity.select(:_regclass, :id, :title)
98
+ def select(*fields)
99
+ return super if fields.empty?
100
+ return super(*fields, build_record_class_marker) if fields.delete(RECORD_CLASS_TOKEN)
101
+
102
+ super
103
+ end
104
+
63
105
  private
64
106
 
107
+ def raise_itself_only_conflict!
108
+ raise InheritanceError.new(<<~MSG.squish)
109
+ Reading from ONLY a table never returns records from its
110
+ inherited tables, so itself_only and expand_records cannot be
111
+ combined.
112
+ MSG
113
+ end
114
+
65
115
  # Hook arel build to add any necessary table
66
116
  def build_arel(*)
67
117
  arel = super
68
118
  arel.only if self.itself_only_value === true
69
- build_inheritances(arel)
119
+
120
+ arel.project(build_record_class_marker) if inheritance_discriminated?
121
+ build_inheritances(arel) if self.expand_records_eager_load_value
70
122
  arel
71
123
  end
72
124
 
125
+ def inheritance_discriminated?
126
+ return false if self.itself_only_value === true
127
+ return false if select_values.present?
128
+ return false unless from_clause.empty?
129
+
130
+ model.physically_inheritances?
131
+ end
132
+
133
+ def build_record_class_marker
134
+ regclass.as(_record_class_column_name)
135
+ end
136
+
73
137
  # Build all necessary data for inheritances
74
138
  def build_inheritances(arel)
75
- return if self.cast_records_values.empty?
139
+ return if self.expand_records_values.empty?
76
140
 
77
- mergeable = inheritance_mergeable_attributes
141
+ columns = build_inheritances_joins(arel, self.expand_records_values)
142
+ # The joins are still needed, but an explicit select owns the projection
143
+ return if columns.empty? || select_values.present?
78
144
 
79
- columns = build_inheritances_joins(arel, self.cast_records_values)
80
- columns = columns.map do |column, arel_tables|
145
+ mergeable = inheritance_mergeable_attributes
146
+ projections = columns.map do |column, arel_tables|
81
147
  next arel_tables.first[column] if arel_tables.size == 1
82
148
 
83
149
  if mergeable.include?(column)
@@ -87,8 +153,7 @@ module Torque
87
153
  end
88
154
  end
89
155
 
90
- columns.push(build_auto_caster_marker(arel, self.cast_records_values))
91
- self.select_extra_values += columns.flatten if columns.any?
156
+ arel.project(*projections.flatten)
92
157
  end
93
158
 
94
159
  # Build as many left outer join as necessary for each dependent table
@@ -111,15 +176,68 @@ module Torque
111
176
  columns
112
177
  end
113
178
 
114
- def build_auto_caster_marker(arel, types)
115
- attribute = regclass.pg_cast(:varchar).in(types.map(&:table_name))
116
- attribute.as(self.class._auto_cast_attribute.to_s)
117
- end
118
-
119
179
  def regclass
120
180
  arel_table['tableoid'].pg_cast(:regclass)
121
181
  end
122
182
 
183
+ module Expansion
184
+ def preload_associations(records)
185
+ super
186
+ return if expand_records_scoped_value.blank?
187
+
188
+ expand_records_scoped_value.each do |base_model, targets|
189
+ preloaded_association_names.each do |name|
190
+ reflection = model.reflect_on_association(name)
191
+ next if reflection.nil? || reflection.polymorphic? || !(reflection.klass <= base_model)
192
+
193
+ loaded = records.flat_map { |record| record.association(name).target }
194
+ PostgreSQL::Inheritance::Expander.new(reflection.klass, loaded.compact, targets).call
195
+ end
196
+ end
197
+ end
198
+
199
+ private
200
+
201
+ # Records only carry the columns of the queried table, so
202
+ # expanding has to happen after they have been instantiated
203
+ def exec_queries
204
+ records = super
205
+ warn_about_missing_record_class(records)
206
+ return records if expand_records_values.empty? || expand_records_eager_load_value
207
+
208
+ PostgreSQL::Inheritance::Expander.new(model, records, expand_records_values).call
209
+ records.each(&:readonly!) if readonly_value
210
+ records
211
+ end
212
+
213
+ def preloaded_association_names
214
+ list = preload_values + (eager_loading? ? [] : includes_values)
215
+ list.flat_map { |entry| entry.is_a?(Hash) ? entry.keys : entry }
216
+ end
217
+
218
+ # Warn once per query that actually lost the real class, instead of
219
+ # once per explicit select that merely omits the marker
220
+ def warn_about_missing_record_class(records)
221
+ return unless model.physically_inheritances?
222
+ return if records.empty? || group_values.present? || distinct_value || select_values.empty?
223
+ return unless from_clause.empty?
224
+ return if itself_only_value === true
225
+ return if select_values.any? { |value| record_class_marker?(value) }
226
+ return if records.any? { |record| record.class != model }
227
+
228
+ warn(<<~MSG.squish)
229
+ #{model.name} was queried with an explicit select that omits
230
+ :_regclass, so its records will not be instantiated as their
231
+ real class.
232
+ MSG
233
+ end
234
+
235
+ def record_class_marker?(value)
236
+ return value.right.to_s == _record_class_column_name if value.is_a?(::Arel::Nodes::As)
237
+ value.is_a?(String) && value.include?(_record_class_column_name)
238
+ end
239
+
240
+ end
123
241
  end
124
242
  end
125
243
  end
@@ -46,18 +46,34 @@ module Torque
46
46
  end
47
47
  end
48
48
 
49
- # Merge settings related to inheritance tables
49
+ # Merge settings related to inheritance tables, going through the
50
+ # public operations so that their conflicts are still detected
50
51
  def merge_inheritance
51
52
  return unless relation.is_a?(Relation::Inheritance)
52
53
 
53
- relation.itself_only_value = true if other.itself_only_value.present?
54
+ relation.itself_only! if other.itself_only_value.present?
54
55
 
55
- if other.cast_records_values.present?
56
- relation.cast_records_values += other.cast_records_values
57
- relation.cast_records_values.uniq!
56
+ return if other.expand_records_values.blank?
57
+
58
+ if relation.model == other.model
59
+ types = (relation.expand_records_values + other.expand_records_values).uniq
60
+ relation.expand_records!(*types, eager_load: other.expand_records_eager_load_value)
61
+ else
62
+ raise_eager_load_conflict! if other.expand_records_eager_load_value
63
+
64
+ relation.expand_records_scoped_value = relation.expand_records_scoped_value
65
+ .merge(other.model => other.expand_records_values) { |_, a, b| (a + b).uniq }
58
66
  end
59
67
  end
60
68
 
69
+ def raise_eager_load_conflict!
70
+ raise InheritanceError.new(<<~MSG.squish)
71
+ Expanding the records of a different model happens through a
72
+ preload, which has no query to eager load the inherited tables
73
+ into, so the two cannot be merged.
74
+ MSG
75
+ end
76
+
61
77
  # Merge settings related to buckets
62
78
  def merge_buckets
63
79
  return unless defined?(Relation::Buckets) && relation.is_a?(Relation::Buckets)