activerecord-refined 0.11.0 → 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.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ # BigDecimal is refined below and is a bundled gem, so nothing loads it
4
+ # before this file does.
5
+ require "bigdecimal"
6
+ require "active_record/refined/ast"
7
+
8
+ module ActiveRecord
9
+ module Refined
10
+ # What a symbol answers to inside a block. A symbol names a column
11
+ # there -- `:age` is `"users"."age"` -- and the methods below build a
12
+ # condition, an expression or an ordering from it. Every one of them is
13
+ # a refinement, so it exists inside a `where`, `select`, `having`,
14
+ # `order`, `group`, `joins`, `update_all` or `upsert_all` block and
15
+ # nowhere else.
16
+ #
17
+ # The comparisons and the rest of the conditions are listed under
18
+ # {AST::Predications}, the arithmetic under {AST::Arithmetics}; a number
19
+ # or a string in a block takes `as` too, for a literal in a select list
20
+ # -- `0.as(:depth)`. A number may stand on the left of an operator when
21
+ # an expression stands on the right; before a bare column it is written
22
+ # `value(20) - :quantity`.
23
+ #
24
+ # @example A column compared, aliased and ordered
25
+ # Author.where { :age >= 18 }
26
+ # Author.select { :name.as(:author) }
27
+ # Author.order { :age.desc.nulls_last }
28
+ # @example A column of another table, and a collation
29
+ # Author.joins(:posts) { :posts[:author_id] == :authors[:id] }
30
+ # Author.where { :name.collate(:nocase) == "alice" }
31
+ module BlockSyntax
32
+ # @!parse include AST::Predications
33
+ # @!parse include AST::Arithmetics
34
+
35
+ # @!method as(alias_name, quote: true)
36
+ # The column under an alias: `AS "name"`. A number or a string takes
37
+ # it too, for a literal in a select list; {BlockContext#value} carries
38
+ # the literals that have no `as` of their own. The alias is quoted,
39
+ # so the name asked for is the name that comes back on every adapter;
40
+ # `quote: false` writes it bare, for a schema that wants the folding,
41
+ # and then it has to be a plain name.
42
+ # @param alias_name [Symbol, String]
43
+ # @param quote [Boolean]
44
+ # @return [AST::As]
45
+ # @example
46
+ # Author.select { :name.as(:author) } # "authors"."name" AS "author"
47
+ # Node.select { [:id, 0.as(:depth)] } # 0 AS "depth"
48
+ # Post.select { [:title, "draft".as(:state)] } # 'draft' AS "state"
49
+
50
+ # @!method asc
51
+ # An ascending ordering, which takes `nulls_first` and `nulls_last`.
52
+ # @return [AST::Ordering]
53
+ # @example
54
+ # Author.order { :country.asc.nulls_last }
55
+
56
+ # @!method desc
57
+ # A descending ordering, which takes `nulls_first` and `nulls_last`.
58
+ # @return [AST::Ordering]
59
+ # @example
60
+ # Post.order { :likes.desc }
61
+
62
+ # @!method collate(name)
63
+ # The column under a collation, for a comparison or an ordering:
64
+ # `"name" COLLATE nocase`. The name is the database's own and not
65
+ # portable; PostgreSQL quotes it, the others take it bare and refuse
66
+ # one that is not a plain identifier.
67
+ # @param name [Symbol, String] the collation's name
68
+ # @return [AST::Collate]
69
+ # @example
70
+ # Author.where { :name.collate(:nocase) == "alice" }
71
+ # Author.order { :name.collate(:"en-US-x-icu").asc } # PostgreSQL
72
+
73
+ # @!method [](column_name)
74
+ # A column of another table: `:posts[:author_id]` is
75
+ # `"posts"."author_id"`, for a join condition or a query over a join.
76
+ # @param column_name [Symbol]
77
+ # @return [AST::Column]
78
+ # @example
79
+ # Author.joins(:posts) { :posts[:author_id] == :authors[:id] }
80
+
81
+ refine Symbol do
82
+ import_methods AST::Predications
83
+ import_methods AST::Arithmetics
84
+
85
+ def as(alias_name, quote: true)
86
+ AST::As.new(self, alias_name, quote: quote)
87
+ end
88
+
89
+ def asc
90
+ AST::Ordering.new(self, :asc)
91
+ end
92
+
93
+ def desc
94
+ AST::Ordering.new(self, :desc)
95
+ end
96
+
97
+ def collate(name)
98
+ AST::Collate.new(self, name)
99
+ end
100
+
101
+ def [](column_name)
102
+ AST::Column.new(self, column_name)
103
+ end
104
+ end
105
+
106
+ # Shorthand for `value(0).as(:depth)` and the like, and the named
107
+ # bitwise operations with the number on the left. BigDecimal is a
108
+ # number here because that is what a decimal column's values are.
109
+ [Integer, Float, BigDecimal].each do |klass|
110
+ refine klass do
111
+ import_methods AST::NumericArithmetics
112
+
113
+ def as(alias_name, quote: true)
114
+ AST::As.new(AST::Value.new(self), alias_name, quote: quote)
115
+ end
116
+ end
117
+ end
118
+
119
+ # A string is a value here as it is in every other position of a block;
120
+ # SQL is asked for by name, with sql().
121
+ refine String do
122
+ def as(alias_name, quote: true)
123
+ AST::As.new(AST::Value.new(self), alias_name, quote: quote)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Refined
5
+ # @private
6
+ VERSION = "0.12.0"
7
+ end
8
+ 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