activerecord-refined 0.11.0 → 0.12.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.
@@ -0,0 +1,451 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record/refined/ast/node"
4
+
5
+ module ActiveRecord
6
+ module Refined
7
+ module AST
8
+ # A plain SQL comparison. The value is passed through as it is, so a Range
9
+ # or an Array compares against a PostgreSQL range or array column, the way
10
+ # Active Record's own force_equality? types do.
11
+ class Comparison < Predicate
12
+ # @private
13
+ OPERATOR_MAP = {
14
+ :== => :eq, :!= => :not_eq,
15
+ :> => :gt, :>= => :gteq, :< => :lt, :<= => :lteq
16
+ }.freeze
17
+
18
+ # @private
19
+ attr_reader :column, :operator, :value
20
+
21
+ def initialize(column, operator, value)
22
+ @column = column
23
+ @operator = operator
24
+ @value = value
25
+ end
26
+
27
+ # @private
28
+ def to_arel(table, model)
29
+ arel_column = to_arel_operand(column, table, model)
30
+ arel_value =
31
+ case value
32
+ when Node then value.to_arel(table, model)
33
+ when ::Symbol then column_operand(value, table, model)
34
+ when ActiveRecord::Relation then scalar_subquery(value)
35
+ else quote_number(value)
36
+ end
37
+ arel_column.public_send(OPERATOR_MAP.fetch(operator), arel_value)
38
+ end
39
+
40
+ private
41
+ # A relation compared against a column has to yield a single value, so
42
+ # unlike In there is no sensible default select list to fall back on.
43
+ def scalar_subquery(relation)
44
+ if relation.select_values.empty?
45
+ raise ArgumentError,
46
+ "#{operator} needs a subquery selecting one value; add a select"
47
+ end
48
+ relation = relation.send(:apply_join_dependency) if relation.eager_loading?
49
+ relation.arel
50
+ end
51
+ end
52
+
53
+ # IS TRUE, IS FALSE and their negations, which every adapter spells the
54
+ # same way and answers alike, NULL included.
55
+ class TruthValue < Predicate
56
+ # @private
57
+ attr_reader :operand, :value, :negated
58
+
59
+ def initialize(operand, value, negated: false)
60
+ @operand = operand
61
+ @value = value
62
+ @negated = negated
63
+ end
64
+
65
+ # @private
66
+ def to_arel(table, model)
67
+ Dialect.for(model).truth_value(
68
+ to_arel_operand(operand, table, model), value, negated, model)
69
+ end
70
+ end
71
+
72
+ # A relation standing for a set of values, which is what IN and the
73
+ # quantifiers each take. The treatment is Active Record's own
74
+ # RelationHandler's: without an explicit select list the subquery
75
+ # selects the model's primary key.
76
+ # @private
77
+ module SetSubquery
78
+ private
79
+ def set_subquery(relation, spelling)
80
+ relation = relation.send(:apply_join_dependency) if relation.eager_loading?
81
+ if relation.select_values.empty?
82
+ model = relation.model
83
+ if model.composite_primary_key?
84
+ raise ArgumentError,
85
+ "Cannot map composite primary key #{model.primary_key} to #{spelling}"
86
+ end
87
+ relation = relation.select(relation.table[model.primary_key])
88
+ end
89
+ relation.arel
90
+ end
91
+ end
92
+
93
+ # IN for a list of values, BETWEEN for a range, IN (SELECT ...) for a
94
+ # relation.
95
+ class In < Predicate
96
+ include SetSubquery
97
+
98
+ # Range holds its endpoints to Comparable, which a quoted node is
99
+ # not, so this quacks the three methods Arel's between reads.
100
+ # @private
101
+ QuotedRange = Struct.new(:begin, :end, :exclude_end) do
102
+ def exclude_end? = exclude_end
103
+ end
104
+
105
+ # @private
106
+ attr_reader :operand, :values, :negated
107
+
108
+ def initialize(operand, values, negated: false)
109
+ @operand = operand
110
+ @values = values
111
+ @negated = negated
112
+ end
113
+
114
+ # @private
115
+ def to_arel(table, model)
116
+ arel_operand = to_arel_operand(operand, table, model)
117
+ case values
118
+ when Range, QuotedRange
119
+ lower = quote_value(values.begin, table, model)
120
+ upper = quote_value(values.end, table, model)
121
+ if json_between?(model) && lower && upper && !negated
122
+ return arel_operand.gteq(lower).and(
123
+ values.exclude_end? ? arel_operand.lt(upper) : arel_operand.lteq(upper))
124
+ end
125
+ range = QuotedRange.new(lower, upper, values.exclude_end?)
126
+ arel_operand.public_send(negated ? :not_between : :between, range)
127
+ when ActiveRecord::Relation
128
+ arel_operand.public_send(negated ? :not_in : :in, set_subquery(values, "IN"))
129
+ else
130
+ arg = values
131
+ if arg.is_a?(::Array)
132
+ arg = arg.map { |value| quote_value(value, table, model) }
133
+ return json_list(arel_operand, arg) if json_list?(model)
134
+ end
135
+ arel_operand.public_send(negated ? :not_in : :in, arg)
136
+ end
137
+ end
138
+
139
+ private
140
+ # An element that is already an expression resolves, a symbol is a
141
+ # column here as everywhere, a number is quoted as itself, and the
142
+ # rest ride for Arel to cast by the column.
143
+ def quote_value(value, table, model)
144
+ case value
145
+ when Node then value.to_arel(table, model)
146
+ when ::Symbol then column_operand(value, table, model)
147
+ else quote_number(value)
148
+ end
149
+ end
150
+
151
+ # MySQL leaves IN and BETWEEN out of its JSON comparisons -- they
152
+ # fall back to another comparison entirely -- so on it a JSON set is
153
+ # spelled as the comparisons it means: the closed range as its two
154
+ # bounds, the list as one equality per element. That names the dug
155
+ # value once per element, the price SQLite's XOR pays per operand;
156
+ # a negated range needs nothing, Arel writing it as two comparisons
157
+ # everywhere. MariaDB never gets this far: the endpoints refuse as
158
+ # they resolve.
159
+ def json_between?(model)
160
+ (values.begin.is_a?(JsonLiteral) || values.end.is_a?(JsonLiteral)) &&
161
+ Dialect.for(model).json_list_by_element?
162
+ end
163
+
164
+ def json_list?(model)
165
+ values.any? { |value| value.is_a?(JsonLiteral) } &&
166
+ Dialect.for(model).json_list_by_element?
167
+ end
168
+
169
+ def json_list(arel_operand, elements)
170
+ comparisons = elements.map do |element|
171
+ negated ? arel_operand.not_eq(element) : arel_operand.eq(element)
172
+ end
173
+ joined = comparisons.inject do |so_far, piece|
174
+ negated ? so_far.and(piece) : so_far.or(piece)
175
+ end
176
+ negated ? Arel::Nodes::Grouping.new(joined) : joined
177
+ end
178
+ end
179
+
180
+ # ANY and ALL, which stand on the right of a comparison and say how many
181
+ # of the subquery's rows have to satisfy it. Where a scalar subquery
182
+ # has to return one row, these take as many as come.
183
+ class Quantified < Node
184
+ include SetSubquery
185
+
186
+ # @private
187
+ attr_reader :kind, :relation
188
+
189
+ def initialize(kind, relation)
190
+ unless relation.is_a?(ActiveRecord::Relation)
191
+ raise ArgumentError,
192
+ "#{kind} takes a relation as its subquery; a list is what in? takes"
193
+ end
194
+ @kind = kind
195
+ @relation = relation
196
+ end
197
+
198
+ # The subquery goes in as its own AST rather than as the manager,
199
+ # which would parenthesise it a second time -- and to PostgreSQL
200
+ # `ANY ((SELECT ...))` is ANY of one scalar, which it refuses.
201
+ # @private
202
+ def to_arel(_table, _model)
203
+ Arel::Nodes::NamedFunction.new(kind, [set_subquery(relation, kind).ast])
204
+ end
205
+ end
206
+
207
+ # EXISTS (SELECT ...) for a relation. Correlate the subquery with the
208
+ # outer table through qualified columns. EXISTS only asks whether a row
209
+ # comes back, so unlike In there is no select list to fix up.
210
+ class Exists < Predicate
211
+ # @private
212
+ attr_reader :relation
213
+
214
+ def initialize(relation)
215
+ @relation = relation
216
+ end
217
+
218
+ # @private
219
+ def to_arel(_table, _model)
220
+ subquery = relation
221
+ if subquery.eager_loading?
222
+ subquery = subquery.send(:apply_join_dependency)
223
+ end
224
+ subquery.arel.exists
225
+ end
226
+ end
227
+
228
+ # `LIKE`, negated or case-insensitive as asked. The pattern of
229
+ # {Predications#like?} goes as written, `%` and `_` its wildcards; the
230
+ # shortcuts, {Predications#start_with?} and its kin, escape theirs and
231
+ # say so with `ESCAPE`, since SQLite reads no escape character unless
232
+ # told one.
233
+ class Like < Predicate
234
+ # @private
235
+ ESCAPE = "\\"
236
+
237
+ # Escapes % and _ so that they match literally. The pattern built from
238
+ # the result must be used with ESCAPE, since SQLite has no default
239
+ # escape character.
240
+ # @private
241
+ def self.escape(string)
242
+ ActiveRecord::Base.sanitize_sql_like(string, ESCAPE)
243
+ end
244
+
245
+ # ORs one LIKE per pattern, for the shortcuts that accept several
246
+ # literals the way String#start_with? does.
247
+ # @private
248
+ def self.any(operand, patterns)
249
+ patterns.map { |pattern| new(operand, pattern, ESCAPE) }.
250
+ inject { |left, right| Or.new(left, right) }
251
+ end
252
+
253
+ # @private
254
+ attr_reader :operand, :pattern, :escape, :case_sensitive, :negated
255
+
256
+ def initialize(operand, pattern, escape = nil, case_sensitive: true,
257
+ negated: false)
258
+ @operand = operand
259
+ @pattern = pattern
260
+ @escape = escape
261
+ @case_sensitive = case_sensitive
262
+ @negated = negated
263
+ end
264
+
265
+ # @private
266
+ def to_arel(table, model)
267
+ # Arel matches case-insensitively unless told otherwise, which is
268
+ # what picks ILIKE over LIKE on PostgreSQL.
269
+ to_arel_operand(operand, table, model).
270
+ public_send(negated ? :does_not_match : :matches,
271
+ pattern, escape, case_sensitive)
272
+ end
273
+ end
274
+
275
+ # IS [NOT] DISTINCT FROM, spelled IS / IS NOT on SQLite and <=> on
276
+ # MySQL. NULL compares as a value here, which is what separates these
277
+ # from = and <>.
278
+ class DistinctFrom < Predicate
279
+ # @private
280
+ attr_reader :operand, :value, :negated
281
+
282
+ def initialize(operand, value, negated: false)
283
+ @operand = operand
284
+ @value = value
285
+ @negated = negated
286
+ end
287
+
288
+ # @private
289
+ def to_arel(table, model)
290
+ arel_operand = to_arel_operand(operand, table, model)
291
+ arel_value = value.is_a?(Node) ? value.to_arel(table, model) : value
292
+ if negated
293
+ arel_operand.is_distinct_from(arel_value)
294
+ else
295
+ arel_operand.is_not_distinct_from(arel_value)
296
+ end
297
+ end
298
+ end
299
+
300
+ # Comparisons against a PostgreSQL array column, named after the Ruby
301
+ # methods that mean the same thing: member? is Enumerable's element
302
+ # test, superset? and subset? are Set's whole-array containment, and
303
+ # intersect? is Array's "any element in common". Each name maps to one
304
+ # operator; the elements are rendered as an array literal, which
305
+ # PostgreSQL coerces to the column's element type, so any expression
306
+ # works as the operand and no schema lookup is needed.
307
+ class ArrayPredicate < Predicate
308
+ # The whole-array comparisons take the collection kinds their
309
+ # namesakes compare against: an Array, or a Set for the Set methods.
310
+ # @private
311
+ def self.elements(arg, method_name)
312
+ case arg
313
+ when ::Array then arg
314
+ when ::Set then arg.to_a
315
+ else
316
+ raise ArgumentError, "#{method_name} takes an Array or Set of elements"
317
+ end
318
+ end
319
+
320
+ # @private
321
+ attr_reader :operand, :operator, :elements
322
+
323
+ def initialize(operand, operator, elements)
324
+ @operand = operand
325
+ @operator = operator
326
+ @elements = elements
327
+ end
328
+
329
+ # @private
330
+ #
331
+ # The refusal is the gem's rather than Arel's: the visitor stopped
332
+ # @> and && off PostgreSQL, but <@ rode an InfixOperation and
333
+ # rendered anywhere, so subset? alone reached the other servers.
334
+ def to_arel(table, model)
335
+ unless Dialect.for(model).array_comparisons_supported?
336
+ raise NotImplementedError,
337
+ "the array comparisons have no equivalent on " \
338
+ "#{model.connection_db_config.adapter}"
339
+ end
340
+ arel_operand = to_arel_operand(operand, table, model)
341
+ quoted = Arel::Nodes.build_quoted(array_literal)
342
+ case operator
343
+ when :"@>" then Arel::Nodes::Contains.new(arel_operand, quoted)
344
+ when :"&&" then Arel::Nodes::Overlaps.new(arel_operand, quoted)
345
+ else Arel::Nodes::InfixOperation.new(operator, arel_operand, quoted)
346
+ end
347
+ end
348
+
349
+ private
350
+ # PostgreSQL array input syntax: elements joined by commas inside
351
+ # braces, and an element is double-quoted whenever it is empty, spells
352
+ # NULL, or contains a character the parser treats specially.
353
+ def array_literal
354
+ encoded = elements.map do |value|
355
+ s = value.to_s
356
+ if s.empty? || s.casecmp?("null") || s.match?(/[\s{},"\\]/)
357
+ "\"#{s.gsub(/["\\]/) { |c| "\\#{c}" }}\""
358
+ else
359
+ s
360
+ end
361
+ end
362
+ "{#{encoded.join(',')}}"
363
+ end
364
+ end
365
+
366
+ # Regular expression match: REGEXP on MySQL, ~ on PostgreSQL. SQLite has
367
+ # no regexp operator built in, so Arel raises NotImplementedError there.
368
+ class Match < Predicate
369
+ # @private
370
+ attr_reader :operand, :pattern, :negated
371
+
372
+ def initialize(operand, pattern, negated: false)
373
+ @operand = operand
374
+ @pattern = pattern.is_a?(Regexp) ? regexp_source(pattern) : pattern
375
+ @negated = negated
376
+ end
377
+
378
+ # @private
379
+ def to_arel(table, model)
380
+ arel_operand = to_arel_operand(operand, table, model)
381
+ if negated
382
+ arel_operand.does_not_match_regexp(pattern)
383
+ else
384
+ arel_operand.matches_regexp(pattern)
385
+ end
386
+ end
387
+
388
+ private
389
+ # A Regexp literal reads naturally with =~, but only its source crosses
390
+ # over; the database has its own dialect and no notion of Ruby's flags.
391
+ # Dropping a flag would silently change what the query matches, so
392
+ # anything beyond a plain literal is refused rather than ignored.
393
+ def regexp_source(regexp)
394
+ unless regexp.options.zero?
395
+ raise ArgumentError,
396
+ "#{regexp.inspect} has options that SQL cannot express; " \
397
+ "pass the pattern as a string instead"
398
+ end
399
+ regexp.source
400
+ end
401
+ end
402
+
403
+ # `AND`, which `&` between two conditions builds.
404
+ class And < Predicate
405
+ # @private
406
+ attr_reader :left, :right
407
+
408
+ def initialize(left, right)
409
+ @left = AST.check_condition(left, :&)
410
+ @right = AST.check_condition(right, :&)
411
+ end
412
+
413
+ # @private
414
+ def to_arel(table, model)
415
+ left.to_arel(table, model).and(right.to_arel(table, model))
416
+ end
417
+ end
418
+
419
+ # `OR`, which `|` between two conditions builds.
420
+ class Or < Predicate
421
+ # @private
422
+ attr_reader :left, :right
423
+
424
+ def initialize(left, right)
425
+ @left = AST.check_condition(left, :|)
426
+ @right = AST.check_condition(right, :|)
427
+ end
428
+
429
+ # @private
430
+ def to_arel(table, model)
431
+ left.to_arel(table, model).or(right.to_arel(table, model))
432
+ end
433
+ end
434
+
435
+ # `NOT`, which `!` on a condition builds.
436
+ class Not < Predicate
437
+ # @private
438
+ attr_reader :operand
439
+
440
+ def initialize(operand)
441
+ @operand = AST.check_condition(operand, :!)
442
+ end
443
+
444
+ # @private
445
+ def to_arel(table, model)
446
+ Arel::Nodes::Not.new(operand.to_arel(table, model))
447
+ end
448
+ end
449
+ end
450
+ end
451
+ end