activerecord-refined 0.3.3 → 0.4.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.
@@ -1,6 +1,20 @@
1
1
  module ActiveRecord
2
2
  module Refined
3
3
  module AST
4
+ # Column aliases and function names are written into the SQL as given,
5
+ # where a value would have been quoted, so anything that is not a plain
6
+ # name is refused. Quoting them instead would need the model's adapter,
7
+ # which does not reach this far, and quoting with the wrong one would be
8
+ # a bug of its own -- MySQL does not read "x" as an identifier.
9
+ NAME = /[[:alpha:]_][[:alnum:]_$]*/
10
+ ALIAS_NAME = /\A#{NAME}\z/
11
+ FUNCTION_NAME = /\A#{NAME}(\.#{NAME})?\z/
12
+
13
+ def self.check_name(name, pattern, what)
14
+ return name if pattern.match?(name.to_s)
15
+ raise ArgumentError, "#{name.inspect} is not a plain #{what}"
16
+ end
17
+
4
18
  # Predicate builders shared by symbols, qualified columns and
5
19
  # expressions. Imported into the Symbol refinement with
6
20
  # Refinement#import_methods, so every method must be defined with def.
@@ -62,6 +76,30 @@ module ActiveRecord
62
76
  Like.new(self, pattern)
63
77
  end
64
78
 
79
+ def ilike?(pattern)
80
+ Like.new(self, pattern, nil, case_sensitive: false)
81
+ end
82
+
83
+ # Case-insensitive equality, folded on both sides rather than left to
84
+ # the collation, so it means the same thing on every adapter.
85
+ def casecmp?(value)
86
+ if value.nil?
87
+ raise ArgumentError, "casecmp? does not take nil; use null? instead"
88
+ end
89
+ Comparison.new(Function.new("LOWER", [self]), :==,
90
+ Function.new("LOWER", [value]))
91
+ end
92
+
93
+ # Null-safe comparison: unlike = and <>, these treat NULL as a value,
94
+ # so not_distinct_from? is the one equality that may take nil.
95
+ def distinct_from?(value)
96
+ DistinctFrom.new(self, value, negated: true)
97
+ end
98
+
99
+ def not_distinct_from?(value)
100
+ DistinctFrom.new(self, value)
101
+ end
102
+
65
103
  def start_with?(*prefixes)
66
104
  if prefixes.empty?
67
105
  raise ArgumentError, "start_with? needs at least one prefix"
@@ -80,8 +118,75 @@ module ActiveRecord
80
118
  Like.new(self, "%#{Like.escape(substring)}%", Like::ESCAPE)
81
119
  end
82
120
 
121
+ # The array comparisons carry the meaning of their Ruby namesakes.
122
+ # member? is Enumerable's element test, so an Array argument is
123
+ # rejected rather than quietly meaning something Array#member? does
124
+ # not; whole-array comparisons go by the Set and Array names.
83
125
  def member?(element)
84
- Member.new(self, element)
126
+ if element.is_a?(::Array) || element.is_a?(::Set)
127
+ raise ArgumentError,
128
+ "member? takes a single element; use superset? to require every element"
129
+ end
130
+ ArrayPredicate.new(self, :"@>", [element])
131
+ end
132
+
133
+ def superset?(elements)
134
+ ArrayPredicate.new(self, :"@>", ArrayPredicate.elements(elements, "superset?"))
135
+ end
136
+
137
+ def subset?(elements)
138
+ ArrayPredicate.new(self, :"<@", ArrayPredicate.elements(elements, "subset?"))
139
+ end
140
+
141
+ def intersect?(elements)
142
+ ArrayPredicate.new(self, :"&&", ArrayPredicate.elements(elements, "intersect?"))
143
+ end
144
+ end
145
+
146
+ # Arithmetic builders shared by symbols, qualified columns and
147
+ # expressions. Imported into the Symbol refinement like Predications,
148
+ # so every method must be defined with def.
149
+ module Arithmetics
150
+ def +(other)
151
+ Arithmetic.new(self, :+, other)
152
+ end
153
+
154
+ def -(other)
155
+ Arithmetic.new(self, :-, other)
156
+ end
157
+
158
+ def *(other)
159
+ Arithmetic.new(self, :*, other)
160
+ end
161
+
162
+ def /(other)
163
+ Arithmetic.new(self, :/, other)
164
+ end
165
+ end
166
+
167
+ # Aggregate builders shared by symbols, qualified columns and
168
+ # expressions. Imported into the Symbol refinement like Predications,
169
+ # so every method must be defined with def.
170
+ module Aggregations
171
+ # DISTINCT is Arel's only aggregate modifier, and only for count.
172
+ def count(distinct: false)
173
+ Aggregate.new(self, :count, distinct: distinct)
174
+ end
175
+
176
+ def sum
177
+ Aggregate.new(self, :sum)
178
+ end
179
+
180
+ def average
181
+ Aggregate.new(self, :average)
182
+ end
183
+
184
+ def maximum
185
+ Aggregate.new(self, :maximum)
186
+ end
187
+
188
+ def minimum
189
+ Aggregate.new(self, :minimum)
85
190
  end
86
191
  end
87
192
 
@@ -131,6 +236,8 @@ module ActiveRecord
131
236
 
132
237
  class Column < Node
133
238
  include Predications
239
+ include Arithmetics
240
+ include Aggregations
134
241
 
135
242
  attr_reader :table_name, :column_name
136
243
 
@@ -142,24 +249,55 @@ module ActiveRecord
142
249
  def to_arel(_table)
143
250
  Arel::Table.new(table_name)[column_name]
144
251
  end
252
+ end
145
253
 
146
- %i[count sum average maximum minimum].each do |func|
147
- define_method(func) { Aggregate.new(self, func) }
254
+ # Arithmetic on columns and expressions. Ruby's precedence puts these
255
+ # above the comparison operators, so :price * :quantity > 100 groups the
256
+ # way it reads.
257
+ class Arithmetic < Node
258
+ include Predications
259
+ include Arithmetics
260
+ include Aggregations
261
+
262
+ attr_reader :left, :operator, :right
263
+
264
+ def initialize(left, operator, right)
265
+ @left = left
266
+ @operator = operator
267
+ @right = right
268
+ end
269
+
270
+ def to_arel(table)
271
+ to_arel_operand(left, table).
272
+ public_send(operator, to_arel_operand(right, table))
148
273
  end
149
274
  end
150
275
 
151
276
  class Aggregate < Node
152
277
  include Predications
278
+ include Arithmetics
153
279
 
154
- attr_reader :operand, :function
280
+ attr_reader :operand, :function, :distinct
155
281
 
156
- def initialize(operand, function)
282
+ def initialize(operand, function, distinct: false)
283
+ if distinct && function != :count
284
+ raise ArgumentError, "#{function} does not take distinct"
285
+ end
286
+ if distinct && operand == :*
287
+ raise ArgumentError, "count(:*) does not take distinct; name a column"
288
+ end
157
289
  @operand = operand
158
290
  @function = function
291
+ @distinct = distinct
159
292
  end
160
293
 
161
294
  def to_arel(table)
162
- to_arel_operand(operand, table).public_send(function)
295
+ arel_operand = to_arel_operand(operand, table)
296
+ if function == :count
297
+ arel_operand.count(distinct)
298
+ else
299
+ arel_operand.public_send(function)
300
+ end
163
301
  end
164
302
  end
165
303
 
@@ -168,7 +306,7 @@ module ActiveRecord
168
306
 
169
307
  def initialize(operand, alias_name)
170
308
  @operand = operand
171
- @alias_name = alias_name
309
+ @alias_name = AST.check_name(alias_name, ALIAS_NAME, "column alias")
172
310
  end
173
311
 
174
312
  def to_arel(table)
@@ -177,20 +315,33 @@ module ActiveRecord
177
315
  end
178
316
 
179
317
  class Ordering < Node
180
- attr_reader :operand, :direction
318
+ attr_reader :operand, :direction, :nulls
181
319
 
182
- def initialize(operand, direction)
320
+ def initialize(operand, direction, nulls = nil)
183
321
  @operand = operand
184
322
  @direction = direction
323
+ @nulls = nulls
324
+ end
325
+
326
+ # MySQL has no NULLS FIRST/LAST, but Arel emulates it there with a
327
+ # leading IS NULL ordering, so these are portable.
328
+ def nulls_first
329
+ Ordering.new(operand, direction, :nulls_first)
330
+ end
331
+
332
+ def nulls_last
333
+ Ordering.new(operand, direction, :nulls_last)
185
334
  end
186
335
 
187
336
  def to_arel(table)
188
- to_arel_operand(operand, table).public_send(direction)
337
+ ordering = to_arel_operand(operand, table).public_send(direction)
338
+ nulls ? ordering.public_send(nulls) : ordering
189
339
  end
190
340
  end
191
341
 
192
342
  class Function < Node
193
343
  include Predications
344
+ include Arithmetics
194
345
 
195
346
  attr_reader :name, :args
196
347
 
@@ -229,9 +380,27 @@ module ActiveRecord
229
380
 
230
381
  def to_arel(table)
231
382
  arel_column = to_arel_operand(column, table)
232
- arel_value = value.is_a?(Node) ? value.to_arel(table) : value
383
+ arel_value =
384
+ case value
385
+ when Node then value.to_arel(table)
386
+ when ActiveRecord::Relation then scalar_subquery(value)
387
+ else value
388
+ end
233
389
  arel_column.public_send(OPERATOR_MAP.fetch(operator), arel_value)
234
390
  end
391
+
392
+ private
393
+
394
+ # A relation compared against a column has to yield a single value, so
395
+ # unlike In there is no sensible default select list to fall back on.
396
+ def scalar_subquery(relation)
397
+ if relation.select_values.empty?
398
+ raise ArgumentError,
399
+ "#{operator} needs a subquery selecting one value; add a select"
400
+ end
401
+ relation = relation.send(:apply_join_dependency) if relation.eager_loading?
402
+ relation.arel
403
+ end
235
404
  end
236
405
 
237
406
  # IN for a list of values, BETWEEN for a range, IN (SELECT ...) for a
@@ -310,38 +479,80 @@ module ActiveRecord
310
479
  inject {|left, right| Or.new(left, right) }
311
480
  end
312
481
 
313
- attr_reader :operand, :pattern, :escape
482
+ attr_reader :operand, :pattern, :escape, :case_sensitive
314
483
 
315
- def initialize(operand, pattern, escape = nil)
484
+ def initialize(operand, pattern, escape = nil, case_sensitive: true)
316
485
  @operand = operand
317
486
  @pattern = pattern
318
487
  @escape = escape
488
+ @case_sensitive = case_sensitive
489
+ end
490
+
491
+ def to_arel(table)
492
+ # Arel matches case-insensitively unless told otherwise, which is
493
+ # what picks ILIKE over LIKE on PostgreSQL.
494
+ to_arel_operand(operand, table).matches(pattern, escape, case_sensitive)
495
+ end
496
+ end
497
+
498
+ # IS [NOT] DISTINCT FROM, spelled IS / IS NOT on SQLite and <=> on
499
+ # MySQL. NULL compares as a value here, which is what separates these
500
+ # from = and <>.
501
+ class DistinctFrom < Predicate
502
+ attr_reader :operand, :value, :negated
503
+
504
+ def initialize(operand, value, negated: false)
505
+ @operand = operand
506
+ @value = value
507
+ @negated = negated
319
508
  end
320
509
 
321
510
  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)
511
+ arel_operand = to_arel_operand(operand, table)
512
+ arel_value = value.is_a?(Node) ? value.to_arel(table) : value
513
+ if negated
514
+ arel_operand.is_distinct_from(arel_value)
515
+ else
516
+ arel_operand.is_not_distinct_from(arel_value)
517
+ end
325
518
  end
326
519
  end
327
520
 
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
521
+ # Comparisons against a PostgreSQL array column, named after the Ruby
522
+ # methods that mean the same thing: member? is Enumerable's element
523
+ # test, superset? and subset? are Set's whole-array containment, and
524
+ # intersect? is Array's "any element in common". Each name maps to one
525
+ # operator; the elements are rendered as an array literal, which
526
+ # PostgreSQL coerces to the column's element type, so any expression
527
+ # works as the operand and no schema lookup is needed.
528
+ class ArrayPredicate < Predicate
529
+ # The whole-array comparisons take the collection kinds their
530
+ # namesakes compare against: an Array, or a Set for the Set methods.
531
+ def self.elements(arg, method_name)
532
+ case arg
533
+ when ::Array then arg
534
+ when ::Set then arg.to_a
535
+ else
536
+ raise ArgumentError, "#{method_name} takes an Array or Set of elements"
537
+ end
538
+ end
539
+
540
+ attr_reader :operand, :operator, :elements
336
541
 
337
- def initialize(operand, element)
542
+ def initialize(operand, operator, elements)
338
543
  @operand = operand
339
- @elements = element.is_a?(::Array) ? element : [element]
544
+ @operator = operator
545
+ @elements = elements
340
546
  end
341
547
 
342
548
  def to_arel(table)
343
549
  arel_operand = to_arel_operand(operand, table)
344
- arel_operand.contains(Arel::Nodes.build_quoted(array_literal))
550
+ quoted = Arel::Nodes.build_quoted(array_literal)
551
+ case operator
552
+ when :"@>" then Arel::Nodes::Contains.new(arel_operand, quoted)
553
+ when :"&&" then Arel::Nodes::Overlaps.new(arel_operand, quoted)
554
+ else Arel::Nodes::InfixOperation.new(operator, arel_operand, quoted)
555
+ end
345
556
  end
346
557
 
347
558
  private
@@ -3,10 +3,8 @@ module ActiveRecord
3
3
  module BlockSyntax
4
4
  refine Symbol do
5
5
  import_methods AST::Predications
6
-
7
- %i[count sum average maximum minimum].each do |func|
8
- define_method(func) { AST::Aggregate.new(self, func) }
9
- end
6
+ import_methods AST::Arithmetics
7
+ import_methods AST::Aggregations
10
8
 
11
9
  def as(alias_name)
12
10
  AST::As.new(self, alias_name)
@@ -27,23 +25,96 @@ module ActiveRecord
27
25
  end
28
26
 
29
27
  class BlockContext
28
+ # The model is only consulted to learn which adapter the query is being
29
+ # built for, which is what decides how a scalar function is spelled.
30
+ def initialize(model)
31
+ @model = model
32
+ end
33
+
30
34
  AGGREGATE_FUNCTIONS = {
31
- count: :count, sum: :sum, avg: :average, min: :minimum, max: :maximum,
35
+ sum: :sum, avg: :average, min: :minimum, max: :maximum,
32
36
  }.freeze
33
37
 
34
38
  AGGREGATE_FUNCTIONS.each do |name, arel_func|
35
39
  define_method(name) {|column| AST::Aggregate.new(column, arel_func) }
36
40
  end
37
41
 
38
- SCALAR_FUNCTIONS = %i[upper lower length trim coalesce abs round].freeze
42
+ def count(column, distinct: false)
43
+ AST::Aggregate.new(column, :count, distinct: distinct)
44
+ end
45
+
46
+ # Scalar functions, defined as real methods so that a typo is a
47
+ # NoMethodError and a name Kernel also answers to (format, hash, test)
48
+ # cannot quietly mean something else.
49
+ #
50
+ # The value lists the adapters that differ: a string is what the
51
+ # function is called there, nil says the adapter has no equivalent. An
52
+ # adapter that is not listed spells it like the method. The families
53
+ # are what the entries key on, so trilogy reads the mysql column.
54
+ #
55
+ # Availability was checked by calling each one; the SQLite figures
56
+ # assume the math functions its build usually enables.
57
+ SCALAR_FUNCTIONS = {
58
+ abs: {}, ceil: {}, coalesce: {}, concat: {}, exp: {}, floor: {},
59
+ length: {}, ln: {}, log: {}, lower: {}, ltrim: {}, mod: {},
60
+ nullif: {}, power: {}, replace: {}, round: {}, rtrim: {}, sqrt: {},
61
+ substr: {}, trim: {}, upper: {},
62
+ char_length: {sqlite: 'LENGTH'},
63
+ greatest: {sqlite: 'MAX'},
64
+ least: {sqlite: 'MIN'},
65
+ now: {sqlite: nil},
66
+ date_trunc: {sqlite: nil, mysql: nil},
67
+ # Named for Kernel#rand, which it also takes back: a block calling
68
+ # rand would otherwise get Ruby's and never reach the database.
69
+ rand: {sqlite: 'RANDOM', postgresql: 'RANDOM'},
70
+ # Two different functions share this name: printf formatting here, and
71
+ # on MySQL the one that puts separators in a number, which reads a
72
+ # printf template as the number zero rather than complaining. The
73
+ # name keeps the one meaning; fn(:format, ...) reaches MySQL's.
74
+ format: {mysql: nil},
75
+ }.freeze
76
+
77
+ ADAPTER_FAMILIES = {
78
+ 'sqlite3' => :sqlite,
79
+ 'postgresql' => :postgresql,
80
+ 'postgis' => :postgresql,
81
+ 'mysql2' => :mysql,
82
+ 'trilogy' => :mysql,
83
+ }.freeze
39
84
 
40
- SCALAR_FUNCTIONS.each do |name|
41
- define_method(name) {|*args| AST::Function.new(name.to_s.upcase, args) }
85
+ SCALAR_FUNCTIONS.each_key do |name|
86
+ define_method(name) {|*args| AST::Function.new(function_name(name), args) }
87
+ end
88
+
89
+ # Escape hatch for functions without a method of their own. The name is
90
+ # emitted as written, so a case-sensitive one can be spelled exactly,
91
+ # and for that reason it has to be a plain name, optionally qualified by
92
+ # a schema; anything else is refused rather than written into the SQL.
93
+ def fn(name, *args)
94
+ AST::Function.new(
95
+ AST.check_name(name, AST::FUNCTION_NAME, "function name").to_s, args)
42
96
  end
43
97
 
44
98
  def exists?(relation)
45
99
  AST::Exists.new(relation)
46
100
  end
101
+
102
+ private
103
+
104
+ def function_name(name)
105
+ spellings = SCALAR_FUNCTIONS.fetch(name)
106
+ return name.to_s.upcase unless spellings.key?(adapter_family)
107
+ spellings.fetch(adapter_family) ||
108
+ raise(NotImplementedError,
109
+ "#{name} has no equivalent on #{@model.connection_db_config.adapter}")
110
+ end
111
+
112
+ # An adapter nobody has classified keeps the standard spellings, and is
113
+ # left to say for itself what it cannot do.
114
+ def adapter_family
115
+ @adapter_family ||=
116
+ ADAPTER_FAMILIES[@model.connection_db_config.adapter] || :unknown
117
+ end
47
118
  end
48
119
 
49
120
  module QueryMethods
@@ -93,19 +164,39 @@ module ActiveRecord
93
164
  end
94
165
  end
95
166
 
96
- def joins(*args, &block)
167
+ # A symbol names a table, which ActiveRecord's own from only takes as a
168
+ # string. With `as` it is selected under another name, which is how a
169
+ # CTE stands in for the model's own table:
170
+ # with_recursive(tree: [...]).from(:tree, as: :nodes)
171
+ def from(value, subquery_name = nil, as: nil)
172
+ unless value.is_a?(Symbol)
173
+ if as
174
+ raise ArgumentError, "as: needs the table named as a symbol"
175
+ end
176
+ return super(value, subquery_name)
177
+ end
178
+ arel_table = Arel::Table.new(value)
179
+ arel_table = arel_table.alias(as) if as
180
+ super(arel_table, subquery_name)
181
+ end
182
+
183
+ # `as` names the table within the query, which is what makes a self
184
+ # join expressible: joins(:employees, as: :managers) { ... }.
185
+ def joins(*args, as: nil, &block)
97
186
  if block
98
- super(build_join_node(args.first, Arel::Nodes::InnerJoin, &block))
187
+ super(build_join_node(args.first, Arel::Nodes::InnerJoin, as, &block))
99
188
  else
100
- super
189
+ reject_join_alias(as)
190
+ super(*args, &block)
101
191
  end
102
192
  end
103
193
 
104
- def left_outer_joins(*args, &block)
194
+ def left_outer_joins(*args, as: nil, &block)
105
195
  if block
106
- joins(build_join_node(args.first, Arel::Nodes::OuterJoin, &block))
196
+ joins(build_join_node(args.first, Arel::Nodes::OuterJoin, as, &block))
107
197
  else
108
- super
198
+ reject_join_alias(as)
199
+ super(*args, &block)
109
200
  end
110
201
  end
111
202
 
@@ -113,7 +204,7 @@ module ActiveRecord
113
204
 
114
205
  def evaluate_block(&block)
115
206
  refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
116
- BlockContext.new.instance_exec(&refined_block)
207
+ BlockContext.new(klass).instance_exec(&refined_block)
117
208
  end
118
209
 
119
210
  def to_arel_field(node)
@@ -124,12 +215,16 @@ module ActiveRecord
124
215
  end
125
216
  end
126
217
 
127
- def build_join_node(target_table, join_class, &block)
218
+ def reject_join_alias(alias_name)
219
+ return unless alias_name
220
+ raise ArgumentError, "as: needs a block to write the ON clause with"
221
+ end
222
+
223
+ def build_join_node(target_table, join_class, alias_name, &block)
128
224
  ast = evaluate_block(&block)
129
- join_class.new(
130
- Arel::Table.new(target_table),
131
- Arel::Nodes::On.new(ast.to_arel(table))
132
- )
225
+ arel_table = Arel::Table.new(target_table)
226
+ arel_table = arel_table.alias(alias_name) if alias_name
227
+ join_class.new(arel_table, Arel::Nodes::On.new(ast.to_arel(table)))
133
228
  end
134
229
  end
135
230
  end
@@ -1,5 +1,5 @@
1
1
  module Activerecord
2
2
  module Refined
3
- VERSION = '0.3.3'
3
+ VERSION = '0.4.0'
4
4
  end
5
5
  end