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.
- checksums.yaml +4 -4
- data/Rakefile +24 -0
- data/lib/torque/postgresql/adapter/database_statements.rb +89 -7
- data/lib/torque/postgresql/adapter/inheritance_statements.rb +297 -0
- data/lib/torque/postgresql/adapter/oid/array.rb +17 -0
- data/lib/torque/postgresql/adapter/oid/box.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/circle.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/composite.rb +116 -0
- data/lib/torque/postgresql/adapter/oid/line.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/lquery.rb +53 -0
- data/lib/torque/postgresql/adapter/oid/ltree.rb +53 -0
- data/lib/torque/postgresql/adapter/oid/segment.rb +1 -1
- data/lib/torque/postgresql/adapter/oid/struct.rb +150 -0
- data/lib/torque/postgresql/adapter/oid/struct_list.rb +52 -0
- data/lib/torque/postgresql/adapter/oid/struct_set.rb +38 -0
- data/lib/torque/postgresql/adapter/quoting.rb +11 -2
- data/lib/torque/postgresql/adapter/schema_definitions.rb +46 -0
- data/lib/torque/postgresql/adapter/schema_dumper.rb +28 -2
- data/lib/torque/postgresql/adapter/schema_statements.rb +207 -0
- data/lib/torque/postgresql/adapter.rb +2 -5
- data/lib/torque/postgresql/arel/nodes.rb +63 -1
- data/lib/torque/postgresql/arel/visitors.rb +19 -8
- data/lib/torque/postgresql/associations/join_dependency.rb +55 -0
- data/lib/torque/postgresql/associations.rb +3 -0
- data/lib/torque/postgresql/attributes/base.rb +94 -0
- data/lib/torque/postgresql/attributes/composite.rb +102 -0
- data/lib/torque/postgresql/attributes/enum.rb +13 -2
- data/lib/torque/postgresql/attributes/lquery.rb +180 -0
- data/lib/torque/postgresql/attributes/ltree.rb +169 -0
- data/lib/torque/postgresql/attributes/simple_enum.rb +72 -0
- data/lib/torque/postgresql/attributes/struct.rb +137 -0
- data/lib/torque/postgresql/base.rb +17 -14
- data/lib/torque/postgresql/config.rb +81 -14
- data/lib/torque/postgresql/inheritance/expander.rb +66 -0
- data/lib/torque/postgresql/inheritance/record.rb +52 -0
- data/lib/torque/postgresql/inheritance.rb +138 -65
- data/lib/torque/postgresql/migration/command_recorder.rb +76 -0
- data/lib/torque/postgresql/predicate_builder/composite_handler.rb +109 -0
- data/lib/torque/postgresql/predicate_builder/ltree_handler.rb +44 -0
- data/lib/torque/postgresql/predicate_builder/struct_handler.rb +68 -0
- data/lib/torque/postgresql/predicate_builder.rb +26 -0
- data/lib/torque/postgresql/predicate_table.rb +61 -0
- data/lib/torque/postgresql/railtie.rb +35 -0
- data/lib/torque/postgresql/relation/inheritance.rb +146 -28
- data/lib/torque/postgresql/relation/join_series.rb +8 -6
- data/lib/torque/postgresql/relation/merger.rb +23 -7
- data/lib/torque/postgresql/relation.rb +12 -11
- data/lib/torque/postgresql/schema_cache.rb +1 -0
- data/lib/torque/postgresql/validations.rb +29 -0
- data/lib/torque/postgresql/version.rb +1 -1
- data/lib/torque/postgresql/versioned_commands/command_migration.rb +1 -1
- data/lib/torque/postgresql.rb +6 -0
- data/spec/initialize.rb +20 -2
- data/spec/mocks/cache_query.rb +12 -0
- data/spec/models/author.rb +1 -1
- data/spec/models/comment.rb +2 -0
- data/spec/models/place.rb +2 -0
- data/spec/models/profile.rb +31 -0
- data/spec/schema.rb +34 -4
- data/spec/tests/arel_spec.rb +5 -3
- data/spec/tests/composite_spec.rb +800 -0
- data/spec/tests/ltree_spec.rb +552 -0
- data/spec/tests/struct_spec.rb +968 -0
- data/spec/tests/table_inheritance_spec.rb +1027 -56
- 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, :
|
|
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
|
|
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
|