activerecord-refined 0.10.2 → 0.12.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/.yardopts +1 -1
- data/README.md +19 -22
- data/activerecord-refined.gemspec +4 -3
- data/docs/conditions.md +3 -1
- data/docs/ctes.md +2 -2
- data/docs/expressions.md +37 -20
- data/docs/functions.md +14 -10
- data/docs/index.md +2 -17
- data/docs/windows.md +2 -2
- data/examples/expressions.rb +21 -15
- data/lib/active_record/refined/ast/arithmetics.rb +137 -0
- data/lib/active_record/refined/ast/conditions.rb +451 -0
- data/lib/active_record/refined/ast/expressions.rb +304 -0
- data/lib/active_record/refined/ast/functions.rb +254 -0
- data/lib/active_record/refined/ast/grouping.rb +63 -0
- data/lib/active_record/refined/ast/json.rb +530 -0
- data/lib/active_record/refined/ast/node.rb +298 -0
- data/lib/active_record/refined/ast/ordering.rb +104 -0
- data/lib/active_record/refined/ast/predications.rb +384 -0
- data/lib/active_record/refined/ast/windows.rb +126 -0
- data/lib/active_record/refined/ast.rb +39 -2364
- data/lib/active_record/refined/block_context.rb +671 -0
- data/lib/active_record/refined/block_syntax.rb +128 -0
- data/lib/active_record/refined/dialect/mysql_compat.rb +16 -0
- data/lib/active_record/refined/dialect/mysqlish_json_functions.rb +45 -0
- data/lib/active_record/refined/dialect/oracle.rb +1 -7
- data/lib/active_record/refined/dialect/postgresql.rb +8 -0
- data/lib/active_record/refined/dialect/sql_server.rb +29 -5
- data/lib/active_record/refined/dialect/sqlite.rb +23 -0
- data/lib/active_record/refined/dialect.rb +132 -66
- data/lib/active_record/refined/query_methods.rb +444 -0
- data/lib/{activerecord-refined → active_record/refined}/version.rb +3 -2
- data/lib/active_record/refined/writes.rb +72 -0
- data/lib/active_record/refined.rb +7 -1270
- data/lib/activerecord-refined.rb +0 -4
- metadata +36 -5
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/refined/ast"
|
|
4
|
+
require "active_record/refined/dialect"
|
|
5
|
+
require "active_record/refined/block_syntax"
|
|
6
|
+
require "active_record/refined/block_context"
|
|
7
|
+
|
|
8
|
+
module ActiveRecord
|
|
9
|
+
module Refined
|
|
10
|
+
# The relation methods a block reaches, prepended to Active Record's
|
|
11
|
+
# own: `where`, `select`, `having`, `order` and `group` take a block
|
|
12
|
+
# beside what they take already, the joins take one for the ON, and
|
|
13
|
+
# `from`, `from_cte`, `distinct_on` and `lateral` are here for what
|
|
14
|
+
# Active Record has no spelling for. Without a block each is Active
|
|
15
|
+
# Record's own.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# Author.
|
|
19
|
+
# joins(:posts) { :posts[:author_id] == :authors[:id] }.
|
|
20
|
+
# where { :posts[:published] == true }.
|
|
21
|
+
# group { :authors[:id] }.
|
|
22
|
+
# having { count(:posts[:id]) > 1 }.
|
|
23
|
+
# order { count(:posts[:id]).desc }.
|
|
24
|
+
# select { [:name, count(:posts[:id]).as(:post_count)] }
|
|
25
|
+
module QueryMethods
|
|
26
|
+
# `WHERE`, from a block: a condition built with the comparisons of
|
|
27
|
+
# {BlockSyntax}, combined with `&`, `|` and `!`.
|
|
28
|
+
# @yieldreturn [AST::Predicate, AST::Sql, AST::Operation]
|
|
29
|
+
# @example
|
|
30
|
+
# Author.where { (:age >= 18) & :country.in?(%w[JP US]) }
|
|
31
|
+
# Author.where { !:name.like?("A%") }
|
|
32
|
+
def where(opts = nil, *rest, &block)
|
|
33
|
+
if block
|
|
34
|
+
super(to_arel_condition(evaluate_block(&block)))
|
|
35
|
+
else
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `SELECT`, from a block: an expression, or an array of them, each
|
|
41
|
+
# aliased with `as` or left to its own name.
|
|
42
|
+
# @yieldreturn [Symbol, AST::Node, Array<Symbol, AST::Node>]
|
|
43
|
+
# @example
|
|
44
|
+
# Author.select { [:name, upper(:name).as(:shouted), count(:*).as(:n)] }
|
|
45
|
+
def select(*fields, &block)
|
|
46
|
+
if block
|
|
47
|
+
super(*to_arel_fields(evaluate_block(&block)), &nil)
|
|
48
|
+
else
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `HAVING`, from a block: a condition over the aggregates of a group.
|
|
54
|
+
# @yieldreturn [AST::Predicate, AST::Sql, AST::Operation]
|
|
55
|
+
# @example
|
|
56
|
+
# Author.group { :country }.having { count(:*) > 1 }
|
|
57
|
+
def having(opts = nil, *rest, &block)
|
|
58
|
+
if block
|
|
59
|
+
super(to_arel_condition(evaluate_block(&block)))
|
|
60
|
+
else
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# `ORDER BY`, from a block: an ordering, or an array of them --
|
|
66
|
+
# `:age.desc`, `count(:*).desc.nulls_last`, or a bare column.
|
|
67
|
+
# @yieldreturn [Symbol, AST::Node, Array<Symbol, AST::Node>]
|
|
68
|
+
# @example
|
|
69
|
+
# Author.order { [:country.asc.nulls_last, :age.desc] }
|
|
70
|
+
def order(*args, &block)
|
|
71
|
+
if block
|
|
72
|
+
super(*to_arel_fields(evaluate_block(&block)), &nil)
|
|
73
|
+
else
|
|
74
|
+
super
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# `GROUP BY`, from a block: a column or an expression, an array of
|
|
79
|
+
# them, or one of {BlockContext#grouping_sets}, {BlockContext#rollup}
|
|
80
|
+
# and {BlockContext#cube}.
|
|
81
|
+
# @yieldreturn [Symbol, AST::Node, Array<Symbol, AST::Node>]
|
|
82
|
+
# @example
|
|
83
|
+
# Post.group { date_trunc("day", :created_at) }.select { [date_trunc("day", :created_at).as(:day), count(:*)] }
|
|
84
|
+
def group(*args, &block)
|
|
85
|
+
if block
|
|
86
|
+
result = evaluate_block(&block)
|
|
87
|
+
check_rollup_stands_alone(result)
|
|
88
|
+
super(*to_arel_fields(result), &nil)
|
|
89
|
+
else
|
|
90
|
+
super
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# `FROM`, with a table named as a symbol and, with `as:`, selected
|
|
95
|
+
# under another name; anything else is Active Record's own `from`.
|
|
96
|
+
# @param value [Symbol, String, ActiveRecord::Relation]
|
|
97
|
+
# @param as [Symbol, nil] the name the table is selected under
|
|
98
|
+
# @example
|
|
99
|
+
# Post.from(:archived_posts, as: :posts)
|
|
100
|
+
#
|
|
101
|
+
# A symbol names a table, which Active Record's own from only takes as
|
|
102
|
+
# a string. With `as` it is selected under another name; when that
|
|
103
|
+
# name is the model's own, from_cte says the same thing without
|
|
104
|
+
# repeating it.
|
|
105
|
+
def from(value, subquery_name = nil, as: nil)
|
|
106
|
+
unless value.is_a?(Symbol)
|
|
107
|
+
if as
|
|
108
|
+
raise ArgumentError, "as: needs the table named as a symbol"
|
|
109
|
+
end
|
|
110
|
+
return super(value, subquery_name)
|
|
111
|
+
end
|
|
112
|
+
arel_table = Arel::Table.new(value)
|
|
113
|
+
arel_table = arel_table.alias(as) if as
|
|
114
|
+
super(arel_table, subquery_name)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Selects a CTE in place of the model's own table, under the model's
|
|
118
|
+
# own name, so that the columns Active Record qualifies still resolve.
|
|
119
|
+
# The name has to be one `with` or `with_recursive` declares.
|
|
120
|
+
# @param name [Symbol] the CTE's name
|
|
121
|
+
# @example
|
|
122
|
+
# Node.with_recursive(tree: [Node.where { :id == 1 }, Node.joins(...)]).from_cte(:tree)
|
|
123
|
+
#
|
|
124
|
+
# The alias is not a choice -- Active Record keeps qualifying columns
|
|
125
|
+
# with the table name, so the model's is the only name that works --
|
|
126
|
+
# which is why it is taken from the model rather than asked for.
|
|
127
|
+
# The name is checked against what `with` declares, so that a typo is
|
|
128
|
+
# not a query against a table nobody has. Checked when the SQL is
|
|
129
|
+
# built, since the CTE may be declared after this in the chain, or by a
|
|
130
|
+
# scope merged into it.
|
|
131
|
+
def from_cte(name)
|
|
132
|
+
unless name.is_a?(Symbol)
|
|
133
|
+
raise ArgumentError, "from_cte takes the CTE's name as a symbol"
|
|
134
|
+
end
|
|
135
|
+
relation = from(name, as: model.table_name)
|
|
136
|
+
relation.from_cte_value = name
|
|
137
|
+
relation
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @private
|
|
141
|
+
def from_cte_value
|
|
142
|
+
@values[:from_cte]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @private
|
|
146
|
+
def from_cte_value=(name)
|
|
147
|
+
assert_modifiable!
|
|
148
|
+
@values[:from_cte] = name
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# `SELECT DISTINCT ON (columns)`: the first row of each group the
|
|
152
|
+
# order brings up. PostgreSQL has it; the portable shape is a
|
|
153
|
+
# `row_number` window in a subquery.
|
|
154
|
+
# @param columns [Array<Symbol>] the columns, unless a block gives them
|
|
155
|
+
# @example
|
|
156
|
+
# Post.distinct_on { :author_id }.order { [:author_id, :likes.desc] }
|
|
157
|
+
#
|
|
158
|
+
# Arel carries the node and refuses to write it elsewhere, the way it
|
|
159
|
+
# does a regexp, so there is nothing for this to check.
|
|
160
|
+
def distinct_on(*columns, &block)
|
|
161
|
+
spawn.distinct_on!(*columns, &block)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# {#distinct_on} on the relation itself.
|
|
165
|
+
def distinct_on!(*columns, &block)
|
|
166
|
+
columns = Array(evaluate_block(&block)) if block
|
|
167
|
+
if columns.empty?
|
|
168
|
+
raise ArgumentError, "distinct_on needs a column or an expression"
|
|
169
|
+
end
|
|
170
|
+
self.distinct_on_values += columns
|
|
171
|
+
self
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Active Record generates these for the values it knows about; this one
|
|
175
|
+
# is ours, and lives in the same place so that it survives a spawn.
|
|
176
|
+
# @private
|
|
177
|
+
def distinct_on_values
|
|
178
|
+
@values.fetch(:distinct_on, ActiveRecord::QueryMethods::FROZEN_EMPTY_ARRAY)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @private
|
|
182
|
+
def distinct_on_values=(columns)
|
|
183
|
+
assert_modifiable!
|
|
184
|
+
@values[:distinct_on] = columns
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Marks the relation for a `LATERAL` join, which lets the subquery see
|
|
188
|
+
# the row it is joined to -- the top few rows of each group, and the
|
|
189
|
+
# like. Said on the relation, since in SQL the keyword modifies the
|
|
190
|
+
# subquery rather than the join. SQLite and MariaDB have none.
|
|
191
|
+
# @example
|
|
192
|
+
# top = Post.where { :posts[:author_id] == :authors[:id] }.order { :likes.desc }.limit(1)
|
|
193
|
+
# Author.left_outer_joins(top.lateral, as: :top).select { [:name, :top[:title]] }
|
|
194
|
+
def lateral
|
|
195
|
+
spawn.lateral!
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# {#lateral} on the relation itself.
|
|
199
|
+
def lateral!
|
|
200
|
+
self.lateral_value = true
|
|
201
|
+
self
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @private
|
|
205
|
+
def lateral_value
|
|
206
|
+
@values[:lateral]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# @private
|
|
210
|
+
def lateral_value=(value)
|
|
211
|
+
assert_modifiable!
|
|
212
|
+
@values[:lateral] = value
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# `INNER JOIN`, with the `ON` from a block: `joins(:posts) { ... }`
|
|
216
|
+
# joins the table named, `joins(relation) { ... }` a subquery -- a
|
|
217
|
+
# lateral one when the relation is marked {#lateral}. `as:` names the
|
|
218
|
+
# table within the query, which is what makes a self join expressible.
|
|
219
|
+
# Without a block it is Active Record's own `joins`.
|
|
220
|
+
# @param as [Symbol, nil]
|
|
221
|
+
# @yieldreturn [AST::Predicate, AST::Sql, AST::Operation]
|
|
222
|
+
# @example
|
|
223
|
+
# Author.joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
224
|
+
# Employee.joins(:employees, as: :managers) { :managers[:id] == :employees[:manager_id] }
|
|
225
|
+
def joins(*args, as: nil, &block)
|
|
226
|
+
if args.first.is_a?(ActiveRecord::Relation)
|
|
227
|
+
super(build_lateral_join(args.first, Arel::Nodes::InnerJoin, as, &block))
|
|
228
|
+
elsif block
|
|
229
|
+
super(build_join_node(args.first, Arel::Nodes::InnerJoin, as, &block))
|
|
230
|
+
else
|
|
231
|
+
reject_join_alias(as)
|
|
232
|
+
super(*args, &block)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# `LEFT OUTER JOIN`, as {#joins} takes it.
|
|
237
|
+
# @param as [Symbol, nil]
|
|
238
|
+
# @yieldreturn [AST::Predicate, AST::Sql, AST::Operation]
|
|
239
|
+
# @example
|
|
240
|
+
# Author.left_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
241
|
+
def left_outer_joins(*args, as: nil, &block)
|
|
242
|
+
if args.first.is_a?(ActiveRecord::Relation)
|
|
243
|
+
joins(build_lateral_join(args.first, Arel::Nodes::OuterJoin, as, &block))
|
|
244
|
+
elsif block
|
|
245
|
+
joins(build_join_node(args.first, Arel::Nodes::OuterJoin, as, &block))
|
|
246
|
+
else
|
|
247
|
+
reject_join_alias(as)
|
|
248
|
+
super(*args, &block)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# `RIGHT OUTER JOIN`, as {#joins} takes it, of a table or a relation;
|
|
253
|
+
# an association name is not among what it takes.
|
|
254
|
+
# @param as [Symbol, nil]
|
|
255
|
+
# @yieldreturn [AST::Predicate, AST::Sql, AST::Operation]
|
|
256
|
+
# @example
|
|
257
|
+
# Post.right_outer_joins(:authors) { :posts[:author_id] == :authors[:id] }
|
|
258
|
+
#
|
|
259
|
+
# The other two outer joins, which Active Record has no method for and
|
|
260
|
+
# Arel has the nodes for. The rules are joins': the block is the ON,
|
|
261
|
+
# `as` names the table within the query, a relation marked `lateral`
|
|
262
|
+
# joins as one. An association name is not among them -- what Active
|
|
263
|
+
# Record reads out of one is an inner or a left join and nothing else.
|
|
264
|
+
def right_outer_joins(*args, as: nil, &block)
|
|
265
|
+
outer_joins(:right_outer_joins, Arel::Nodes::RightOuterJoin,
|
|
266
|
+
args, as, &block)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# `FULL OUTER JOIN`, as {#right_outer_joins} takes it. The MySQL
|
|
270
|
+
# family has none.
|
|
271
|
+
# @param as [Symbol, nil]
|
|
272
|
+
# @yieldreturn [AST::Predicate, AST::Sql, AST::Operation]
|
|
273
|
+
def full_outer_joins(*args, as: nil, &block)
|
|
274
|
+
check_full_outer_support
|
|
275
|
+
outer_joins(:full_outer_joins, Arel::Nodes::FullOuterJoin,
|
|
276
|
+
args, as, &block)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# `CROSS JOIN`: every row of one table against every row of the
|
|
280
|
+
# other, so there is no condition to give and no block to write it in.
|
|
281
|
+
# @param as [Symbol, nil]
|
|
282
|
+
# @example
|
|
283
|
+
# Post.cross_joins(:authors)
|
|
284
|
+
# Post.cross_joins(:posts, as: :others)
|
|
285
|
+
def cross_joins(*args, as: nil, &block)
|
|
286
|
+
if block
|
|
287
|
+
raise ArgumentError,
|
|
288
|
+
"a cross join has no condition; joins is the one that takes a block"
|
|
289
|
+
end
|
|
290
|
+
joins(build_cross_join(args.first, as))
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
def build_arel(...)
|
|
295
|
+
check_from_cte
|
|
296
|
+
arel = super
|
|
297
|
+
unless distinct_on_values.empty?
|
|
298
|
+
arel.distinct_on(distinct_on_values.map { |column| to_arel_field(column) })
|
|
299
|
+
end
|
|
300
|
+
arel
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Only when every `with` is one this can read the names out of; anything
|
|
304
|
+
# else and there is nothing to be sure about, so nothing is said.
|
|
305
|
+
def check_from_cte
|
|
306
|
+
name = from_cte_value
|
|
307
|
+
return unless name
|
|
308
|
+
return unless with_values.all? { |value| value.is_a?(::Hash) }
|
|
309
|
+
|
|
310
|
+
declared = with_values.flat_map { |value| value.keys.map(&:to_sym) }
|
|
311
|
+
return if declared.include?(name)
|
|
312
|
+
|
|
313
|
+
raise ArgumentError,
|
|
314
|
+
"from_cte(#{name.inspect}) names no CTE; " +
|
|
315
|
+
(declared.empty? ? "this query declares none" :
|
|
316
|
+
"this query declares #{declared.map(&:inspect).join(', ')}")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def evaluate_block(&block)
|
|
320
|
+
refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
|
|
321
|
+
BlockContext.new(model).instance_exec(&refined_block)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# WITH ROLLUP trails the whole group list, so on the MySQL family a
|
|
325
|
+
# rollup cannot stand beside other group entries the way PostgreSQL's
|
|
326
|
+
# ROLLUP(...) can.
|
|
327
|
+
def check_rollup_stands_alone(result)
|
|
328
|
+
entries = Array(result)
|
|
329
|
+
return if entries.size == 1
|
|
330
|
+
return unless entries.any? { |node| node.is_a?(AST::GroupingSets) }
|
|
331
|
+
return unless Dialect.for(model).grouping_by_with_rollup?
|
|
332
|
+
|
|
333
|
+
raise ArgumentError,
|
|
334
|
+
"WITH ROLLUP takes the whole group list; group by the rollup alone"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def to_arel_condition(result)
|
|
338
|
+
return result if result.is_a?(Arel::Nodes::SqlLiteral)
|
|
339
|
+
if result.is_a?(::String)
|
|
340
|
+
raise ArgumentError,
|
|
341
|
+
"#{result.inspect} is a string, not a condition; sql(...) " \
|
|
342
|
+
"writes one as SQL"
|
|
343
|
+
end
|
|
344
|
+
result.to_arel(table, model)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# The top of a select, order or group list. A bare string is refused
|
|
348
|
+
# rather than passed to Active Record, where it would be SQL: inside a
|
|
349
|
+
# block a string is a value in every other position, and a literal
|
|
350
|
+
# whose meaning turns on where it stands is how an interpolation
|
|
351
|
+
# becomes an injection.
|
|
352
|
+
def to_arel_fields(result)
|
|
353
|
+
fields =
|
|
354
|
+
if result.nil? then []
|
|
355
|
+
elsif result.is_a?(::Array) then result
|
|
356
|
+
else [result]
|
|
357
|
+
end
|
|
358
|
+
fields.map do |node|
|
|
359
|
+
if node.is_a?(::String) && !node.is_a?(Arel::Nodes::SqlLiteral)
|
|
360
|
+
raise ArgumentError,
|
|
361
|
+
"#{node.inspect} could mean SQL or a string; " \
|
|
362
|
+
"sql(...) says the SQL, value(...) the string"
|
|
363
|
+
end
|
|
364
|
+
to_arel_field(node)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def to_arel_field(node)
|
|
369
|
+
case node
|
|
370
|
+
when AST::Sql then node.field_arel(model)
|
|
371
|
+
when AST::Node then node.to_arel(table, model)
|
|
372
|
+
when Symbol then table[node]
|
|
373
|
+
else node
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def reject_join_alias(alias_name)
|
|
378
|
+
return unless alias_name
|
|
379
|
+
raise ArgumentError, "as: needs a block to write the ON clause with"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# The subquery is written out rather than handed over as a tree: Arel has
|
|
383
|
+
# a LATERAL node but only PostgreSQL's visitor writes it, and MySQL can
|
|
384
|
+
# read what it will not write. Without a block the join is ON TRUE,
|
|
385
|
+
# which is the usual shape -- what the subquery is allowed to see is
|
|
386
|
+
# what makes it lateral, and that is said inside it.
|
|
387
|
+
def build_lateral_join(relation, join_class, alias_name, &block)
|
|
388
|
+
unless relation.lateral_value
|
|
389
|
+
raise ArgumentError,
|
|
390
|
+
"a relation joins laterally; mark it: joins(sub.lateral, as: :top)"
|
|
391
|
+
end
|
|
392
|
+
unless alias_name
|
|
393
|
+
raise ArgumentError, "a lateral join needs a name: joins(..., as: :top)"
|
|
394
|
+
end
|
|
395
|
+
check_lateral_support
|
|
396
|
+
|
|
397
|
+
aliased = Arel::Nodes::TableAlias.new(
|
|
398
|
+
Arel::Nodes::SqlLiteral.new("LATERAL (#{relation.to_sql})"), alias_name)
|
|
399
|
+
on = block ? evaluate_block(&block).to_arel(table, model) : Arel::Nodes::True.new
|
|
400
|
+
join_class.new(aliased, Arel::Nodes::On.new(on))
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def check_lateral_support
|
|
404
|
+
Dialect.for(model).check_lateral(model)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def check_full_outer_support
|
|
408
|
+
return if Dialect.for(model).full_outer_join_supported?
|
|
409
|
+
raise NotImplementedError, "a full outer join has no equivalent on MySQL"
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def outer_joins(called, join_class, args, alias_name, &block)
|
|
413
|
+
if args.first.is_a?(ActiveRecord::Relation)
|
|
414
|
+
return joins(build_lateral_join(args.first, join_class, alias_name, &block))
|
|
415
|
+
end
|
|
416
|
+
return joins(build_join_node(args.first, join_class, alias_name, &block)) if block
|
|
417
|
+
|
|
418
|
+
raise ArgumentError,
|
|
419
|
+
"#{called} takes a table and the block that joins it; an association " \
|
|
420
|
+
"is what joins and left_outer_joins read"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Arel has a node for every other join and none for this one, and INNER
|
|
424
|
+
# JOIN with no ON -- which is a cross join on SQLite and MySQL -- is a
|
|
425
|
+
# syntax error on PostgreSQL. So the SQL is written here, the second
|
|
426
|
+
# place in the gem that writes any: the keyword is fixed and the names
|
|
427
|
+
# are quoted by the adapter, so nothing of the caller's is in it.
|
|
428
|
+
def build_cross_join(target_table, alias_name)
|
|
429
|
+
joined = model.with_connection do |connection|
|
|
430
|
+
name = connection.quote_table_name(target_table.to_s)
|
|
431
|
+
alias_name ? "#{name} #{connection.quote_table_name(alias_name.to_s)}" : name
|
|
432
|
+
end
|
|
433
|
+
Arel::Nodes::StringJoin.new(Arel.sql("CROSS JOIN #{joined}"))
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def build_join_node(target_table, join_class, alias_name, &block)
|
|
437
|
+
ast = evaluate_block(&block)
|
|
438
|
+
arel_table = Arel::Table.new(target_table)
|
|
439
|
+
arel_table = arel_table.alias(alias_name) if alias_name
|
|
440
|
+
join_class.new(arel_table, Arel::Nodes::On.new(ast.to_arel(table, model)))
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/refined/query_methods"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Refined
|
|
7
|
+
# The writing statements, which live on Relation rather than in
|
|
8
|
+
# QueryMethods. What a block adds here is the one thing their arguments
|
|
9
|
+
# cannot carry: a value worked out from the row rather than given.
|
|
10
|
+
module Writes
|
|
11
|
+
# `UPDATE`, from a block that gives a hash of column to value, where a
|
|
12
|
+
# value may be an expression built from the row: `{ likes: :likes + 1 }`.
|
|
13
|
+
# Without a block it is Active Record's own, where `likes: :likes`
|
|
14
|
+
# sets the column to the symbol.
|
|
15
|
+
# @yieldreturn [Hash{Symbol => Object}]
|
|
16
|
+
# @example
|
|
17
|
+
# Post.where { :published == true }.update_all { { likes: :likes + 1 } }
|
|
18
|
+
# Post.update_all { { title: upper(:title) } }
|
|
19
|
+
def update_all(updates = nil, &block)
|
|
20
|
+
return super(updates) unless block
|
|
21
|
+
if updates
|
|
22
|
+
raise ArgumentError, "update_all takes updates or a block, not both"
|
|
23
|
+
end
|
|
24
|
+
result = evaluate_block(&block)
|
|
25
|
+
unless result.is_a?(::Hash)
|
|
26
|
+
raise ArgumentError, "the block gives update_all a hash of column => value"
|
|
27
|
+
end
|
|
28
|
+
super(result.transform_values { |value| to_arel_field(value) })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# `INSERT ... ON CONFLICT DO UPDATE`, with a block for what happens to
|
|
32
|
+
# a row that is already there: a hash of column to value, where
|
|
33
|
+
# {BlockContext#excluded} is the row that could not be inserted. Takes
|
|
34
|
+
# the block or `on_duplicate:`, not both.
|
|
35
|
+
# @yieldreturn [Hash{Symbol => Object}]
|
|
36
|
+
# @example
|
|
37
|
+
# Tally.upsert_all(rows, unique_by: :page) { { hits: :hits + excluded(:hits) } }
|
|
38
|
+
#
|
|
39
|
+
# upsert_all's on_duplicate takes SQL text and nothing else, so this is
|
|
40
|
+
# the one place the DSL writes the SQL out itself rather than handing
|
|
41
|
+
# Arel a tree.
|
|
42
|
+
def upsert_all(attributes, **options, &block)
|
|
43
|
+
return super(attributes, **options) unless block
|
|
44
|
+
if options.key?(:on_duplicate)
|
|
45
|
+
raise ArgumentError, "upsert_all takes on_duplicate: or a block, not both"
|
|
46
|
+
end
|
|
47
|
+
result = evaluate_block(&block)
|
|
48
|
+
unless result.is_a?(::Hash)
|
|
49
|
+
raise ArgumentError, "the block gives upsert_all a hash of column => value"
|
|
50
|
+
end
|
|
51
|
+
if result.empty?
|
|
52
|
+
raise ArgumentError, "the block gives upsert_all at least one column to set"
|
|
53
|
+
end
|
|
54
|
+
super(attributes, on_duplicate: Arel.sql(set_clause(result)), **options)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
# The left of each assignment is the column being written, which is bare
|
|
59
|
+
# -- the statement is already about one table -- and the right is the
|
|
60
|
+
# expression, compiled here because a string is what on_duplicate reads.
|
|
61
|
+
def set_clause(updates)
|
|
62
|
+
model.with_connection do |connection|
|
|
63
|
+
updates.map do |column, value|
|
|
64
|
+
expression = connection.visitor.compile(
|
|
65
|
+
to_arel_field(value), Arel::Collectors::SQLString.new)
|
|
66
|
+
"#{connection.quote_column_name(column)}=#{expression}"
|
|
67
|
+
end.join(", ")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|