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.
- checksums.yaml +4 -4
- data/.github/workflows/sandbox.yml +162 -26
- data/.github/workflows/test.yml +15 -2
- data/README.md +486 -52
- data/activerecord-refined.gemspec +3 -2
- data/examples/ctes.rb +41 -10
- 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 +831 -94
- data/lib/active_record/refined.rb +351 -26
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +7 -0
- data/test/test_block_syntax.rb +1100 -51
- 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
|
|
@@ -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
|
|
234
|
-
#
|
|
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
|
-
|
|
251
|
-
|
|
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
|
|
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
|
data/lib/activerecord-refined.rb
CHANGED
|
@@ -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
|