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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +1 -1
  3. data/README.md +19 -22
  4. data/activerecord-refined.gemspec +4 -3
  5. data/docs/conditions.md +3 -1
  6. data/docs/ctes.md +2 -2
  7. data/docs/expressions.md +37 -20
  8. data/docs/functions.md +14 -10
  9. data/docs/index.md +2 -17
  10. data/docs/windows.md +2 -2
  11. data/examples/expressions.rb +21 -15
  12. data/lib/active_record/refined/ast/arithmetics.rb +137 -0
  13. data/lib/active_record/refined/ast/conditions.rb +451 -0
  14. data/lib/active_record/refined/ast/expressions.rb +304 -0
  15. data/lib/active_record/refined/ast/functions.rb +254 -0
  16. data/lib/active_record/refined/ast/grouping.rb +63 -0
  17. data/lib/active_record/refined/ast/json.rb +530 -0
  18. data/lib/active_record/refined/ast/node.rb +298 -0
  19. data/lib/active_record/refined/ast/ordering.rb +104 -0
  20. data/lib/active_record/refined/ast/predications.rb +384 -0
  21. data/lib/active_record/refined/ast/windows.rb +126 -0
  22. data/lib/active_record/refined/ast.rb +39 -2364
  23. data/lib/active_record/refined/block_context.rb +671 -0
  24. data/lib/active_record/refined/block_syntax.rb +128 -0
  25. data/lib/active_record/refined/dialect/mysql_compat.rb +16 -0
  26. data/lib/active_record/refined/dialect/mysqlish_json_functions.rb +45 -0
  27. data/lib/active_record/refined/dialect/oracle.rb +1 -7
  28. data/lib/active_record/refined/dialect/postgresql.rb +8 -0
  29. data/lib/active_record/refined/dialect/sql_server.rb +29 -5
  30. data/lib/active_record/refined/dialect/sqlite.rb +23 -0
  31. data/lib/active_record/refined/dialect.rb +132 -66
  32. data/lib/active_record/refined/query_methods.rb +444 -0
  33. data/lib/{activerecord-refined → active_record/refined}/version.rb +3 -2
  34. data/lib/active_record/refined/writes.rb +72 -0
  35. data/lib/active_record/refined.rb +7 -1270
  36. data/lib/activerecord-refined.rb +0 -4
  37. metadata +36 -5
@@ -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