activerecord-refined 0.3.1 → 0.3.3
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/.github/workflows/push_gem.yml +45 -0
- data/.github/workflows/test.yml +23 -0
- data/.gitignore +2 -0
- data/Gemfile +8 -0
- data/README.md +141 -8
- data/Rakefile +17 -0
- data/examples/aggregations.rb +91 -0
- data/examples/complex_joins.rb +69 -0
- data/lib/active_record/refined/ast.rb +275 -54
- data/lib/active_record/refined.rb +5 -7
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +370 -72
- data/test/test_helper.rb +94 -3
- metadata +5 -1
|
@@ -1,6 +1,90 @@
|
|
|
1
1
|
module ActiveRecord
|
|
2
2
|
module Refined
|
|
3
3
|
module AST
|
|
4
|
+
# Predicate builders shared by symbols, qualified columns and
|
|
5
|
+
# expressions. Imported into the Symbol refinement with
|
|
6
|
+
# Refinement#import_methods, so every method must be defined with def.
|
|
7
|
+
module Predications
|
|
8
|
+
# == and != mean SQL = and <>, and = NULL is never true there, so nil
|
|
9
|
+
# is rejected rather than silently rewritten to IS NULL. null? builds
|
|
10
|
+
# its node directly and stays clear of this check.
|
|
11
|
+
def ==(other)
|
|
12
|
+
if other.nil?
|
|
13
|
+
raise ArgumentError, "== does not take nil; use null? instead"
|
|
14
|
+
end
|
|
15
|
+
Comparison.new(self, :==, other)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def !=(other)
|
|
19
|
+
if other.nil?
|
|
20
|
+
raise ArgumentError, "!= does not take nil; use !null? instead"
|
|
21
|
+
end
|
|
22
|
+
Comparison.new(self, :!=, other)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def >(other)
|
|
26
|
+
Comparison.new(self, :>, other)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def >=(other)
|
|
30
|
+
Comparison.new(self, :>=, other)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def <(other)
|
|
34
|
+
Comparison.new(self, :<, other)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def <=(other)
|
|
38
|
+
Comparison.new(self, :<=, other)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def =~(pattern)
|
|
42
|
+
Match.new(self, pattern)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def !~(pattern)
|
|
46
|
+
Match.new(self, pattern, negated: true)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def null?
|
|
50
|
+
Comparison.new(self, :==, nil)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def in?(values)
|
|
54
|
+
In.new(self, values)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def between?(min, max)
|
|
58
|
+
In.new(self, min..max)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def like?(pattern)
|
|
62
|
+
Like.new(self, pattern)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def start_with?(*prefixes)
|
|
66
|
+
if prefixes.empty?
|
|
67
|
+
raise ArgumentError, "start_with? needs at least one prefix"
|
|
68
|
+
end
|
|
69
|
+
Like.any(self, prefixes.map {|prefix| "#{Like.escape(prefix)}%" })
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def end_with?(*suffixes)
|
|
73
|
+
if suffixes.empty?
|
|
74
|
+
raise ArgumentError, "end_with? needs at least one suffix"
|
|
75
|
+
end
|
|
76
|
+
Like.any(self, suffixes.map {|suffix| "%#{Like.escape(suffix)}" })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def include?(substring)
|
|
80
|
+
Like.new(self, "%#{Like.escape(substring)}%", Like::ESCAPE)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def member?(element)
|
|
84
|
+
Member.new(self, element)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
4
88
|
class Node
|
|
5
89
|
def to_arel(table)
|
|
6
90
|
raise ScriptError, "subclass must override this method"
|
|
@@ -17,6 +101,18 @@ module ActiveRecord
|
|
|
17
101
|
def desc
|
|
18
102
|
Ordering.new(self, :desc)
|
|
19
103
|
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Resolves an operand denoting a column or an expression.
|
|
108
|
+
def to_arel_operand(operand, table)
|
|
109
|
+
case operand
|
|
110
|
+
when Node then operand.to_arel(table)
|
|
111
|
+
when :* then Arel.star
|
|
112
|
+
when Symbol then table[operand]
|
|
113
|
+
else operand
|
|
114
|
+
end
|
|
115
|
+
end
|
|
20
116
|
end
|
|
21
117
|
|
|
22
118
|
class Predicate < Node
|
|
@@ -34,6 +130,8 @@ module ActiveRecord
|
|
|
34
130
|
end
|
|
35
131
|
|
|
36
132
|
class Column < Node
|
|
133
|
+
include Predications
|
|
134
|
+
|
|
37
135
|
attr_reader :table_name, :column_name
|
|
38
136
|
|
|
39
137
|
def initialize(table_name, column_name)
|
|
@@ -45,20 +143,14 @@ module ActiveRecord
|
|
|
45
143
|
Arel::Table.new(table_name)[column_name]
|
|
46
144
|
end
|
|
47
145
|
|
|
48
|
-
%i[== != =~ !~ > >= < <=].each do |op|
|
|
49
|
-
define_method(op) {|val| Comparison.new(self, op, val) }
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def null?
|
|
53
|
-
Comparison.new(self, :==, nil)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
146
|
%i[count sum average maximum minimum].each do |func|
|
|
57
147
|
define_method(func) { Aggregate.new(self, func) }
|
|
58
148
|
end
|
|
59
149
|
end
|
|
60
150
|
|
|
61
151
|
class Aggregate < Node
|
|
152
|
+
include Predications
|
|
153
|
+
|
|
62
154
|
attr_reader :operand, :function
|
|
63
155
|
|
|
64
156
|
def initialize(operand, function)
|
|
@@ -67,15 +159,7 @@ module ActiveRecord
|
|
|
67
159
|
end
|
|
68
160
|
|
|
69
161
|
def to_arel(table)
|
|
70
|
-
|
|
71
|
-
when Node then operand.to_arel(table)
|
|
72
|
-
else table[operand]
|
|
73
|
-
end
|
|
74
|
-
arel_operand.public_send(function)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
%i[== != =~ !~ > >= < <=].each do |op|
|
|
78
|
-
define_method(op) {|val| Comparison.new(self, op, val) }
|
|
162
|
+
to_arel_operand(operand, table).public_send(function)
|
|
79
163
|
end
|
|
80
164
|
end
|
|
81
165
|
|
|
@@ -88,12 +172,7 @@ module ActiveRecord
|
|
|
88
172
|
end
|
|
89
173
|
|
|
90
174
|
def to_arel(table)
|
|
91
|
-
|
|
92
|
-
when Node then operand.to_arel(table)
|
|
93
|
-
when Symbol then table[operand]
|
|
94
|
-
else operand
|
|
95
|
-
end
|
|
96
|
-
arel_operand.as(alias_name.to_s)
|
|
175
|
+
to_arel_operand(operand, table).as(alias_name.to_s)
|
|
97
176
|
end
|
|
98
177
|
end
|
|
99
178
|
|
|
@@ -106,16 +185,13 @@ module ActiveRecord
|
|
|
106
185
|
end
|
|
107
186
|
|
|
108
187
|
def to_arel(table)
|
|
109
|
-
|
|
110
|
-
when Node then operand.to_arel(table)
|
|
111
|
-
when Symbol then table[operand]
|
|
112
|
-
else operand
|
|
113
|
-
end
|
|
114
|
-
arel_operand.public_send(direction)
|
|
188
|
+
to_arel_operand(operand, table).public_send(direction)
|
|
115
189
|
end
|
|
116
190
|
end
|
|
117
191
|
|
|
118
192
|
class Function < Node
|
|
193
|
+
include Predications
|
|
194
|
+
|
|
119
195
|
attr_reader :name, :args
|
|
120
196
|
|
|
121
197
|
def initialize(name, args)
|
|
@@ -126,22 +202,20 @@ module ActiveRecord
|
|
|
126
202
|
def to_arel(table)
|
|
127
203
|
arel_args = args.map do |arg|
|
|
128
204
|
case arg
|
|
129
|
-
when Node then arg
|
|
130
|
-
when Symbol then table[arg]
|
|
205
|
+
when Node, Symbol then to_arel_operand(arg, table)
|
|
131
206
|
else Arel::Nodes.build_quoted(arg)
|
|
132
207
|
end
|
|
133
208
|
end
|
|
134
209
|
Arel::Nodes::NamedFunction.new(name, arel_args)
|
|
135
210
|
end
|
|
136
|
-
|
|
137
|
-
%i[== != =~ !~ > >= < <=].each do |op|
|
|
138
|
-
define_method(op) {|val| Comparison.new(self, op, val) }
|
|
139
|
-
end
|
|
140
211
|
end
|
|
141
212
|
|
|
213
|
+
# A plain SQL comparison. The value is passed through as it is, so a Range
|
|
214
|
+
# or an Array compares against a PostgreSQL range or array column, the way
|
|
215
|
+
# ActiveRecord's own force_equality? types do.
|
|
142
216
|
class Comparison < Predicate
|
|
143
217
|
OPERATOR_MAP = {
|
|
144
|
-
:== => :eq, :!= => :not_eq,
|
|
218
|
+
:== => :eq, :!= => :not_eq,
|
|
145
219
|
:> => :gt, :>= => :gteq, :< => :lt, :<= => :lteq
|
|
146
220
|
}.freeze
|
|
147
221
|
|
|
@@ -154,26 +228,173 @@ module ActiveRecord
|
|
|
154
228
|
end
|
|
155
229
|
|
|
156
230
|
def to_arel(table)
|
|
157
|
-
arel_column =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
231
|
+
arel_column = to_arel_operand(column, table)
|
|
232
|
+
arel_value = value.is_a?(Node) ? value.to_arel(table) : value
|
|
233
|
+
arel_column.public_send(OPERATOR_MAP.fetch(operator), arel_value)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# IN for a list of values, BETWEEN for a range, IN (SELECT ...) for a
|
|
238
|
+
# relation.
|
|
239
|
+
class In < Predicate
|
|
240
|
+
attr_reader :operand, :values
|
|
241
|
+
|
|
242
|
+
def initialize(operand, values)
|
|
243
|
+
@operand = operand
|
|
244
|
+
@values = values
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def to_arel(table)
|
|
248
|
+
arel_operand = to_arel_operand(operand, table)
|
|
249
|
+
case values
|
|
250
|
+
when Range then arel_operand.between(values)
|
|
251
|
+
when ActiveRecord::Relation then arel_operand.in(subquery(values))
|
|
252
|
+
else arel_operand.in(values)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private
|
|
257
|
+
|
|
258
|
+
# The same treatment ActiveRecord's own RelationHandler gives a
|
|
259
|
+
# relation used as a value: without an explicit select list the
|
|
260
|
+
# subquery selects the model's primary key.
|
|
261
|
+
def subquery(relation)
|
|
262
|
+
if relation.eager_loading?
|
|
263
|
+
relation = relation.send(:apply_join_dependency)
|
|
264
|
+
end
|
|
265
|
+
if relation.select_values.empty?
|
|
266
|
+
model = relation.model
|
|
267
|
+
if model.composite_primary_key?
|
|
268
|
+
raise ArgumentError,
|
|
269
|
+
"Cannot map composite primary key #{model.primary_key} to IN"
|
|
270
|
+
end
|
|
271
|
+
relation = relation.select(relation.table[model.primary_key])
|
|
272
|
+
end
|
|
273
|
+
relation.arel
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# EXISTS (SELECT ...) for a relation. Correlate the subquery with the
|
|
278
|
+
# outer table through qualified columns. EXISTS only asks whether a row
|
|
279
|
+
# comes back, so unlike In there is no select list to fix up.
|
|
280
|
+
class Exists < Predicate
|
|
281
|
+
attr_reader :relation
|
|
282
|
+
|
|
283
|
+
def initialize(relation)
|
|
284
|
+
@relation = relation
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def to_arel(_table)
|
|
288
|
+
subquery = relation
|
|
289
|
+
if subquery.eager_loading?
|
|
290
|
+
subquery = subquery.send(:apply_join_dependency)
|
|
291
|
+
end
|
|
292
|
+
subquery.arel.exists
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
class Like < Predicate
|
|
297
|
+
ESCAPE = "\\".freeze
|
|
298
|
+
|
|
299
|
+
# Escapes % and _ so that they match literally. The pattern built from
|
|
300
|
+
# the result must be used with ESCAPE, since SQLite has no default
|
|
301
|
+
# escape character.
|
|
302
|
+
def self.escape(string)
|
|
303
|
+
ActiveRecord::Base.sanitize_sql_like(string, ESCAPE)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# ORs one LIKE per pattern, for the shortcuts that accept several
|
|
307
|
+
# literals the way String#start_with? does.
|
|
308
|
+
def self.any(operand, patterns)
|
|
309
|
+
patterns.map {|pattern| new(operand, pattern, ESCAPE) }.
|
|
310
|
+
inject {|left, right| Or.new(left, right) }
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
attr_reader :operand, :pattern, :escape
|
|
314
|
+
|
|
315
|
+
def initialize(operand, pattern, escape = nil)
|
|
316
|
+
@operand = operand
|
|
317
|
+
@pattern = pattern
|
|
318
|
+
@escape = escape
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def to_arel(table)
|
|
322
|
+
# Arel matches case-insensitively unless told otherwise, which turns
|
|
323
|
+
# into ILIKE on PostgreSQL. like? means SQL LIKE on every adapter.
|
|
324
|
+
to_arel_operand(operand, table).matches(pattern, escape, true)
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Containment in a PostgreSQL array column. The two flavors of "does it
|
|
329
|
+
# contain this?" split by name the way Ruby's own classes do: include? is
|
|
330
|
+
# String's substring match (LIKE), member? is Enumerable's element test,
|
|
331
|
+
# which String does not have. The elements are rendered as an array
|
|
332
|
+
# literal, which PostgreSQL coerces to the column's element type, so any
|
|
333
|
+
# expression works as the operand and no schema lookup is needed.
|
|
334
|
+
class Member < Predicate
|
|
335
|
+
attr_reader :operand, :elements
|
|
336
|
+
|
|
337
|
+
def initialize(operand, element)
|
|
338
|
+
@operand = operand
|
|
339
|
+
@elements = element.is_a?(::Array) ? element : [element]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def to_arel(table)
|
|
343
|
+
arel_operand = to_arel_operand(operand, table)
|
|
344
|
+
arel_operand.contains(Arel::Nodes.build_quoted(array_literal))
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
private
|
|
348
|
+
|
|
349
|
+
# PostgreSQL array input syntax: elements joined by commas inside
|
|
350
|
+
# braces, and an element is double-quoted whenever it is empty, spells
|
|
351
|
+
# NULL, or contains a character the parser treats specially.
|
|
352
|
+
def array_literal
|
|
353
|
+
encoded = elements.map do |value|
|
|
354
|
+
s = value.to_s
|
|
355
|
+
if s.empty? || s.casecmp?("null") || s.match?(/[\s{},"\\]/)
|
|
356
|
+
"\"#{s.gsub(/["\\]/) {|c| "\\#{c}" }}\""
|
|
357
|
+
else
|
|
358
|
+
s
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
"{#{encoded.join(',')}}"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Regular expression match: REGEXP on MySQL, ~ on PostgreSQL. SQLite has
|
|
366
|
+
# no regexp operator built in, so Arel raises NotImplementedError there.
|
|
367
|
+
class Match < Predicate
|
|
368
|
+
attr_reader :operand, :pattern, :negated
|
|
369
|
+
|
|
370
|
+
def initialize(operand, pattern, negated: false)
|
|
371
|
+
@operand = operand
|
|
372
|
+
@pattern = pattern.is_a?(Regexp) ? regexp_source(pattern) : pattern
|
|
373
|
+
@negated = negated
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def to_arel(table)
|
|
377
|
+
arel_operand = to_arel_operand(operand, table)
|
|
378
|
+
if negated
|
|
379
|
+
arel_operand.does_not_match_regexp(pattern)
|
|
174
380
|
else
|
|
175
|
-
|
|
381
|
+
arel_operand.matches_regexp(pattern)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
private
|
|
386
|
+
|
|
387
|
+
# A Regexp literal reads naturally with =~, but only its source crosses
|
|
388
|
+
# over; the database has its own dialect and no notion of Ruby's flags.
|
|
389
|
+
# Dropping a flag would silently change what the query matches, so
|
|
390
|
+
# anything beyond a plain literal is refused rather than ignored.
|
|
391
|
+
def regexp_source(regexp)
|
|
392
|
+
unless regexp.options.zero?
|
|
393
|
+
raise ArgumentError,
|
|
394
|
+
"#{regexp.inspect} has options that SQL cannot express; " \
|
|
395
|
+
"pass the pattern as a string instead"
|
|
176
396
|
end
|
|
397
|
+
regexp.source
|
|
177
398
|
end
|
|
178
399
|
end
|
|
179
400
|
|
|
@@ -2,13 +2,7 @@ module ActiveRecord
|
|
|
2
2
|
module Refined
|
|
3
3
|
module BlockSyntax
|
|
4
4
|
refine Symbol do
|
|
5
|
-
|
|
6
|
-
define_method(op) {|val| AST::Comparison.new(self, op, val) }
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
def null?
|
|
10
|
-
AST::Comparison.new(self, :==, nil)
|
|
11
|
-
end
|
|
5
|
+
import_methods AST::Predications
|
|
12
6
|
|
|
13
7
|
%i[count sum average maximum minimum].each do |func|
|
|
14
8
|
define_method(func) { AST::Aggregate.new(self, func) }
|
|
@@ -46,6 +40,10 @@ module ActiveRecord
|
|
|
46
40
|
SCALAR_FUNCTIONS.each do |name|
|
|
47
41
|
define_method(name) {|*args| AST::Function.new(name.to_s.upcase, args) }
|
|
48
42
|
end
|
|
43
|
+
|
|
44
|
+
def exists?(relation)
|
|
45
|
+
AST::Exists.new(relation)
|
|
46
|
+
end
|
|
49
47
|
end
|
|
50
48
|
|
|
51
49
|
module QueryMethods
|