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,384 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Refined
5
+ module AST
6
+ # The conditions a column or an expression can be put in. Every one
7
+ # gives back a condition that combines with `&`, `|` and `!`, and the
8
+ # comparisons quote a Ruby value on the right the way Active Record
9
+ # does, or take a column, an expression or a subquery there.
10
+ #
11
+ # @example
12
+ # Author.where { :age.between?(20, 40) & :name.like?("A%") }
13
+ # Author.where { :id.in?(Post.select(:author_id)) }
14
+ #
15
+ # Predicate builders shared by symbols, qualified columns and
16
+ # expressions. Imported into the Symbol refinement with
17
+ # Refinement#import_methods, so every method must be defined with def.
18
+ module Predications
19
+ # `=`. A value, a column, an expression or a scalar subquery on the right; `nil` is refused, since `= NULL` is never true -- {#null?} is the spelling.
20
+ # @return [AST::Predicate]
21
+ # @example
22
+ # Author.where { :name == "alice" }
23
+ # Author.where { :age == Author.select { max(:age) } }
24
+ #
25
+ # == and != mean SQL = and <>, and = NULL is never true there, so nil
26
+ # is rejected rather than silently rewritten to IS NULL. null? builds
27
+ # its node directly and stays clear of this check.
28
+ def ==(other)
29
+ if other.nil?
30
+ raise ArgumentError, "== does not take nil; use null? instead"
31
+ end
32
+ Comparison.new(self, :==, other)
33
+ end
34
+
35
+ # `!=`; `nil` is refused, as with `==`.
36
+ # @return [AST::Predicate]
37
+ def !=(other)
38
+ if other.nil?
39
+ raise ArgumentError, "!= does not take nil; use !null? instead"
40
+ end
41
+ Comparison.new(self, :!=, other)
42
+ end
43
+
44
+ # `>`.
45
+ # @return [AST::Predicate]
46
+ def >(other)
47
+ Comparison.new(self, :>, other)
48
+ end
49
+
50
+ # `>=`.
51
+ # @return [AST::Predicate]
52
+ def >=(other)
53
+ Comparison.new(self, :>=, other)
54
+ end
55
+
56
+ # `<`.
57
+ # @return [AST::Predicate]
58
+ def <(other)
59
+ Comparison.new(self, :<, other)
60
+ end
61
+
62
+ # `<=`.
63
+ # @return [AST::Predicate]
64
+ def <=(other)
65
+ Comparison.new(self, :<=, other)
66
+ end
67
+
68
+ # A regular expression match: `~` on PostgreSQL, `REGEXP` on MySQL, and what the adapter has elsewhere. A Ruby Regexp's source is the pattern.
69
+ # @param pattern [Regexp, String]
70
+ # @return [AST::Predicate]
71
+ # @example
72
+ # Author.where { :name =~ /^A/ }
73
+ def =~(pattern)
74
+ Match.new(self, pattern)
75
+ end
76
+
77
+ # The negated regular expression match.
78
+ # @return [AST::Predicate]
79
+ def !~(pattern)
80
+ Match.new(self, pattern, negated: true)
81
+ end
82
+
83
+ # `IS NULL`.
84
+ # @return [AST::Predicate]
85
+ # @example
86
+ # Author.where { :country.null? }
87
+ #
88
+ # `!` negates any predicate, so these are here for the four that SQL
89
+ # spells for itself: IS NOT NULL rather than NOT (... IS NULL), and
90
+ # likewise NOT IN and NOT LIKE. They mean the same thing either way,
91
+ # including when the column is NULL; what they save is the reading.
92
+ def null?
93
+ Comparison.new(self, :==, nil)
94
+ end
95
+
96
+ # `IS NOT NULL`.
97
+ # @return [AST::Predicate]
98
+ def not_null?
99
+ Comparison.new(self, :!=, nil)
100
+ end
101
+
102
+ # `IS TRUE`: true of the rows where the boolean is true, false where it is false or NULL -- where `== true` would be NULL.
103
+ # @return [AST::Predicate]
104
+ # @example
105
+ # Post.where { :published.true? }
106
+ #
107
+ # IS TRUE and IS FALSE differ from a comparison against the literal in
108
+ # what they make of NULL: `flag = TRUE` is itself NULL there, and a
109
+ # NULL predicate selects nothing, while these two answer false. So the
110
+ # difference shows in the negations: `not_true?` keeps the NULL rows
111
+ # that `!(:flag == true)` drops.
112
+ def true?
113
+ TruthValue.new(self, true)
114
+ end
115
+
116
+ # `IS NOT TRUE`: keeps the NULL rows that `!(:flag == true)` drops.
117
+ # @return [AST::Predicate]
118
+ def not_true?
119
+ TruthValue.new(self, true, negated: true)
120
+ end
121
+
122
+ # `IS FALSE`.
123
+ # @return [AST::Predicate]
124
+ def false?
125
+ TruthValue.new(self, false)
126
+ end
127
+
128
+ # `IS NOT FALSE`.
129
+ # @return [AST::Predicate]
130
+ def not_false?
131
+ TruthValue.new(self, false, negated: true)
132
+ end
133
+
134
+ # `IN (...)`: an array of values, a range, or a relation as a subquery.
135
+ # @param values [Array, Range, ActiveRecord::Relation]
136
+ # @return [AST::Predicate]
137
+ # @example
138
+ # Author.where { :country.in?(%w[JP US]) }
139
+ # Author.where { :id.in?(Post.select(:author_id)) }
140
+ def in?(values)
141
+ In.new(self, values)
142
+ end
143
+
144
+ # `NOT IN (...)`.
145
+ # @return [AST::Predicate]
146
+ def not_in?(values)
147
+ In.new(self, values, negated: true)
148
+ end
149
+
150
+ # `BETWEEN min AND max`, with either end a value, a column or an expression.
151
+ # @return [AST::Predicate]
152
+ # @example
153
+ # Author.where { :age.between?(20, 40) }
154
+ #
155
+ # Not min..max: an endpoint may be an expression, which Range would
156
+ # refuse to hold, since expressions do not compare among themselves.
157
+ def between?(min, max)
158
+ In.new(self, In::QuotedRange.new(min, max, false))
159
+ end
160
+
161
+ # `NOT BETWEEN min AND max`.
162
+ # @return [AST::Predicate]
163
+ def not_between?(min, max)
164
+ In.new(self, In::QuotedRange.new(min, max, false), negated: true)
165
+ end
166
+
167
+ # `CASE column WHEN value THEN ...`: a CASE with this as the operand, each `when` a value it is compared against, followed by `then` and finally `else`.
168
+ # @return [AST::Case::When]
169
+ # @example
170
+ # Author.select { :country.when("JP").then("Japan").else("elsewhere").as(:where) }
171
+ #
172
+ # CASE with this as the operand, compared against each `when`:
173
+ # `:age.when(10).then(1).else(0)`. The other shape, where each `when`
174
+ # carries its own condition, starts at `case_when`.
175
+ def when(value = nil, &block)
176
+ Case.new(self).when(value, &block)
177
+ end
178
+
179
+ # `LIKE pattern`, the pattern as written: `%` and `_` are its wildcards.
180
+ # @param pattern [String]
181
+ # @return [AST::Predicate]
182
+ # @example
183
+ # Author.where { :name.like?("A%") }
184
+ def like?(pattern)
185
+ Like.new(self, pattern)
186
+ end
187
+
188
+ # `NOT LIKE pattern`.
189
+ # @return [AST::Predicate]
190
+ def not_like?(pattern)
191
+ Like.new(self, pattern, negated: true)
192
+ end
193
+
194
+ # A case-insensitive `LIKE`: `ILIKE` on PostgreSQL, and `LIKE` over both sides lower-cased elsewhere.
195
+ # @return [AST::Predicate]
196
+ def ilike?(pattern)
197
+ Like.new(self, pattern, nil, case_sensitive: false)
198
+ end
199
+
200
+ # The negated case-insensitive `LIKE`.
201
+ # @return [AST::Predicate]
202
+ def not_ilike?(pattern)
203
+ Like.new(self, pattern, nil, case_sensitive: false, negated: true)
204
+ end
205
+
206
+ # Case-insensitive equality: `LOWER(column) = LOWER(value)`.
207
+ # @return [AST::Predicate]
208
+ # @example
209
+ # Author.where { :name.casecmp?("Alice") }
210
+ #
211
+ # Case-insensitive equality, folded on both sides rather than left to
212
+ # the collation, so it means the same thing on every adapter.
213
+ def casecmp?(value)
214
+ if value.nil?
215
+ raise ArgumentError, "casecmp? does not take nil; use null? instead"
216
+ end
217
+ Comparison.new(Function.new("LOWER", [self]), :==,
218
+ Function.new("LOWER", [value]))
219
+ end
220
+
221
+ # `IS DISTINCT FROM`: `!=` that treats NULL as a value. `IS NOT` on SQLite, `NOT <=>` on MySQL.
222
+ # @return [AST::Predicate]
223
+ #
224
+ # Null-safe comparison: unlike = and <>, these treat NULL as a value,
225
+ # so not_distinct_from? is the one equality that may take nil.
226
+ def distinct_from?(value)
227
+ DistinctFrom.new(self, value, negated: true)
228
+ end
229
+
230
+ # `IS NOT DISTINCT FROM`: `=` that treats NULL as a value, so this is the one equality that takes `nil`.
231
+ # @return [AST::Predicate]
232
+ # @example
233
+ # Author.where { :country.not_distinct_from?(nil) }
234
+ def not_distinct_from?(value)
235
+ DistinctFrom.new(self, value)
236
+ end
237
+
238
+ # `LIKE 'prefix%'`, the prefix escaped so that a `%` or `_` in it is itself; several prefixes are `OR`ed.
239
+ # @return [AST::Predicate]
240
+ # @example
241
+ # Author.where { :name.start_with?("A", "B") }
242
+ def start_with?(*prefixes)
243
+ if prefixes.empty?
244
+ raise ArgumentError, "start_with? needs at least one prefix"
245
+ end
246
+ Like.any(self, prefixes.map { |prefix| "#{Like.escape(prefix)}%" })
247
+ end
248
+
249
+ # `LIKE '%suffix'`, escaped as {#start_with?} escapes.
250
+ # @return [AST::Predicate]
251
+ def end_with?(*suffixes)
252
+ if suffixes.empty?
253
+ raise ArgumentError, "end_with? needs at least one suffix"
254
+ end
255
+ Like.any(self, suffixes.map { |suffix| "%#{Like.escape(suffix)}" })
256
+ end
257
+
258
+ # `LIKE '%substring%'`, escaped as {#start_with?} escapes.
259
+ # @return [AST::Predicate]
260
+ # @example
261
+ # Post.where { :title.include?("ruby") }
262
+ def include?(substring)
263
+ Like.new(self, "%#{Like.escape(substring)}%", Like::ESCAPE)
264
+ end
265
+
266
+ # Whether a PostgreSQL array column holds the element: `@> ARRAY[element]`.
267
+ # @return [AST::Predicate]
268
+ # @example
269
+ # Post.where { :tags.member?("ruby") }
270
+ #
271
+ # The array comparisons carry the meaning of their Ruby namesakes.
272
+ # member? is Enumerable's element test, so an Array argument is
273
+ # rejected rather than quietly meaning something Array#member? does
274
+ # not; whole-array comparisons go by the Set and Array names.
275
+ def member?(element)
276
+ if element.is_a?(::Array) || element.is_a?(::Set)
277
+ raise ArgumentError,
278
+ "member? takes a single element; use superset? to require every element"
279
+ end
280
+ ArrayPredicate.new(self, :"@>", [element])
281
+ end
282
+
283
+ # Whether an array column holds every element given: `@>`.
284
+ # @return [AST::Predicate]
285
+ def superset?(elements)
286
+ ArrayPredicate.new(self, :"@>", ArrayPredicate.elements(elements, "superset?"))
287
+ end
288
+
289
+ # Whether every element of an array column is among those given: `<@`.
290
+ # @return [AST::Predicate]
291
+ def subset?(elements)
292
+ ArrayPredicate.new(self, :"<@", ArrayPredicate.elements(elements, "subset?"))
293
+ end
294
+
295
+ # Whether an array column and the elements given share any: `&&`.
296
+ # @return [AST::Predicate]
297
+ # @example
298
+ # Post.where { :tags.intersect?(%w[ruby sql]) }
299
+ def intersect?(elements)
300
+ ArrayPredicate.new(self, :"&&", ArrayPredicate.elements(elements, "intersect?"))
301
+ end
302
+
303
+ # The JSON at a path into a JSON column, still JSON -- to be dug further, compared with a Ruby value, or asked {#key?} and the rest. A string or a symbol steps into an object, an integer into an array. `#>` on PostgreSQL, `JSON_EXTRACT` on MySQL, `->` on SQLite.
304
+ # @return [AST::JsonPath]
305
+ # @example
306
+ # Doc.select { :meta.dig(:author).as(:author) }
307
+ # Doc.where { :meta.dig(:author).key?(:name) }
308
+ #
309
+ # Reading inside a JSON document, by the name of what Hash does. A
310
+ # string or symbol steps into an object, an integer into an array, and
311
+ # what comes back is still JSON, the way Hash#dig hands back the
312
+ # structure itself -- for a document to be dug into further or asked
313
+ # the JSON questions. dig_text gives the value as text instead,
314
+ # which is what a comparison wants.
315
+ def dig(*path)
316
+ JsonPath.new(self, path)
317
+ end
318
+
319
+ # The value at a path as text, which is what a comparison against a string wants where the JSON type would not do: `#>>` on PostgreSQL, `JSON_UNQUOTE(JSON_EXTRACT(...))` on MySQL, `->>` on SQLite.
320
+ # @return [AST::JsonPath]
321
+ # @example
322
+ # Doc.where { :meta.dig_text(:author, :name) == "alice" }
323
+ def dig_text(*path)
324
+ JsonPath.new(self, path, json_value: false)
325
+ end
326
+
327
+ # The document without the keys given, as Hash#except gives it; an expression, for `update_all` to write back.
328
+ # @return [AST::JsonExcept]
329
+ # @example
330
+ # Doc.update_all { { meta: :meta.except(:draft) } }
331
+ #
332
+ # Keys taken out of a JSON document, by the name of what Hash does,
333
+ # and taking keys as Hash#except takes them. Like bury it gives back
334
+ # the document changed rather than writing it anywhere.
335
+ def except(*keys)
336
+ JsonExcept.new(self, keys)
337
+ end
338
+
339
+ # The document with a value set at a path, as {#dig} reads one; an expression, for `update_all` to write back.
340
+ # @return [AST::JsonSet]
341
+ # @example
342
+ # Doc.update_all { { meta: :meta.bury(:author, :name, "alice") } }
343
+ #
344
+ # What dig reads, bury sets: the last argument is the value and the
345
+ # rest are the path to it. The document comes back changed rather
346
+ # than being written anywhere, which update_all is for.
347
+ def bury(*path, value)
348
+ JsonSet.new(self, path, value)
349
+ end
350
+
351
+ # Whether the document contains the Ruby document given, which SQL calls containment: `@>` on PostgreSQL, `JSON_CONTAINS` on MySQL. SQLite and MariaDB have none.
352
+ # @return [AST::Predicate]
353
+ # @example
354
+ # Doc.where { :meta.contains?(author: { name: "alice" }) }
355
+ #
356
+ # Whether the document holds what is given, which SQL calls
357
+ # containment. SQLite has no equivalent.
358
+ def contains?(value)
359
+ JsonContains.new(self, value)
360
+ end
361
+
362
+ # Whether the object has the key, as Hash#key? asks.
363
+ # @return [AST::Predicate]
364
+ # @example
365
+ # Doc.where { :meta.key?(:author) }
366
+ #
367
+ # Whether the key is there at all, as Hash#key? asks. Hash has
368
+ # has_key? too; one name is enough, and this is the one Ruby's own
369
+ # style prefers.
370
+ def key?(key)
371
+ JsonHasKey.new(self, key)
372
+ end
373
+
374
+ # The keys of the object as a JSON array, as Hash#keys gives them. Oracle and SQL Server have none.
375
+ # @return [AST::JsonKeys]
376
+ #
377
+ # The keys of the document, as Hash#keys gives them: a JSON array.
378
+ def keys
379
+ JsonKeys.new(self)
380
+ end
381
+ end
382
+ end
383
+ end
384
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record/refined/ast/node"
4
+
5
+ module ActiveRecord
6
+ module Refined
7
+ module AST
8
+ # OVER, on the two things that can carry a window: an aggregate, and a
9
+ # function.
10
+ module Windowing
11
+ # `OVER ()`, an empty window to be filled by {Over#partition},
12
+ # {Over#order}, {Over#rows} and {Over#range}.
13
+ # @return [AST::Over]
14
+ # @example
15
+ # Author.select { avg(:age).over.partition(:country).as(:country_average) }
16
+ # Post.select { sum(:likes).over.order(:created_at).rows(..0).as(:running) }
17
+ def over
18
+ Over.new(self)
19
+ end
20
+ end
21
+
22
+ # A function with a window. The window is built by chaining, the way
23
+ # Arel's own is, and each method returns a new node rather than adding to
24
+ # this one, so a window can be finished more than one way.
25
+ class Over < Node
26
+ include Predications
27
+ include Arithmetics
28
+
29
+ # @private
30
+ attr_reader :function, :partitions, :orders, :frame
31
+
32
+ def initialize(function, partitions = [], orders = [], frame = nil)
33
+ @function = function
34
+ @partitions = partitions
35
+ @orders = orders
36
+ @frame = frame
37
+ end
38
+
39
+ # `PARTITION BY`, the columns or expressions given.
40
+ # @return [AST::Over]
41
+ def partition(*exprs)
42
+ raise ArgumentError, "partition needs an expression" if exprs.empty?
43
+ Over.new(function, partitions + exprs, orders, frame)
44
+ end
45
+
46
+ # `ORDER BY` within the window: columns, or orderings such as `:age.desc`.
47
+ # @return [AST::Over]
48
+ def order(*exprs)
49
+ raise ArgumentError, "order needs an expression" if exprs.empty?
50
+ Over.new(function, partitions, orders + exprs, frame)
51
+ end
52
+
53
+ # `ROWS BETWEEN`, as a range of rows counted from the current one: negative before it, positive after, `0` the row itself, an open end unbounded. `rows(..0)` is a running total, `rows(-1..1)` the row and its neighbours.
54
+ # @param bounds [Range]
55
+ # @return [AST::Over]
56
+ def rows(bounds)
57
+ Over.new(function, partitions, orders, framing(:rows, bounds))
58
+ end
59
+
60
+ # `RANGE BETWEEN`, with the bounds as {#rows} takes them.
61
+ # @param bounds [Range]
62
+ # @return [AST::Over]
63
+ def range(bounds)
64
+ Over.new(function, partitions, orders, framing(:range, bounds))
65
+ end
66
+
67
+ # @private
68
+ def to_arel(table, model)
69
+ window = Arel::Nodes::Window.new
70
+ partitions.each { |expr| window.partition(to_arel_operand(expr, table, model)) }
71
+ orders.each { |expr| window.order(to_arel_operand(expr, table, model)) }
72
+ frame_arel(window) if frame
73
+
74
+ # Not every aggregate can ride a window everywhere; the node itself
75
+ # says where, once the adapter is known.
76
+ function.check_window(model) if function.respond_to?(:check_window)
77
+
78
+ # A window-only function refuses to build on its own; here is where
79
+ # it is asked for the call itself.
80
+ arel_function =
81
+ function.is_a?(WindowFunction) ? function.call_arel(table, model)
82
+ : function.to_arel(table, model)
83
+ Arel::Nodes::Over.new(arel_function, window)
84
+ end
85
+
86
+ private
87
+ # The frame is a range of rows counted from the current one: negative
88
+ # before it, positive after, 0 the row itself, and an open end for
89
+ # unbounded. `rows(..0)` is what a running total wants.
90
+ def framing(kind, bounds)
91
+ raise ArgumentError, "a window has one frame" if frame
92
+ unless bounds.is_a?(::Range)
93
+ raise ArgumentError, "#{kind} takes a range of rows, as in rows(..0)"
94
+ end
95
+ if bounds.exclude_end?
96
+ raise ArgumentError, "a frame ends on a row rather than before one; use .."
97
+ end
98
+ [bounds.begin, bounds.end].each do |bound|
99
+ next if bound.nil? || bound.is_a?(::Integer)
100
+ raise ArgumentError,
101
+ "a frame bound is a number of rows, or nothing for unbounded"
102
+ end
103
+ [kind, bounds.begin, bounds.end]
104
+ end
105
+
106
+ # Arel wants the keyword itself on the left of the BETWEEN, which is
107
+ # what window.rows with no argument hands back.
108
+ def frame_arel(window)
109
+ kind, from, to = frame
110
+ window.frame(
111
+ Arel::Nodes::Between.new(
112
+ window.public_send(kind),
113
+ Arel::Nodes::And.new([bound(from, Arel::Nodes::Preceding.new),
114
+ bound(to, Arel::Nodes::Following.new)])))
115
+ end
116
+
117
+ def bound(rows, unbounded)
118
+ return unbounded if rows.nil?
119
+ return Arel::Nodes::CurrentRow.new if rows.zero?
120
+ rows.negative? ? Arel::Nodes::Preceding.new(-rows)
121
+ : Arel::Nodes::Following.new(rows)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end