activerecord-refined 0.5.1 → 0.6.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.
@@ -4,10 +4,9 @@ module ActiveRecord
4
4
  refine Symbol do
5
5
  import_methods AST::Predications
6
6
  import_methods AST::Arithmetics
7
- import_methods AST::Aggregations
8
7
 
9
- def as(alias_name)
10
- AST::As.new(self, alias_name)
8
+ def as(alias_name, quote: true)
9
+ AST::As.new(self, alias_name, quote: quote)
11
10
  end
12
11
 
13
12
  def asc
@@ -29,8 +28,8 @@ module ActiveRecord
29
28
  # depending on whether it had been sent a message.
30
29
  [Integer, Float].each do |klass|
31
30
  refine klass do
32
- def as(alias_name)
33
- AST::As.new(AST::Value.new(self), alias_name)
31
+ def as(alias_name, quote: true)
32
+ AST::As.new(AST::Value.new(self), alias_name, quote: quote)
34
33
  end
35
34
  end
36
35
  end
@@ -82,6 +81,9 @@ module ActiveRecord
82
81
  # default it to zero; SQLite's trunc takes only the one.
83
82
  trunc: {mysql: 'TRUNCATE'},
84
83
  now: {sqlite: nil},
84
+ # The bit aggregates, which PostgreSQL and MySQL spell alike and
85
+ # SQLite has none of. PostgreSQL gained bit_xor in 14.
86
+ bit_and: {sqlite: nil}, bit_or: {sqlite: nil}, bit_xor: {sqlite: nil},
85
87
  date_trunc: {sqlite: nil, mysql: nil},
86
88
  # Named for Kernel#rand, which it also takes back: a block calling
87
89
  # rand would otherwise get Ruby's and never reach the database.
@@ -93,14 +95,6 @@ module ActiveRecord
93
95
  format: {mysql: nil},
94
96
  }.freeze
95
97
 
96
- ADAPTER_FAMILIES = {
97
- 'sqlite3' => :sqlite,
98
- 'postgresql' => :postgresql,
99
- 'postgis' => :postgresql,
100
- 'mysql2' => :mysql,
101
- 'trilogy' => :mysql,
102
- }.freeze
103
-
104
98
  SCALAR_FUNCTIONS.each_key do |name|
105
99
  define_method(name) do |*args|
106
100
  AST::Function.new(function_name(name, SCALAR_FUNCTIONS), args)
@@ -156,6 +150,26 @@ module ActiveRecord
156
150
  node
157
151
  end
158
152
 
153
+ # GROUP BY GROUPING SETS / ROLLUP / CUBE, which PostgreSQL has and the
154
+ # others do not -- MySQL's WITH ROLLUP says one of the three and says it
155
+ # somewhere else in the clause. Arel has the nodes and writes them for
156
+ # PostgreSQL alone, so what it would raise elsewhere says nothing; this
157
+ # says it here, as extract does, while the block is being read.
158
+ #
159
+ # Sale.group { grouping_sets([:region], [:product], []) }
160
+ # Sale.group { rollup(:region, :product) }
161
+ def grouping_sets(*sets)
162
+ grouping(:grouping_sets, sets)
163
+ end
164
+
165
+ def rollup(*columns)
166
+ grouping(:rollup, columns)
167
+ end
168
+
169
+ def cube(*columns)
170
+ grouping(:cube, columns)
171
+ end
172
+
159
173
  # CAST(expr AS type). The type is the adapter's own name for it,
160
174
  # checked for shape by the node; whether it exists is the database's to
161
175
  # say.
@@ -163,6 +177,32 @@ module ActiveRecord
163
177
  AST::Cast.new(expr, type)
164
178
  end
165
179
 
180
+ # The functions that only mean anything with a window. Every adapter
181
+ # that has window functions at all spells these the same -- PostgreSQL,
182
+ # MySQL 8, SQLite 3.25 -- so unlike the scalar functions there is nothing
183
+ # here to translate. Each says so if `over` never arrives.
184
+ %i[row_number rank dense_rank percent_rank cume_dist].each do |name|
185
+ define_method(name) { AST::WindowFunction.new(name.to_s.upcase, []) }
186
+ end
187
+
188
+ %i[ntile first_value last_value].each do |name|
189
+ define_method(name) {|arg| AST::WindowFunction.new(name.to_s.upcase, [arg]) }
190
+ end
191
+
192
+ def nth_value(expr, nth)
193
+ AST::WindowFunction.new('NTH_VALUE', [expr, nth])
194
+ end
195
+
196
+ # The offset is written out rather than left to default, so that a
197
+ # default value cannot end up where the offset belongs.
198
+ def lag(expr, offset = 1, default = nil)
199
+ AST::WindowFunction.new('LAG', default.nil? ? [expr, offset] : [expr, offset, default])
200
+ end
201
+
202
+ def lead(expr, offset = 1, default = nil)
203
+ AST::WindowFunction.new('LEAD', default.nil? ? [expr, offset] : [expr, offset, default])
204
+ end
205
+
166
206
  # Escape hatch for functions without a method of their own. The name is
167
207
  # emitted as written, so a case-sensitive one can be spelled exactly,
168
208
  # and for that reason it has to be a plain name, optionally qualified by
@@ -172,10 +212,41 @@ module ActiveRecord
172
212
  AST.check_name(name, AST::FUNCTION_NAME, "function name").to_s, args)
173
213
  end
174
214
 
215
+ # BIT_COUNT. MySQL counts the bits of a number; PostgreSQL counts those
216
+ # of a bit string, so the argument is cast, and to bit(64) because that
217
+ # is what makes a negative come back as MySQL has it -- 64 bits of two's
218
+ # complement rather than as many as the column happens to be wide.
219
+ def bit_count(expr)
220
+ case adapter_family
221
+ when :mysql then AST::Function.new('BIT_COUNT', [expr])
222
+ when :postgresql
223
+ AST::Function.new('BIT_COUNT', [AST::Cast.new(expr, 'bit(64)')])
224
+ else
225
+ raise NotImplementedError,
226
+ "bit_count has no equivalent on #{@model.connection_db_config.adapter}"
227
+ end
228
+ end
229
+
175
230
  def exists?(relation)
176
231
  AST::Exists.new(relation)
177
232
  end
178
233
 
234
+ # ANY and ALL quantify a comparison over a subquery, which is what a
235
+ # scalar subquery cannot do: it has to return the one row.
236
+ #
237
+ # Post.where { :likes > any(Post.published.select(:likes)) }
238
+ # Post.where { :likes >= all(Post.select(:likes)) }
239
+ #
240
+ # `== any` is IN and `!= all` is NOT IN, so what these add is the four
241
+ # comparisons IN has no spelling for.
242
+ def any(relation)
243
+ quantified('ANY', relation)
244
+ end
245
+
246
+ def all(relation)
247
+ quantified('ALL', relation)
248
+ end
249
+
179
250
  # A literal where an expression is expected, quoted like any other value:
180
251
  #
181
252
  # select { [:id, value(0).as(:depth)] }
@@ -187,8 +258,55 @@ module ActiveRecord
187
258
  AST::Value.new(literal)
188
259
  end
189
260
 
261
+ # The row an upsert could not insert, for the block upsert_all takes.
262
+ # PostgreSQL and SQLite give it a name; MySQL spells the same thing
263
+ # VALUES(column), which takes the column bare.
264
+ def excluded(column)
265
+ return AST::Column.new(:excluded, column) unless adapter_family == :mysql
266
+
267
+ quoted = @model.with_connection {|c| c.quote_column_name(column) }
268
+ AST::Function.new('VALUES', [Arel::Nodes::SqlLiteral.new(quoted)])
269
+ end
270
+
271
+ # CASE. `case` is a keyword, so Ruby only reaches this one through the
272
+ # receiver -- `self.case` -- which is why the two shapes have shorthands
273
+ # that do not need it: `:age.when(...)` for the form with an operand, and
274
+ # `case_when` for the form where each when carries its own condition.
275
+ #
276
+ # self.case(:age).when(10).then(1).else(0)
277
+ # self.case.when { :age >= 60 }.then { :age - 60 }
278
+ def case(operand = nil)
279
+ AST::Case.new(operand)
280
+ end
281
+
282
+ # The searched CASE, started at its first when:
283
+ #
284
+ # case_when { :age >= 60 }.then { :age - 60 }.else(0)
285
+ def case_when(value = nil, &block)
286
+ AST::Case.new.when(value, &block)
287
+ end
288
+
190
289
  private
191
290
 
291
+ # SQLite is the one adapter with no quantifier at all, and what it says
292
+ # when it meets one is a syntax error at the SELECT.
293
+ def quantified(kind, relation)
294
+ if adapter_family == :sqlite
295
+ raise NotImplementedError,
296
+ "#{kind} has no equivalent on #{@model.connection_db_config.adapter}"
297
+ end
298
+ AST::Quantified.new(kind, relation)
299
+ end
300
+
301
+ def grouping(kind, sets)
302
+ node = AST::GroupingSets.new(kind, sets)
303
+ unless adapter_family == :postgresql
304
+ raise NotImplementedError,
305
+ "#{kind} has no equivalent on #{@model.connection_db_config.adapter}"
306
+ end
307
+ node
308
+ end
309
+
192
310
  def function_name(name, functions)
193
311
  spellings = functions.fetch(name)
194
312
  return name.to_s.upcase unless spellings.key?(adapter_family)
@@ -197,18 +315,15 @@ module ActiveRecord
197
315
  "#{name} has no equivalent on #{@model.connection_db_config.adapter}")
198
316
  end
199
317
 
200
- # An adapter nobody has classified keeps the standard spellings, and is
201
- # left to say for itself what it cannot do.
202
318
  def adapter_family
203
- @adapter_family ||=
204
- ADAPTER_FAMILIES[@model.connection_db_config.adapter] || :unknown
319
+ @adapter_family ||= AST.adapter_family(@model)
205
320
  end
206
321
  end
207
322
 
208
323
  module QueryMethods
209
324
  def where(opts = nil, *rest, &block)
210
325
  if block
211
- super(evaluate_block(&block).to_arel(table))
326
+ super(evaluate_block(&block).to_arel(table, klass))
212
327
  else
213
328
  super
214
329
  end
@@ -226,7 +341,7 @@ module ActiveRecord
226
341
 
227
342
  def having(opts = nil, *rest, &block)
228
343
  if block
229
- super(evaluate_block(&block).to_arel(table))
344
+ super(evaluate_block(&block).to_arel(table, klass))
230
345
  else
231
346
  super
232
347
  end
@@ -272,17 +387,71 @@ module ActiveRecord
272
387
  # so the model's is the only name that works -- which is why it is
273
388
  # taken from the model rather than asked for:
274
389
  # with_recursive(tree: [...]).from_cte(:tree)
390
+ #
391
+ # The name is checked against what `with` declares, so that a typo is
392
+ # not a query against a table nobody has. Checked when the SQL is
393
+ # built, since the CTE may be declared after this in the chain, or by a
394
+ # scope merged into it.
275
395
  def from_cte(name)
276
396
  unless name.is_a?(Symbol)
277
397
  raise ArgumentError, "from_cte takes the CTE's name as a symbol"
278
398
  end
279
- from(name, as: klass.table_name)
399
+ relation = from(name, as: klass.table_name)
400
+ relation.from_cte_value = name
401
+ relation
402
+ end
403
+
404
+ def from_cte_value
405
+ @values[:from_cte]
406
+ end
407
+
408
+ def from_cte_value=(name)
409
+ assert_modifiable!
410
+ @values[:from_cte] = name
411
+ end
412
+
413
+ # DISTINCT ON (...), which keeps the first row of each group the order
414
+ # brings up. PostgreSQL has it and the others do not; Arel carries the
415
+ # node and refuses to write it elsewhere, the way it does a regexp, so
416
+ # there is nothing for this to check:
417
+ #
418
+ # Post.distinct_on { :author }.order { [:author, :likes.desc] }
419
+ #
420
+ # The portable shape is a row_number window in a subquery, which the
421
+ # README shows.
422
+ def distinct_on(*columns, &block)
423
+ spawn.distinct_on!(*columns, &block)
424
+ end
425
+
426
+ def distinct_on!(*columns, &block)
427
+ columns = Array(evaluate_block(&block)) if block
428
+ if columns.empty?
429
+ raise ArgumentError, "distinct_on needs a column or an expression"
430
+ end
431
+ self.distinct_on_values += columns
432
+ self
433
+ end
434
+
435
+ # ActiveRecord generates these for the values it knows about; this one
436
+ # is ours, and lives in the same place so that it survives a spawn.
437
+ def distinct_on_values
438
+ @values.fetch(:distinct_on, ActiveRecord::QueryMethods::FROZEN_EMPTY_ARRAY)
439
+ end
440
+
441
+ def distinct_on_values=(columns)
442
+ assert_modifiable!
443
+ @values[:distinct_on] = columns
280
444
  end
281
445
 
282
446
  # `as` names the table within the query, which is what makes a self
283
447
  # join expressible: joins(:employees, as: :managers) { ... }.
284
- def joins(*args, as: nil, &block)
285
- if block
448
+ #
449
+ # `lateral` joins a relation instead of a table, and lets it see the row
450
+ # being joined to -- the top few rows of each group, and the like.
451
+ def joins(*args, as: nil, lateral: false, &block)
452
+ if lateral
453
+ super(build_lateral_join(args.first, Arel::Nodes::InnerJoin, as, &block))
454
+ elsif block
286
455
  super(build_join_node(args.first, Arel::Nodes::InnerJoin, as, &block))
287
456
  else
288
457
  reject_join_alias(as)
@@ -290,8 +459,10 @@ module ActiveRecord
290
459
  end
291
460
  end
292
461
 
293
- def left_outer_joins(*args, as: nil, &block)
294
- if block
462
+ def left_outer_joins(*args, as: nil, lateral: false, &block)
463
+ if lateral
464
+ joins(build_lateral_join(args.first, Arel::Nodes::OuterJoin, as, &block))
465
+ elsif block
295
466
  joins(build_join_node(args.first, Arel::Nodes::OuterJoin, as, &block))
296
467
  else
297
468
  reject_join_alias(as)
@@ -301,6 +472,31 @@ module ActiveRecord
301
472
 
302
473
  private
303
474
 
475
+ def build_arel(...)
476
+ check_from_cte
477
+ arel = super
478
+ unless distinct_on_values.empty?
479
+ arel.distinct_on(distinct_on_values.map {|column| to_arel_field(column) })
480
+ end
481
+ arel
482
+ end
483
+
484
+ # Only when every `with` is one this can read the names out of; anything
485
+ # else and there is nothing to be sure about, so nothing is said.
486
+ def check_from_cte
487
+ name = from_cte_value
488
+ return unless name
489
+ return unless with_values.all? {|value| value.is_a?(::Hash) }
490
+
491
+ declared = with_values.flat_map {|value| value.keys.map(&:to_sym) }
492
+ return if declared.include?(name)
493
+
494
+ raise ArgumentError,
495
+ "from_cte(#{name.inspect}) names no CTE; " +
496
+ (declared.empty? ? "this query declares none" :
497
+ "this query declares #{declared.map(&:inspect).join(', ')}")
498
+ end
499
+
304
500
  def evaluate_block(&block)
305
501
  refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
306
502
  BlockContext.new(klass).instance_exec(&refined_block)
@@ -308,7 +504,7 @@ module ActiveRecord
308
504
 
309
505
  def to_arel_field(node)
310
506
  case node
311
- when AST::Node then node.to_arel(table)
507
+ when AST::Node then node.to_arel(table, klass)
312
508
  when Symbol then table[node]
313
509
  else node
314
510
  end
@@ -319,11 +515,106 @@ module ActiveRecord
319
515
  raise ArgumentError, "as: needs a block to write the ON clause with"
320
516
  end
321
517
 
518
+ # The subquery is written out rather than handed over as a tree: Arel has
519
+ # a LATERAL node but only PostgreSQL's visitor writes it, and MySQL can
520
+ # read what it will not write. Without a block the join is ON TRUE,
521
+ # which is the usual shape -- what the subquery is allowed to see is
522
+ # what makes it lateral, and that is said inside it.
523
+ def build_lateral_join(relation, join_class, alias_name, &block)
524
+ unless relation.is_a?(ActiveRecord::Relation)
525
+ raise ArgumentError, "a lateral join takes a relation to join against"
526
+ end
527
+ unless alias_name
528
+ raise ArgumentError, "a lateral join needs a name: joins(..., as: :top)"
529
+ end
530
+ check_lateral_support
531
+
532
+ aliased = Arel::Nodes::TableAlias.new(
533
+ Arel::Nodes::SqlLiteral.new("LATERAL (#{relation.to_sql})"), alias_name)
534
+ on = block ? evaluate_block(&block).to_arel(table, klass) : Arel::Nodes::True.new
535
+ join_class.new(aliased, Arel::Nodes::On.new(on))
536
+ end
537
+
538
+ # PostgreSQL has LATERAL and so does MySQL, from 8.0.14. SQLite has
539
+ # none, and neither has MariaDB, which answers to the same adapter as
540
+ # MySQL. An adapter nobody has classified is left to say for itself.
541
+ def check_lateral_support
542
+ case AST.adapter_family(klass)
543
+ when :sqlite
544
+ refuse_lateral('sqlite3')
545
+ when :mysql
546
+ refuse_lateral('MariaDB') if klass.with_connection {|c| c.mariadb? }
547
+ end
548
+ end
549
+
550
+ def refuse_lateral(database)
551
+ raise NotImplementedError, "a lateral join has no equivalent on #{database}"
552
+ end
553
+
322
554
  def build_join_node(target_table, join_class, alias_name, &block)
323
555
  ast = evaluate_block(&block)
324
556
  arel_table = Arel::Table.new(target_table)
325
557
  arel_table = arel_table.alias(alias_name) if alias_name
326
- join_class.new(arel_table, Arel::Nodes::On.new(ast.to_arel(table)))
558
+ join_class.new(arel_table, Arel::Nodes::On.new(ast.to_arel(table, klass)))
559
+ end
560
+ end
561
+
562
+ # The writing statements, which live on Relation rather than in
563
+ # QueryMethods. What a block adds here is the one thing their arguments
564
+ # cannot carry: a value worked out from the row rather than given.
565
+ module Writes
566
+ # `update_all(likes: :likes)` sets the column to the symbol; the block
567
+ # reads a symbol as the column it names, as every other block here does,
568
+ # which is what lets the new value be built from the old:
569
+ #
570
+ # Post.where { ... }.update_all { { likes: :likes + 1 } }
571
+ def update_all(updates = nil, &block)
572
+ return super(updates) unless block
573
+ if updates
574
+ raise ArgumentError, "update_all takes updates or a block, not both"
575
+ end
576
+ result = evaluate_block(&block)
577
+ unless result.is_a?(::Hash)
578
+ raise ArgumentError, "the block gives update_all a hash of column => value"
579
+ end
580
+ super(result.transform_values {|value| to_arel_field(value) })
581
+ end
582
+
583
+ # upsert_all's on_duplicate takes SQL text and nothing else, so this is
584
+ # the one place the DSL writes the SQL out itself rather than handing
585
+ # Arel a tree. `excluded` is the row that could not be inserted:
586
+ #
587
+ # Post.upsert_all(rows, unique_by: :title) {
588
+ # { likes: :likes + excluded(:likes) }
589
+ # }
590
+ def upsert_all(attributes, **options, &block)
591
+ return super(attributes, **options) unless block
592
+ if options.key?(:on_duplicate)
593
+ raise ArgumentError, "upsert_all takes on_duplicate: or a block, not both"
594
+ end
595
+ result = evaluate_block(&block)
596
+ unless result.is_a?(::Hash)
597
+ raise ArgumentError, "the block gives upsert_all a hash of column => value"
598
+ end
599
+ if result.empty?
600
+ raise ArgumentError, "the block gives upsert_all at least one column to set"
601
+ end
602
+ super(attributes, on_duplicate: Arel.sql(set_clause(result)), **options)
603
+ end
604
+
605
+ private
606
+
607
+ # The left of each assignment is the column being written, which is bare
608
+ # -- the statement is already about one table -- and the right is the
609
+ # expression, compiled here because a string is what on_duplicate reads.
610
+ def set_clause(updates)
611
+ klass.with_connection do |connection|
612
+ updates.map do |column, value|
613
+ expression = connection.visitor.compile(
614
+ to_arel_field(value), Arel::Collectors::SQLString.new)
615
+ "#{connection.quote_column_name(column)}=#{expression}"
616
+ end.join(', ')
617
+ end
327
618
  end
328
619
  end
329
620
  end
@@ -1,5 +1,5 @@
1
1
  module Activerecord
2
2
  module Refined
3
- VERSION = '0.5.1'
3
+ VERSION = '0.6.0'
4
4
  end
5
5
  end
@@ -6,6 +6,9 @@ require 'active_record/refined'
6
6
 
7
7
  ActiveRecord::QueryMethods.prepend ActiveRecord::Refined::QueryMethods
8
8
 
9
+ # update_all and its kind are Relation's own rather than QueryMethods'.
10
+ ActiveRecord::Relation.prepend ActiveRecord::Refined::Writes
11
+
9
12
  # The methods above are ActiveRecord's own, so a model already forwards them
10
- # to its relation. from_cte is new, and has to be added to that list itself.
11
- ActiveRecord::Base.singleton_class.delegate :from_cte, to: :all
13
+ # to its relation. These two are new, and have to be added to that list.
14
+ ActiveRecord::Base.singleton_class.delegate :from_cte, :distinct_on, to: :all