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.
- checksums.yaml +4 -4
- data/.yardopts +1 -1
- data/README.md +19 -22
- data/activerecord-refined.gemspec +4 -3
- data/docs/conditions.md +3 -1
- data/docs/expressions.md +10 -7
- data/docs/index.md +1 -1
- data/examples/expressions.rb +8 -8
- data/lib/active_record/refined/ast/arithmetics.rb +137 -0
- data/lib/active_record/refined/ast/conditions.rb +451 -0
- data/lib/active_record/refined/ast/expressions.rb +304 -0
- data/lib/active_record/refined/ast/functions.rb +254 -0
- data/lib/active_record/refined/ast/grouping.rb +63 -0
- data/lib/active_record/refined/ast/json.rb +530 -0
- data/lib/active_record/refined/ast/node.rb +298 -0
- data/lib/active_record/refined/ast/ordering.rb +104 -0
- data/lib/active_record/refined/ast/predications.rb +384 -0
- data/lib/active_record/refined/ast/windows.rb +126 -0
- data/lib/active_record/refined/ast.rb +10 -2610
- data/lib/active_record/refined/block_context.rb +671 -0
- data/lib/active_record/refined/block_syntax.rb +128 -0
- data/lib/active_record/refined/query_methods.rb +444 -0
- data/lib/active_record/refined/version.rb +8 -0
- data/lib/active_record/refined/writes.rb +72 -0
- data/lib/active_record/refined.rb +7 -1307
- data/lib/activerecord-refined.rb +0 -4
- metadata +35 -5
- data/lib/activerecord-refined/version.rb +0 -12
|
@@ -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
|