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.
- 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/expressions.md +10 -7
- data/docs/index.md +1 -1
- data/examples/expressions.rb +8 -8
- 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 +10 -2610
- 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/query_methods.rb +444 -0
- data/lib/active_record/refined/version.rb +8 -0
- data/lib/active_record/refined/writes.rb +72 -0
- data/lib/active_record/refined.rb +7 -1307
- data/lib/activerecord-refined.rb +0 -4
- metadata +35 -5
- data/lib/activerecord-refined/version.rb +0 -12
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# JSON.generate, for the document a containment test is given.
|
|
4
|
+
require "json"
|
|
5
|
+
require "active_record/refined/ast/node"
|
|
6
|
+
require "active_record/refined/ast/windows"
|
|
7
|
+
|
|
8
|
+
module ActiveRecord
|
|
9
|
+
module Refined
|
|
10
|
+
module AST
|
|
11
|
+
# A path into a JSON document, spelled the two ways the adapters want it.
|
|
12
|
+
# Shared, because reading a value and setting one walk the same path.
|
|
13
|
+
# @private
|
|
14
|
+
module JsonSteps
|
|
15
|
+
def check_steps(path, called)
|
|
16
|
+
raise ArgumentError, "#{called} needs a key or an index" if path.empty?
|
|
17
|
+
path.each do |step|
|
|
18
|
+
next if step.is_a?(::Integer) || step.is_a?(::String) || step.is_a?(::Symbol)
|
|
19
|
+
raise ArgumentError, "a step is a key or an array index, not #{step.inspect}"
|
|
20
|
+
end
|
|
21
|
+
path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# PostgreSQL takes the steps as a text array, where every element is
|
|
25
|
+
# quoted so that a comma or a brace in a key is part of it. except
|
|
26
|
+
# writes its keys the same way, which are steps of no one path.
|
|
27
|
+
def steps_array(steps = path)
|
|
28
|
+
"{#{steps.map { |step| %("#{escape_step(step)}") }.join(',')}}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# MySQL and SQLite take a path expression instead, where an integer is
|
|
32
|
+
# a subscript and a name that is not plain has to be quoted.
|
|
33
|
+
def dollar_path
|
|
34
|
+
path.inject(+"$") { |so_far, step| so_far << dollar_step(step) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dollar_step(step)
|
|
38
|
+
return "[#{step}]" if step.is_a?(::Integer)
|
|
39
|
+
name = step.to_s
|
|
40
|
+
"." + (name.match?(/\A[[:alpha:]_][[:alnum:]_]*\z/) ?
|
|
41
|
+
name : %("#{escape_step(step)}"))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def escape_step(step)
|
|
45
|
+
step.to_s.gsub("\\", "\\\\").gsub('"', '\\"')
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# What a dug value may be compared with. dig_text gives text on every
|
|
50
|
+
# adapter, and what a text value compared with a number means is a
|
|
51
|
+
# question the three answer three ways: `dig_text(:n) == 5` is true on
|
|
52
|
+
# SQLite, an error on PostgreSQL and true on MySQL, while
|
|
53
|
+
# `dig_text(:flag) == true` is true, an error, and false. cast is what
|
|
54
|
+
# says which type was meant, and then all three agree.
|
|
55
|
+
#
|
|
56
|
+
# dig, bury and except give JSON, and a JSON comparison belongs to the
|
|
57
|
+
# JSON types -- jsonb and MySQL's -- where numbers compare as numbers
|
|
58
|
+
# and documents structurally, key order and spelling aside. The Ruby
|
|
59
|
+
# value goes in as a JSON literal, and the adapters without such a
|
|
60
|
+
# type refuse it from JsonLiteral when the SQL is written, which is
|
|
61
|
+
# when the adapter is known.
|
|
62
|
+
#
|
|
63
|
+
# A string against dig_text, and anything the block itself built -- a
|
|
64
|
+
# column, a function, another dug value -- go through untouched.
|
|
65
|
+
#
|
|
66
|
+
# Arithmetic and the bit operators are refused outright on both sides:
|
|
67
|
+
# `dig_text(:n) + 1` is 6 on SQLite, an error on PostgreSQL and 6.0 on
|
|
68
|
+
# MariaDB, and an expression on the right does not change what the
|
|
69
|
+
# dug side is.
|
|
70
|
+
module JsonComparable
|
|
71
|
+
%i[== != < <= > >=].each do |operator|
|
|
72
|
+
define_method(operator) do |other|
|
|
73
|
+
super(comparison_value(other))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def in?(values) = super(comparison_set(values))
|
|
78
|
+
def not_in?(values) = super(comparison_set(values))
|
|
79
|
+
def between?(min, max) = super(comparison_value(min), comparison_value(max))
|
|
80
|
+
def not_between?(min, max) = super(comparison_value(min), comparison_value(max))
|
|
81
|
+
|
|
82
|
+
%i[+ - * / & | ^ << >> bitwise_and bitwise_or bitwise_xor].each do |operator|
|
|
83
|
+
define_method(operator) do |_other|
|
|
84
|
+
raise ArgumentError, arithmetic_refusal(operator)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ~
|
|
89
|
+
raise ArgumentError, arithmetic_refusal(:~)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def bitwise_not
|
|
93
|
+
raise ArgumentError, arithmetic_refusal(:bitwise_not)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
# nil is left to the comparison itself, which says to use null?, and
|
|
98
|
+
# so is anything the block built rather than wrote as a literal.
|
|
99
|
+
def comparison_value(other)
|
|
100
|
+
return other if other.nil? || other.is_a?(Node) || other.is_a?(::Symbol) ||
|
|
101
|
+
other.is_a?(Arel::Nodes::Node) ||
|
|
102
|
+
other.is_a?(Arel::Attributes::Attribute) ||
|
|
103
|
+
other.is_a?(ActiveRecord::Relation)
|
|
104
|
+
return json_literal(other) if json_value?
|
|
105
|
+
return other if other.is_a?(::String)
|
|
106
|
+
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"dig_text gives text, and comparing it with #{other.inspect} means " \
|
|
109
|
+
"something different on every adapter; cast it to the type meant"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def json_literal(other)
|
|
113
|
+
case other
|
|
114
|
+
when ::String, ::Integer, ::Float, ::BigDecimal, true, false, ::Hash, ::Array
|
|
115
|
+
JsonLiteral.new(other)
|
|
116
|
+
when ::Rational
|
|
117
|
+
raise ArgumentError,
|
|
118
|
+
"a Rational has no exact SQL spelling; to_d says the decimal meant"
|
|
119
|
+
else
|
|
120
|
+
raise ArgumentError,
|
|
121
|
+
"#{json_source} gives JSON, and #{other.inspect} has no JSON " \
|
|
122
|
+
"spelling; dig_text gives the value"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def comparison_set(values)
|
|
127
|
+
case values
|
|
128
|
+
when ActiveRecord::Relation then values
|
|
129
|
+
when ::Range
|
|
130
|
+
In::QuotedRange.new(comparison_value(values.begin),
|
|
131
|
+
comparison_value(values.end), values.exclude_end?)
|
|
132
|
+
else values.map { |value| comparison_value(value) }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def arithmetic_refusal(operator)
|
|
137
|
+
json_value? ?
|
|
138
|
+
"#{json_source} gives JSON, and #{operator} on it means something " \
|
|
139
|
+
"different on every adapter; cast dig_text to the type meant" :
|
|
140
|
+
"dig_text gives text, and #{operator} on it means something " \
|
|
141
|
+
"different on every adapter; cast it to the type meant"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A Ruby value on the JSON side of a comparison, which jsonb and
|
|
146
|
+
# MySQL's JSON type answer alike: numbers compare as numbers and
|
|
147
|
+
# documents structurally. SQLite and MariaDB have only the text of
|
|
148
|
+
# each -- spelling and key order deciding what equality means -- and
|
|
149
|
+
# refuse here. PostgreSQL needs no cast, an untyped literal beside a
|
|
150
|
+
# jsonb operand coercing to jsonb; MySQL is told CAST(... AS JSON),
|
|
151
|
+
# since a bare string beside JSON would be a JSON string, and every
|
|
152
|
+
# string outranks every number in its ordering.
|
|
153
|
+
class JsonLiteral < Node
|
|
154
|
+
# @private
|
|
155
|
+
attr_reader :value
|
|
156
|
+
|
|
157
|
+
def initialize(value)
|
|
158
|
+
@value = value
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @private
|
|
162
|
+
def to_arel(_table, model)
|
|
163
|
+
Dialect.for(model).json_literal(Arel::Nodes.build_quoted(JSON.generate(value)), model)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The JSON operations read a document, and what dig gives is one:
|
|
168
|
+
# `dig(:author).key?(:email)` and `dig(:tags).contains?(...)` are the
|
|
169
|
+
# same question asked of a part of it, and the adapters answer them
|
|
170
|
+
# alike. What dig_text gives is text, and reading that as a document
|
|
171
|
+
# again is where they part company: SQLite parses it back and MySQL
|
|
172
|
+
# takes it as written, where PostgreSQL has no such function for text.
|
|
173
|
+
module JsonDocument
|
|
174
|
+
%i[dig dig_text key? keys contains? bury except].each do |name|
|
|
175
|
+
define_method(name) do |*args|
|
|
176
|
+
unless json_value?
|
|
177
|
+
raise ArgumentError,
|
|
178
|
+
"dig_text gives text, and #{name} reads JSON; dig keeps it"
|
|
179
|
+
end
|
|
180
|
+
super(*args)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# JSON the query computes rather than reads out of a document: always
|
|
186
|
+
# a JSON value, with no dig_text counterpart for JsonComparable's
|
|
187
|
+
# advice to name. Included after JsonComparable, whose own
|
|
188
|
+
# arithmetic_refusal it overrides.
|
|
189
|
+
module ComputedJson
|
|
190
|
+
# @private
|
|
191
|
+
def json_value?
|
|
192
|
+
true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
def arithmetic_refusal(operator)
|
|
197
|
+
"#{json_source} gives JSON, and #{operator} on it means " \
|
|
198
|
+
"something different on every adapter"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Reading inside a JSON document, which is what {Predications#dig} and
|
|
203
|
+
# {Predications#dig_text} build. Every adapter can do it and no two
|
|
204
|
+
# spell it alike: PostgreSQL walks an array of steps, SQLite has the
|
|
205
|
+
# operators with a $ path, and MySQL has the functions -- which is what
|
|
206
|
+
# this uses for that family, since MariaDB answers to the same adapter
|
|
207
|
+
# and has no -> at all.
|
|
208
|
+
#
|
|
209
|
+
# The path is turned into a string either way, so a key with a space or
|
|
210
|
+
# a quote in it travels as itself rather than having to be refused.
|
|
211
|
+
# What a dug value compares with is {JsonComparable}'s to say.
|
|
212
|
+
class JsonPath < Node
|
|
213
|
+
include Predications
|
|
214
|
+
include Arithmetics
|
|
215
|
+
include JsonSteps
|
|
216
|
+
include JsonComparable
|
|
217
|
+
include JsonDocument
|
|
218
|
+
|
|
219
|
+
# @private
|
|
220
|
+
attr_reader :operand, :path
|
|
221
|
+
|
|
222
|
+
def initialize(operand, path, json_value: true)
|
|
223
|
+
@operand = operand
|
|
224
|
+
@path = check_steps(path, "dig")
|
|
225
|
+
@json_value = json_value
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# @private
|
|
229
|
+
def json_value?
|
|
230
|
+
@json_value
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# @private
|
|
234
|
+
def to_arel(table, model)
|
|
235
|
+
Dialect.for(model).json_path(
|
|
236
|
+
to_arel_operand(operand, table, model),
|
|
237
|
+
dollar_path, steps_array, json_value?, model)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
def json_source
|
|
242
|
+
"dig"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Setting a value inside a JSON document, which is what bury does to what
|
|
247
|
+
# dig reads. The document comes back changed rather than being written
|
|
248
|
+
# anywhere; update_all is what writes it.
|
|
249
|
+
class JsonSet < Node
|
|
250
|
+
include Predications
|
|
251
|
+
include JsonSteps
|
|
252
|
+
include JsonComparable
|
|
253
|
+
|
|
254
|
+
# @private
|
|
255
|
+
attr_reader :operand, :path, :value
|
|
256
|
+
|
|
257
|
+
def initialize(operand, path, value)
|
|
258
|
+
@operand = operand
|
|
259
|
+
@path = check_steps(path, "bury")
|
|
260
|
+
@value = value
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Always JSON, which is what the comparison guard asks.
|
|
264
|
+
# @private
|
|
265
|
+
def json_value?
|
|
266
|
+
true
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# @private
|
|
270
|
+
def to_arel(table, model)
|
|
271
|
+
Dialect.for(model).json_set(
|
|
272
|
+
to_arel_operand(operand, table, model),
|
|
273
|
+
steps_array, dollar_path, value,
|
|
274
|
+
(to_arel_operand(value, table, model) if expression?), model)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
def expression?
|
|
279
|
+
value.is_a?(Node) || value.is_a?(::Symbol)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def json_source
|
|
283
|
+
"bury"
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Keys taken out of a JSON document. PostgreSQL subtracts them, the
|
|
288
|
+
# other two remove a path apiece.
|
|
289
|
+
class JsonExcept < Node
|
|
290
|
+
include Predications
|
|
291
|
+
include JsonSteps
|
|
292
|
+
include JsonComparable
|
|
293
|
+
|
|
294
|
+
# @private
|
|
295
|
+
attr_reader :operand, :keys
|
|
296
|
+
|
|
297
|
+
def initialize(operand, keys)
|
|
298
|
+
@operand = operand
|
|
299
|
+
@keys = check_keys(keys)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# @private
|
|
303
|
+
def json_value?
|
|
304
|
+
true
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# @private
|
|
308
|
+
def to_arel(table, model)
|
|
309
|
+
Dialect.for(model).json_remove(
|
|
310
|
+
to_arel_operand(operand, table, model),
|
|
311
|
+
keys.map { |key| "$#{dollar_step(key)}" },
|
|
312
|
+
steps_array(keys), model)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
316
|
+
# Keys, as Hash#except takes them: an index into an array is not what
|
|
317
|
+
# the name says anywhere, and is bury's business through a path.
|
|
318
|
+
def check_keys(keys)
|
|
319
|
+
raise ArgumentError, "except needs a key" if keys.empty?
|
|
320
|
+
keys.each do |key|
|
|
321
|
+
next if key.is_a?(::String) || key.is_a?(::Symbol)
|
|
322
|
+
raise ArgumentError,
|
|
323
|
+
"except takes keys of the document, not #{key.inspect}"
|
|
324
|
+
end
|
|
325
|
+
keys
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def json_source
|
|
329
|
+
"except"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# JSON containment: whether the document holds what is given.
|
|
334
|
+
class JsonContains < Predicate
|
|
335
|
+
# @private
|
|
336
|
+
attr_reader :operand, :value
|
|
337
|
+
|
|
338
|
+
def initialize(operand, value)
|
|
339
|
+
@operand = operand
|
|
340
|
+
@value = value
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# @private
|
|
344
|
+
def to_arel(table, model)
|
|
345
|
+
Dialect.for(model).json_contains(
|
|
346
|
+
to_arel_operand(operand, table, model),
|
|
347
|
+
Arel::Nodes.build_quoted(JSON.generate(value)), model)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Whether a key is in the document. PostgreSQL's spelling is the ?
|
|
352
|
+
# operator rather than jsonb_exists, the function it is shorthand for,
|
|
353
|
+
# because a GIN index matches the operator and never the function. A
|
|
354
|
+
# ? is a bind placeholder only to sanitize_sql, which none of the SQL
|
|
355
|
+
# written here passes through.
|
|
356
|
+
class JsonHasKey < Predicate
|
|
357
|
+
# @private
|
|
358
|
+
attr_reader :operand, :key
|
|
359
|
+
|
|
360
|
+
def initialize(operand, key)
|
|
361
|
+
@operand = operand
|
|
362
|
+
@key = key
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# @private
|
|
366
|
+
def to_arel(table, model)
|
|
367
|
+
Dialect.for(model).json_has_key(
|
|
368
|
+
to_arel_operand(operand, table, model),
|
|
369
|
+
Arel::Nodes.build_quoted(key.to_s),
|
|
370
|
+
Arel::Nodes.build_quoted("$.#{key}"), model)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# The keys of a JSON document, as Hash#keys gives them: a JSON array.
|
|
375
|
+
# Only the MySQL family has a function for it; the other two reach the
|
|
376
|
+
# same array through a subquery over their key-listing functions. The
|
|
377
|
+
# type guard is what makes all four answer alike: the keys of what is
|
|
378
|
+
# not an object are NULL rather than SQLite's array indices or
|
|
379
|
+
# PostgreSQL's error, and the keys of {} are [] rather than
|
|
380
|
+
# PostgreSQL's NULL, jsonb_agg over no rows.
|
|
381
|
+
class JsonKeys < Node
|
|
382
|
+
include Predications
|
|
383
|
+
include JsonComparable
|
|
384
|
+
include ComputedJson
|
|
385
|
+
|
|
386
|
+
# @private
|
|
387
|
+
attr_reader :operand
|
|
388
|
+
|
|
389
|
+
def initialize(operand)
|
|
390
|
+
@operand = operand
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# @private
|
|
394
|
+
def to_arel(table, model)
|
|
395
|
+
Dialect.for(model).json_keys(to_arel_operand(operand, table, model), model)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
private
|
|
399
|
+
def json_source
|
|
400
|
+
"keys"
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# A JSON document built in the row: json_array from the values given,
|
|
405
|
+
# json_object from a Ruby hash. SQLite and the MySQL family both say
|
|
406
|
+
# the standard names; PostgreSQL is asked to build jsonb, whose
|
|
407
|
+
# documents the other JSON operations here read.
|
|
408
|
+
class JsonBuild < Node
|
|
409
|
+
include Predications
|
|
410
|
+
include JsonComparable
|
|
411
|
+
include ComputedJson
|
|
412
|
+
|
|
413
|
+
# @private
|
|
414
|
+
attr_reader :kind, :values
|
|
415
|
+
|
|
416
|
+
def initialize(kind, values)
|
|
417
|
+
@kind = kind
|
|
418
|
+
@values = kind == :object ? check_pairs(values) : values
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# @private
|
|
422
|
+
def to_arel(table, model)
|
|
423
|
+
dialect = Dialect.for(model)
|
|
424
|
+
if kind == :array
|
|
425
|
+
dialect.json_build(:array, nil,
|
|
426
|
+
values.map { |value| build_argument(value, dialect, table, model) }, model)
|
|
427
|
+
else
|
|
428
|
+
dialect.json_build(:object, values.keys.map(&:to_s),
|
|
429
|
+
values.values.map { |value| build_argument(value, dialect, table, model) }, model)
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
private
|
|
434
|
+
# An expression is itself and a bare scalar is quoted; a document or
|
|
435
|
+
# a boolean the dialect embeds as JSON, as bury takes it.
|
|
436
|
+
def build_argument(value, dialect, table, model)
|
|
437
|
+
case value
|
|
438
|
+
when Node, ::Symbol then to_arel_operand(value, table, model)
|
|
439
|
+
when ::Hash, ::Array, true, false then dialect.json_build_argument(value, model)
|
|
440
|
+
when ::Rational then quote_number(value)
|
|
441
|
+
else Arel::Nodes.build_quoted(value)
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# The keys come from Ruby as Hash keys rather than alternating with
|
|
446
|
+
# the values as SQL has them, which is what keeps a bare symbol
|
|
447
|
+
# free to mean a column on the value side. Anything but a name is
|
|
448
|
+
# refused here, before the adapters answer a NULL key three ways.
|
|
449
|
+
def check_pairs(pairs)
|
|
450
|
+
unless pairs.is_a?(::Hash)
|
|
451
|
+
raise ArgumentError,
|
|
452
|
+
"json_object takes a hash of keys to values, not #{pairs.inspect}"
|
|
453
|
+
end
|
|
454
|
+
pairs.each_key do |key|
|
|
455
|
+
next if key.is_a?(::String) || key.is_a?(::Symbol)
|
|
456
|
+
raise ArgumentError,
|
|
457
|
+
"a key of json_object is a string or a symbol, not #{key.inspect}"
|
|
458
|
+
end
|
|
459
|
+
pairs
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def json_source
|
|
463
|
+
"json_#{kind}"
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Rows gathered into one JSON document: json_arrayagg collects a value
|
|
468
|
+
# from each row into an array, json_objectagg a key and a value into an
|
|
469
|
+
# object. Every adapter has the pair under a name of its own; what
|
|
470
|
+
# PostgreSQL gets is the jsonb one, whose documents the other JSON
|
|
471
|
+
# operations here read.
|
|
472
|
+
class JsonAggregate < Node
|
|
473
|
+
include Predications
|
|
474
|
+
include JsonComparable
|
|
475
|
+
include ComputedJson
|
|
476
|
+
include Windowing
|
|
477
|
+
|
|
478
|
+
# @private
|
|
479
|
+
attr_reader :kind, :operands, :condition
|
|
480
|
+
|
|
481
|
+
def initialize(kind, operands, condition: nil)
|
|
482
|
+
@kind = kind
|
|
483
|
+
@operands = operands
|
|
484
|
+
@condition = condition
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# `FILTER (WHERE condition)`, as {Aggregate#filter}; refused on the
|
|
488
|
+
# MySQL family, where the CASE that stands in would leave a JSON null
|
|
489
|
+
# for every row it drops.
|
|
490
|
+
# @return [AST::JsonAggregate]
|
|
491
|
+
def filter(condition = nil, &block)
|
|
492
|
+
JsonAggregate.new(kind, operands,
|
|
493
|
+
condition: Case.argument(:filter, condition, block))
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Over asks here before writing a window, since a family may take
|
|
497
|
+
# every other aggregate as one but not these two.
|
|
498
|
+
# @private
|
|
499
|
+
def check_window(model)
|
|
500
|
+
Dialect.for(model).check_json_aggregate_window(json_source, model)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# @private
|
|
504
|
+
def to_arel(table, model)
|
|
505
|
+
dialect = Dialect.for(model)
|
|
506
|
+
call = Arel::Nodes::NamedFunction.new(
|
|
507
|
+
dialect.json_aggregate_name(kind),
|
|
508
|
+
operands.map { |operand| to_arel_argument(operand, table, model) })
|
|
509
|
+
return call unless condition
|
|
510
|
+
|
|
511
|
+
# The CASE that stands in for FILTER elsewhere hands the aggregate a
|
|
512
|
+
# NULL for every row the condition misses, and these two keep a NULL
|
|
513
|
+
# -- as JSON null -- rather than passing over it, so it is refused.
|
|
514
|
+
unless dialect.json_aggregate_filter_supported?
|
|
515
|
+
raise NotImplementedError,
|
|
516
|
+
"#{json_source}.filter has no equivalent on " \
|
|
517
|
+
"#{model.connection_db_config.adapter}; a CASE would leave a " \
|
|
518
|
+
"null in the document for every row it drops"
|
|
519
|
+
end
|
|
520
|
+
call.filter(condition.to_arel(table, model))
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
private
|
|
524
|
+
def json_source
|
|
525
|
+
"json_#{kind}"
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
end
|