activerecord-refined 0.5.0 → 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
@@ -22,6 +21,18 @@ module ActiveRecord
22
21
  AST::Column.new(self, column_name)
23
22
  end
24
23
  end
24
+
25
+ # Shorthand for `value(0).as(:depth)` and the like. Numbers only: a
26
+ # string in a select list already means SQL rather than a string, so
27
+ # giving String this would make the same literal mean two things
28
+ # depending on whether it had been sent a message.
29
+ [Integer, Float].each do |klass|
30
+ refine klass do
31
+ def as(alias_name, quote: true)
32
+ AST::As.new(AST::Value.new(self), alias_name, quote: quote)
33
+ end
34
+ end
35
+ end
25
36
  end
26
37
 
27
38
  class BlockContext
@@ -70,6 +81,9 @@ module ActiveRecord
70
81
  # default it to zero; SQLite's trunc takes only the one.
71
82
  trunc: {mysql: 'TRUNCATE'},
72
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},
73
87
  date_trunc: {sqlite: nil, mysql: nil},
74
88
  # Named for Kernel#rand, which it also takes back: a block calling
75
89
  # rand would otherwise get Ruby's and never reach the database.
@@ -81,14 +95,6 @@ module ActiveRecord
81
95
  format: {mysql: nil},
82
96
  }.freeze
83
97
 
84
- ADAPTER_FAMILIES = {
85
- 'sqlite3' => :sqlite,
86
- 'postgresql' => :postgresql,
87
- 'postgis' => :postgresql,
88
- 'mysql2' => :mysql,
89
- 'trilogy' => :mysql,
90
- }.freeze
91
-
92
98
  SCALAR_FUNCTIONS.each_key do |name|
93
99
  define_method(name) do |*args|
94
100
  AST::Function.new(function_name(name, SCALAR_FUNCTIONS), args)
@@ -144,6 +150,26 @@ module ActiveRecord
144
150
  node
145
151
  end
146
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
+
147
173
  # CAST(expr AS type). The type is the adapter's own name for it,
148
174
  # checked for shape by the node; whether it exists is the database's to
149
175
  # say.
@@ -151,6 +177,32 @@ module ActiveRecord
151
177
  AST::Cast.new(expr, type)
152
178
  end
153
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
+
154
206
  # Escape hatch for functions without a method of their own. The name is
155
207
  # emitted as written, so a case-sensitive one can be spelled exactly,
156
208
  # and for that reason it has to be a plain name, optionally qualified by
@@ -160,12 +212,101 @@ module ActiveRecord
160
212
  AST.check_name(name, AST::FUNCTION_NAME, "function name").to_s, args)
161
213
  end
162
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
+
163
230
  def exists?(relation)
164
231
  AST::Exists.new(relation)
165
232
  end
166
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
+
250
+ # A literal where an expression is expected, quoted like any other value:
251
+ #
252
+ # select { [:id, value(0).as(:depth)] }
253
+ #
254
+ # Needed because the top of a select list is ActiveRecord's, and a bare
255
+ # string there is SQL rather than a string. Numbers have a shorthand --
256
+ # `0.as(:depth)` -- since nothing else could be meant by one.
257
+ def value(literal)
258
+ AST::Value.new(literal)
259
+ end
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
+
167
289
  private
168
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
+
169
310
  def function_name(name, functions)
170
311
  spellings = functions.fetch(name)
171
312
  return name.to_s.upcase unless spellings.key?(adapter_family)
@@ -174,18 +315,15 @@ module ActiveRecord
174
315
  "#{name} has no equivalent on #{@model.connection_db_config.adapter}")
175
316
  end
176
317
 
177
- # An adapter nobody has classified keeps the standard spellings, and is
178
- # left to say for itself what it cannot do.
179
318
  def adapter_family
180
- @adapter_family ||=
181
- ADAPTER_FAMILIES[@model.connection_db_config.adapter] || :unknown
319
+ @adapter_family ||= AST.adapter_family(@model)
182
320
  end
183
321
  end
184
322
 
185
323
  module QueryMethods
186
324
  def where(opts = nil, *rest, &block)
187
325
  if block
188
- super(evaluate_block(&block).to_arel(table))
326
+ super(evaluate_block(&block).to_arel(table, klass))
189
327
  else
190
328
  super
191
329
  end
@@ -203,7 +341,7 @@ module ActiveRecord
203
341
 
204
342
  def having(opts = nil, *rest, &block)
205
343
  if block
206
- super(evaluate_block(&block).to_arel(table))
344
+ super(evaluate_block(&block).to_arel(table, klass))
207
345
  else
208
346
  super
209
347
  end
@@ -230,9 +368,8 @@ module ActiveRecord
230
368
  end
231
369
 
232
370
  # A symbol names a table, which ActiveRecord's own from only takes as a
233
- # string. With `as` it is selected under another name, which is how a
234
- # CTE stands in for the model's own table:
235
- # with_recursive(tree: [...]).from(:tree, as: :nodes)
371
+ # string. With `as` it is selected under another name; when that name
372
+ # is the model's own, from_cte says the same thing without repeating it.
236
373
  def from(value, subquery_name = nil, as: nil)
237
374
  unless value.is_a?(Symbol)
238
375
  if as
@@ -245,10 +382,76 @@ module ActiveRecord
245
382
  super(arel_table, subquery_name)
246
383
  end
247
384
 
385
+ # Selects a CTE in place of the model's own table. The alias is not a
386
+ # choice -- ActiveRecord keeps qualifying columns with the table name,
387
+ # so the model's is the only name that works -- which is why it is
388
+ # taken from the model rather than asked for:
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.
395
+ def from_cte(name)
396
+ unless name.is_a?(Symbol)
397
+ raise ArgumentError, "from_cte takes the CTE's name as a symbol"
398
+ end
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
444
+ end
445
+
248
446
  # `as` names the table within the query, which is what makes a self
249
447
  # join expressible: joins(:employees, as: :managers) { ... }.
250
- def joins(*args, as: nil, &block)
251
- 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
252
455
  super(build_join_node(args.first, Arel::Nodes::InnerJoin, as, &block))
253
456
  else
254
457
  reject_join_alias(as)
@@ -256,8 +459,10 @@ module ActiveRecord
256
459
  end
257
460
  end
258
461
 
259
- def left_outer_joins(*args, as: nil, &block)
260
- 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
261
466
  joins(build_join_node(args.first, Arel::Nodes::OuterJoin, as, &block))
262
467
  else
263
468
  reject_join_alias(as)
@@ -267,6 +472,31 @@ module ActiveRecord
267
472
 
268
473
  private
269
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
+
270
500
  def evaluate_block(&block)
271
501
  refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
272
502
  BlockContext.new(klass).instance_exec(&refined_block)
@@ -274,7 +504,7 @@ module ActiveRecord
274
504
 
275
505
  def to_arel_field(node)
276
506
  case node
277
- when AST::Node then node.to_arel(table)
507
+ when AST::Node then node.to_arel(table, klass)
278
508
  when Symbol then table[node]
279
509
  else node
280
510
  end
@@ -285,11 +515,106 @@ module ActiveRecord
285
515
  raise ArgumentError, "as: needs a block to write the ON clause with"
286
516
  end
287
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
+
288
554
  def build_join_node(target_table, join_class, alias_name, &block)
289
555
  ast = evaluate_block(&block)
290
556
  arel_table = Arel::Table.new(target_table)
291
557
  arel_table = arel_table.alias(alias_name) if alias_name
292
- 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
293
618
  end
294
619
  end
295
620
  end
@@ -1,5 +1,5 @@
1
1
  module Activerecord
2
2
  module Refined
3
- VERSION = '0.5.0'
3
+ VERSION = '0.6.0'
4
4
  end
5
5
  end
@@ -5,3 +5,10 @@ require 'active_record/refined/ast'
5
5
  require 'active_record/refined'
6
6
 
7
7
  ActiveRecord::QueryMethods.prepend ActiveRecord::Refined::QueryMethods
8
+
9
+ # update_all and its kind are Relation's own rather than QueryMethods'.
10
+ ActiveRecord::Relation.prepend ActiveRecord::Refined::Writes
11
+
12
+ # The methods above are ActiveRecord's own, so a model already forwards them
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