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.
- checksums.yaml +4 -4
- data/.github/workflows/sandbox.yml +161 -25
- data/.github/workflows/test.yml +15 -2
- data/README.md +412 -49
- data/examples/ctes.rb +3 -3
- data/examples/expressions.rb +71 -5
- data/examples/json.rb +94 -0
- data/examples/postgresql.rb +89 -6
- data/examples/predicates.rb +32 -5
- data/examples/windows.rb +96 -0
- data/examples/writes.rb +84 -0
- data/lib/active_record/refined/ast.rb +776 -92
- data/lib/active_record/refined.rb +317 -26
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +5 -2
- data/test/test_block_syntax.rb +949 -60
- data/test/test_helper.rb +82 -0
- metadata +4 -1
|
@@ -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
|
-
|
|
285
|
-
|
|
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
|
|
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
|
data/lib/activerecord-refined.rb
CHANGED
|
@@ -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.
|
|
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
|