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,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module Attributes
6
+ # A pattern that matches label paths, as described by the +lquery+ data
7
+ # type. It is only ever built from Ruby and never parsed back from its
8
+ # text form, so each entry of the given list becomes one item
9
+ class LQuery
10
+ MARKERS = /[*|!{}@%]/
11
+ QUANTIFIER = /\{(?:\d+|\d*,\d*)\}/
12
+ LABEL = /[[:alnum:]_-]+/
13
+ ALTERNATIVE = /#{LABEL}[@*%]{0,3}/
14
+ ITEM = /\A!?#{ALTERNATIVE}(?:\|#{ALTERNATIVE})*#{QUANTIFIER}?\z/
15
+ STAR_ITEM = /\A\*#{QUANTIFIER}?\z/
16
+
17
+ class << self
18
+ def [](*items)
19
+ new(items)
20
+ end
21
+
22
+ # Values coming from the database are valid by construction
23
+ def load(value)
24
+ new(value, normalize: false)
25
+ end
26
+
27
+ # Whether the given value asks for a pattern instead of a plain path,
28
+ # which is what makes a condition use +~+ instead of +=+
29
+ def marker?(value)
30
+ Array.wrap(value).any? { |item| item_marker?(item) }
31
+ end
32
+
33
+ private
34
+
35
+ def item_marker?(item)
36
+ return marker?(LTree.compatible(item)) if LTree.compatible?(item)
37
+
38
+ case item
39
+ when ::Symbol then item == :any
40
+ when ::Range, ::Array, LQuery then true
41
+ when ::String then item.match?(MARKERS)
42
+ else false
43
+ end
44
+ end
45
+ end
46
+
47
+ attr_reader :items
48
+
49
+ def initialize(items, normalize: true)
50
+ items = normalize ? expand(items) : Array.wrap(items)
51
+ @items = normalize ? items.map { |item| compile(item) } : items.map(&:to_s)
52
+ end
53
+
54
+ def to_s
55
+ items.join('.')
56
+ end
57
+
58
+ def ==(other)
59
+ other.is_a?(LQuery) && to_s == other.to_s
60
+ end
61
+ alias eql? ==
62
+
63
+ def hash
64
+ to_s.hash
65
+ end
66
+
67
+ private
68
+
69
+ # An object that describes itself as a path contributes every one of
70
+ # its labels as an item, rather than a single one
71
+ def expand(value)
72
+ return expand(LTree.compatible(value)) if LTree.compatible?(value)
73
+
74
+ value = value.split('.') if value.is_a?(::String)
75
+ Array.wrap(value).flat_map { |item| expand_item(item) }
76
+ end
77
+
78
+ def expand_item(item)
79
+ return expand(LTree.compatible(item)) if LTree.compatible?(item)
80
+
81
+ [item]
82
+ end
83
+
84
+ def compile(item)
85
+ case item
86
+ when ::Range then compile_range(item)
87
+ when ::Array then compile_alternatives(item)
88
+ when ::Symbol then item == :any ? '*' : compile_item(item)
89
+ when ::String, ::Numeric, ::ActiveRecord::Base then compile_item(item)
90
+ else
91
+ raise ArgumentError, <<~MSG.squish
92
+ Unable to use #{item.inspect} as an lquery item. Items are labels,
93
+ a Range for a quantified star, or an Array of alternatives.
94
+ MSG
95
+ end
96
+ end
97
+
98
+ # A Range is always a quantifier over the star, since a star on its own
99
+ # already means any number of labels
100
+ def compile_range(range)
101
+ min = range.begin
102
+ max = range.end
103
+ max -= 1 if max && range.exclude_end?
104
+
105
+ invalid = min&.negative? || max&.negative? || (min && max && max < min)
106
+ raise ArgumentError, <<~MSG.squish if invalid
107
+ #{range.inspect} is not a valid quantifier for an lquery star.
108
+ MSG
109
+
110
+ return '*' if min.nil? && max.nil?
111
+ return "*{#{min}}" if min == max
112
+ return "*{#{min},}" if max.nil?
113
+ return "*{,#{max}}" if min.nil?
114
+ "*{#{min},#{max}}"
115
+ end
116
+
117
+ def compile_alternatives(list)
118
+ raise ArgumentError, <<~MSG.squish if list.empty?
119
+ An lquery alternation needs at least one label.
120
+ MSG
121
+
122
+ alternatives = list.map { |entry| compile_alternative(entry) }
123
+ alternatives.join('|')
124
+ end
125
+
126
+ def compile_alternative(entry)
127
+ alternative = sanitize_labels(assert_plain!(entry))
128
+
129
+ raise ArgumentError, <<~MSG.squish unless alternative.match?(/\A#{ALTERNATIVE}\z/)
130
+ #{entry.inspect} is not a valid lquery alternative. Alternatives are
131
+ single labels, optionally followed by the #{'@*%'.inspect} modifiers.
132
+ MSG
133
+
134
+ alternative
135
+ end
136
+
137
+ def compile_item(entry)
138
+ text = assert_plain!(entry)
139
+ return text if text.match?(STAR_ITEM)
140
+
141
+ head, quantifier = split_quantifier(text)
142
+ item = sanitize_labels(head) + quantifier
143
+
144
+ raise ArgumentError, <<~MSG.squish unless item.match?(ITEM)
145
+ #{entry.inspect} is not a valid lquery item. An item is a label with
146
+ the optional #{'@*%'.inspect} modifiers, optionally negated with "!",
147
+ alternated with "|" and quantified with "{n,m}".
148
+ MSG
149
+
150
+ item
151
+ end
152
+
153
+ def assert_plain!(entry)
154
+ entry = LTree.resolve_record(entry)
155
+ plain = entry.is_a?(::String) || entry.is_a?(::Symbol) || entry.is_a?(::Numeric)
156
+ raise ArgumentError, <<~MSG.squish unless plain
157
+ Unable to use #{entry.inspect} as part of an lquery item.
158
+ MSG
159
+
160
+ entry.to_s
161
+ end
162
+
163
+ # Only the labels are normalized, so that a replacement never rewrites
164
+ # the structure of the pattern nor the digits of a quantifier
165
+ def sanitize_labels(text)
166
+ text.gsub(LABEL) { |label| LTree.sanitize(label) }
167
+ end
168
+
169
+ def split_quantifier(text)
170
+ match = text.match(/#{QUANTIFIER}\z/)
171
+ return [text, ''] if match.nil?
172
+
173
+ [match.pre_match, match[0]]
174
+ end
175
+ end
176
+ end
177
+
178
+ LQuery = Attributes::LQuery
179
+ end
180
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module Attributes
6
+ # A label path, as stored by the +ltree+ data type. It is an Array of
7
+ # labels, so it flows through Ruby like any other list, which also means
8
+ # that an +ltree[]+ column simply becomes an Array of these
9
+ class LTree < Array
10
+ LABEL = /\A[[:alnum:]_-]+\z/
11
+
12
+ alias depth size
13
+
14
+ class << self
15
+ def [](*labels)
16
+ new(labels)
17
+ end
18
+
19
+ # Values coming from the database are valid by construction, so they
20
+ # skip both the normalization and the validation
21
+ def load(value)
22
+ new(value.to_s.split('.'), normalize: false)
23
+ end
24
+
25
+ # Whether the object knows how to describe itself as a path, which is
26
+ # what allows any class to be used where a path is expected
27
+ def compatible?(value)
28
+ method = PostgreSQL.config.ltree.compatible_method
29
+ method.present? && value.respond_to?(method)
30
+ end
31
+
32
+ # The path that the object describes for itself
33
+ def compatible(value)
34
+ method = PostgreSQL.config.ltree.compatible_method
35
+ value.public_send(method) if compatible?(value)
36
+ end
37
+
38
+ # A record stands for its primary key, which is what makes a path
39
+ # built out of other records work the same way as one built out of
40
+ # labels
41
+ def resolve_record(value)
42
+ return value unless value.is_a?(::ActiveRecord::Base)
43
+
44
+ id = value.id
45
+ raise ArgumentError, <<~MSG.squish if id.nil?
46
+ Unable to use #{value.class.name} as a label because its
47
+ #{value.class.primary_key} is still empty.
48
+ MSG
49
+
50
+ raise ArgumentError, <<~MSG.squish if id.is_a?(::Array)
51
+ Unable to use #{value.class.name} as a label because it has a
52
+ composite primary key, which cannot be a single label.
53
+ MSG
54
+
55
+ id
56
+ end
57
+
58
+ # Apply the configured replacements, so callers can feed a source that
59
+ # does not satisfy PostgreSQL's rules for a label on its own
60
+ def sanitize(value)
61
+ replacements = PostgreSQL.config.ltree.sanitize
62
+ return value if replacements.blank?
63
+
64
+ value.gsub(Regexp.union(replacements.keys), replacements)
65
+ end
66
+ end
67
+
68
+ def initialize(labels = nil, normalize: true)
69
+ super()
70
+ concat(normalize ? normalized(labels) : Array.wrap(labels))
71
+ end
72
+
73
+ def to_s
74
+ join('.')
75
+ end
76
+
77
+ def root?
78
+ size <= 1
79
+ end
80
+
81
+ def root
82
+ self.class.new(first, normalize: false)
83
+ end
84
+
85
+ # A path with a single label has no parent, and neither does an empty one
86
+ def parent
87
+ self.class.new(self[0..-2], normalize: false) unless root?
88
+ end
89
+
90
+ def /(other)
91
+ self.class.new(to_a + self.class.new(other), normalize: false)
92
+ end
93
+
94
+ alias_method :+, :/
95
+
96
+ # Same as the +@>+ operator, which includes the path itself
97
+ def ancestor_of?(other)
98
+ other = self.class.new(other)
99
+ size <= other.size && other.first(size) == to_a
100
+ end
101
+ alias covers? ancestor_of?
102
+
103
+ # Same as the +<@+ operator, which includes the path itself
104
+ def descendant_of?(other)
105
+ self.class.new(other).ancestor_of?(self)
106
+ end
107
+ alias covered_by? descendant_of?
108
+
109
+ # The longest common ancestor, which never includes the last label of
110
+ # any of the paths, exactly like PostgreSQL's own +lca+
111
+ def lca(*others)
112
+ paths = [self, *others].map { |path| self.class.new(path)[0..-2].to_a }
113
+ result = paths.shift || []
114
+ paths.each { |path| result = common_prefix(result, path) }
115
+ self.class.new(result, normalize: false)
116
+ end
117
+
118
+ # The position where the given subpath starts, or -1 when it is not
119
+ # present. Named apart from +index+ so that Array's own contract, of
120
+ # returning +nil+ when the element is missing, stays intact
121
+ def index_of(subpath, offset = 0)
122
+ subpath = self.class.new(subpath)
123
+ offset += size if offset.negative?
124
+ return -1 if subpath.empty? || offset.negative?
125
+
126
+ range = offset..(size - subpath.size)
127
+ position = range.find { |i| self[i, subpath.size] == subpath.to_a }
128
+ position || -1
129
+ end
130
+
131
+ private
132
+
133
+ def normalized(labels)
134
+ entries = Array.wrap(labels).flat_map { |value| split_labels(value) }
135
+ entries.each { |label| assert_valid_label!(label) }
136
+ end
137
+
138
+ def split_labels(value)
139
+ return normalized(self.class.compatible(value)) if self.class.compatible?(value)
140
+
141
+ value = self.class.resolve_record(value)
142
+ plain = value.is_a?(::String) || value.is_a?(::Symbol) || value.is_a?(::Numeric)
143
+ raise ArgumentError, <<~MSG.squish unless plain
144
+ Unable to use #{value.inspect} as part of an ltree path. A path is a
145
+ plain sequence of labels, so it accepts neither the alternatives nor
146
+ the quantifiers that only make sense on an lquery.
147
+ MSG
148
+
149
+ self.class.sanitize(value.to_s).split('.')
150
+ end
151
+
152
+ def assert_valid_label!(label)
153
+ raise ArgumentError, <<~MSG.squish unless label.match?(LABEL)
154
+ #{label.inspect} is not a valid ltree label. Labels are limited to
155
+ letters, numbers, underscores and dashes.
156
+ MSG
157
+ end
158
+
159
+ def common_prefix(one, other)
160
+ limit = [one.size, other.size].min
161
+ size = (0...limit).find { |i| one[i] != other[i] }
162
+ one.first(size || limit)
163
+ end
164
+ end
165
+ end
166
+
167
+ LTree = Attributes::LTree
168
+ end
169
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Torque
4
+ module PostgreSQL
5
+ module Attributes
6
+ # Brings Rails' own +enum+ to classes that are not backed by a table, which
7
+ # means everything that depends on a record being persisted, or on a
8
+ # relation, is left out. Only the +value?+ methods are generated
9
+ module SimpleEnum
10
+ extend ActiveSupport::Concern
11
+
12
+ # Replaces the module that Rails uses to generate the methods of an
13
+ # enum, so that only the ones that make sense here are defined
14
+ class Methods < Module
15
+ def initialize(klass)
16
+ @klass = klass
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :klass
22
+
23
+ def define_enum_methods(name, value_method_name, value, _scopes, instance_methods)
24
+ return unless instance_methods
25
+
26
+ klass.send(:detect_enum_conflict!, name, "#{value_method_name}?")
27
+ define_method("#{value_method_name}?") do
28
+ @attributes[name].value_for_database == value
29
+ end
30
+ end
31
+ end
32
+
33
+ included do
34
+ class_attribute :defined_enums, instance_writer: false, default: {}
35
+ end
36
+
37
+ class_methods do
38
+ include ActiveRecord::Enum
39
+
40
+ # Scopes require a relation, so they are never an option here
41
+ def enum(name, values = nil, **options)
42
+ super(name, values, **options, scopes: false)
43
+ end
44
+
45
+ private
46
+
47
+ def _enum_methods_module
48
+ @_enum_methods_module ||= begin
49
+ mod = Methods.new(self)
50
+ include mod
51
+ mod
52
+ end
53
+ end
54
+
55
+ # There are no dangerous class methods to check against, since enums
56
+ # never define one, and anything that the base class already
57
+ # provides is off limits
58
+ def detect_enum_conflict!(enum_name, method_name, klass_method = false)
59
+ return if klass_method
60
+ return unless Base.method_defined?(method_name) ||
61
+ Base.private_method_defined?(method_name)
62
+
63
+ raise ArgumentError, <<~MSG.squish
64
+ You tried to define an enum named "#{enum_name}" on #{name}, but
65
+ it generates a method "#{method_name}" that is already defined.
66
+ MSG
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Torque
6
+ module PostgreSQL
7
+ module Attributes
8
+ class Struct < Base
9
+ # ActiveModel keeps a sentinel object as the original value of a
10
+ # property that was never written to, which leaks into +changes+ and
11
+ # into anything built on top of it, so it reads as nil instead
12
+ class Uninitialized < ActiveModel::Attribute.const_get(:Uninitialized)
13
+ def original_value
14
+ end
15
+ end
16
+
17
+ class << self
18
+ attr_writer :strict
19
+
20
+ # Whether properties that are not declared by the class are rejected
21
+ def strict?
22
+ return !!@strict if defined?(@strict)
23
+ return !!superclass.strict? if superclass.respond_to?(:strict?)
24
+
25
+ PostgreSQL.config.struct.default_strict
26
+ end
27
+
28
+ # Declared properties without a default are uninitialized, so that
29
+ # they are absent from the document until they are written to
30
+ def _default_attributes
31
+ @_struct_default_attributes ||= super.map do |attribute|
32
+ next attribute if attribute.is_a?(ActiveModel::Attribute::UserProvidedDefault)
33
+ Uninitialized.new(attribute.name, attribute.type)
34
+ end
35
+ end
36
+
37
+ # Provide a method on the given class to setup which columns are
38
+ # backed by a struct class
39
+ def include_on(klass, method_name = nil)
40
+ method_name ||= PostgreSQL.config.struct.base_method
41
+ klass.define_singleton_method(method_name) do |column, struct_klass, **options|
42
+ Struct.build_on(self, column, struct_klass, **options)
43
+ rescue Interrupt
44
+ # Not able to build the attribute, maybe pending migrations
45
+ end
46
+ end
47
+
48
+ # Setup the struct column on the given model, adding the proper
49
+ # attribute type, default, validation, and delegations
50
+ def build_on(model, column, klass, default: nil, array: nil, delegate: nil, backfill: nil, strict: nil)
51
+ return unless model.table_exists?
52
+
53
+ column = column.to_s
54
+ has_encryption = model.respond_to?(:encrypted_attributes) &&
55
+ model.encrypted_attributes&.include?(column.to_sym)
56
+
57
+ raise ArgumentError, <<~MSG.squish if has_encryption
58
+ Unable to setup the struct column "#{column}" on #{model.name}
59
+ because the column is encrypted. Encryption is supported on
60
+ individual struct attributes instead.
61
+ MSG
62
+
63
+ info = model.columns_hash[column]
64
+ return if info.nil?
65
+
66
+ raise ArgumentError, <<~MSG.squish unless %i[json jsonb].include?(info.type)
67
+ Unable to setup the struct column "#{column}" on #{model.name}
68
+ because #{info.sql_type} columns cannot hold a document.
69
+ MSG
70
+
71
+ klass = klass.constantize if klass.is_a?(String) || klass.is_a?(Symbol)
72
+ klass.strict = !!strict if !strict.nil? && klass.respond_to?(:strict=)
73
+
74
+ type =
75
+ if array
76
+ Adapter::OID::StructList.new(klass, type: info.type, backfill: backfill)
77
+ else
78
+ Adapter::OID::Struct.new(klass, type: info.type, backfill: backfill)
79
+ end
80
+
81
+ type = Adapter::OID::StructSet.new(type) if info.array?
82
+
83
+ # Defaults that can be composed from the record have to be resolved
84
+ # on it, all the others are plain attribute defaults
85
+ if default.is_a?(Proc) || default.is_a?(Symbol)
86
+ model.attribute(column, type)
87
+ resolve_default_on(model, column, default)
88
+ else
89
+ model.attribute(column, type, default: -> { default&.deep_dup || type.blank_document })
90
+ end
91
+
92
+ Array.wrap(delegate).flat_map do |property|
93
+ [property, "#{property}="]
94
+ end.then do |delegations|
95
+ model.delegate(*delegations, to: column) if delegations.any?
96
+ end
97
+
98
+ model.validates(column, nested: true, allow_blank: true)
99
+ end
100
+
101
+ private
102
+
103
+ def reset_default_attributes!
104
+ @_struct_default_attributes = nil
105
+ super
106
+ end
107
+
108
+ # Write the default on the record, as soon as it is initialized, so
109
+ # that it can be composed from the record and properly stored
110
+ def resolve_default_on(model, column, default)
111
+ model.after_initialize do
112
+ next unless new_record?
113
+ next unless read_attribute_before_type_cast(column).nil?
114
+ next if attribute_changed?(column)
115
+
116
+ value = default.is_a?(Proc) ? instance_exec(&default) : public_send(default)
117
+ write_attribute(column, value)
118
+ end
119
+ end
120
+ end
121
+
122
+ # Properties that are not declared by the class are kept as they are,
123
+ # without any type, which is only allowed when the class is not strict
124
+ def attribute_writer_missing(name, value)
125
+ return super if self.class.strict?
126
+
127
+ name = name.to_s
128
+ attribute = @attributes[name] if @attributes.key?(name)
129
+ attribute ||= ActiveModel::Attribute.from_database(name, nil, ActiveModel::Type.default_value)
130
+ @attributes[name] = attribute.with_value_from_user(value)
131
+ end
132
+ end
133
+ end
134
+
135
+ Struct = Attributes::Struct
136
+ end
137
+ end
@@ -17,9 +17,24 @@ module Torque
17
17
  end
18
18
 
19
19
  class_methods do
20
- delegate :distinct_on, :with, :itself_only, :cast_records, :join_series,
20
+ delegate :distinct_on, :with, :itself_only, :expand_records, :join_series,
21
21
  :buckets, to: :all
22
22
 
23
+ # Composite values are objects that can be invalid on their own, so the
24
+ # attributes backed by one are validated alongside the record
25
+ def load_schema!
26
+ super
27
+ return unless PostgreSQL.config.composite.enabled &&
28
+ Adapter::OID.const_defined?(:Composite)
29
+
30
+ attribute_types.each do |name, type|
31
+ next if Adapter::OID::Composite.from(type).nil?
32
+ next if _validators[name.to_sym].any?(Validations::NestedValidator)
33
+
34
+ validates(name, nested: true)
35
+ end
36
+ end
37
+
23
38
  # Make sure that table name is an instance of TableName class
24
39
  def reset_table_name
25
40
  return super unless PostgreSQL.config.schemas.enabled
@@ -27,24 +42,12 @@ module Torque
27
42
  end
28
43
 
29
44
  # Whenever the base model is inherited, add a list of auxiliary
30
- # statements like the one that loads inherited records' relname
45
+ # statements
31
46
  def inherited(subclass)
32
47
  super
33
48
 
34
49
  subclass.class_attribute(:auxiliary_statements_list)
35
50
  subclass.auxiliary_statements_list = {}
36
-
37
- record_class = ActiveRecord::Relation._record_class_attribute
38
-
39
- # Define the dynamic attribute that returns the same information as
40
- # the one provided by the auxiliary statement
41
- subclass.dynamic_attribute(record_class) do
42
- klass = self.class
43
- next klass.table_name unless klass.physically_inheritances?
44
-
45
- query = klass.unscoped.where(subclass.primary_key => id)
46
- query.pluck(klass.arel_table['tableoid'].pg_cast('regclass')).first
47
- end
48
51
  end
49
52
 
50
53
  # Specifies a one-to-many association. The following methods for