activerecord-refined 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/sandbox.yml +1 -1
- data/README.md +78 -7
- data/activerecord-refined.gemspec +3 -2
- data/examples/ctes.rb +39 -8
- data/lib/active_record/refined/ast.rb +62 -9
- data/lib/active_record/refined.rb +37 -3
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +4 -0
- data/test/test_block_syntax.rb +163 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98f198f59d3bb4b6f4c51a16940d4e7ee44b890aa7e033a327a650b73cb3dfbd
|
|
4
|
+
data.tar.gz: 5fb7cdd8964b82d6bb14ffdfecf1400ea875f1c5bc12d7fa4645c431caf47d19
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ea7a29d21b31e3bf5f262a54eacb8e4e27590d9031f560d8de635da958cf91e6c5ef0872e77caf7b4e32907d2985f13fcd2ff8492ffbeda0edff1d8b7f658f4
|
|
7
|
+
data.tar.gz: 4949bb4ce9b0e4cd0f964e7f2759460a72707734a56d746cbf1221f80f9f7a87161c4a494c0226b880d7ad905e6c83933b29254c5ce3e59c41de415339557f56
|
data/README.md
CHANGED
|
@@ -87,6 +87,24 @@ Author.where { :country.in?(%w[JP US]) } # IN
|
|
|
87
87
|
Author.where { :country.null? } # IS NULL
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
`!` negates any of these. Where SQL has a negative of its own, so does the
|
|
91
|
+
block, which is the same rows written the way they would be written by hand:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
Author.where { :country.not_null? } # IS NOT NULL
|
|
95
|
+
Author.where { :country.not_in?(%w[JP US]) } # NOT IN
|
|
96
|
+
Author.where { :age.not_between?(20, 40) } # not between 20 and 40
|
|
97
|
+
Author.where { :name.not_like?('A%') } # NOT LIKE
|
|
98
|
+
Author.where { :name.not_ilike?('a%') } # NOT ILIKE / NOT LIKE
|
|
99
|
+
|
|
100
|
+
Author.where { !:name.start_with?('A') } # NOT (name LIKE 'A%')
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Nothing turns on the choice: `NOT (country IS NULL)` and `country IS NOT NULL`
|
|
104
|
+
select the same rows, NULLs included. `not_between?` is the one whose SQL
|
|
105
|
+
looks unlike its name — Arel writes it as the two comparisons, `age < 20 OR
|
|
106
|
+
age > 40`, which is again the same rows.
|
|
107
|
+
|
|
90
108
|
`in?` also takes a relation as a subquery. Without an explicit select list the
|
|
91
109
|
subquery selects the relation's primary key, the same way ActiveRecord's own
|
|
92
110
|
`where(id: relation)` does:
|
|
@@ -247,23 +265,53 @@ ActiveRecord's `with` and `with_recursive` need nothing from this gem: a CTE
|
|
|
247
265
|
is joined by name like any other table, so its `ON` clause is a block, where
|
|
248
266
|
Rails' own documentation reaches for a string join.
|
|
249
267
|
|
|
250
|
-
`
|
|
251
|
-
|
|
268
|
+
`from_cte` takes the CTE's name and selects it under the model's own table
|
|
269
|
+
name, so the model's columns resolve:
|
|
252
270
|
|
|
253
271
|
```ruby
|
|
254
272
|
Node.with_recursive(
|
|
255
273
|
tree: [
|
|
256
|
-
Node.where { :id == root.id }
|
|
257
|
-
|
|
274
|
+
Node.where { :id == root.id }.
|
|
275
|
+
select { [:id, :name, :parent_id, 0.as(:depth)] },
|
|
276
|
+
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
|
|
277
|
+
select { [:nodes[:id], :nodes[:name], :nodes[:parent_id],
|
|
278
|
+
(:tree[:depth] + 1).as(:depth)] },
|
|
258
279
|
]
|
|
259
|
-
).
|
|
280
|
+
).from_cte(:tree)
|
|
260
281
|
# WITH RECURSIVE "tree" AS (
|
|
261
|
-
# SELECT "nodes"
|
|
282
|
+
# SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id", 0 AS depth
|
|
283
|
+
# FROM "nodes" WHERE "nodes"."id" = 1
|
|
262
284
|
# UNION ALL
|
|
263
|
-
# SELECT "nodes"
|
|
285
|
+
# SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id",
|
|
286
|
+
# ("tree"."depth" + 1) AS depth
|
|
287
|
+
# FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
|
|
264
288
|
# ) SELECT "nodes".* FROM "tree" AS "nodes"
|
|
265
289
|
```
|
|
266
290
|
|
|
291
|
+
The anchor starts the count and the recursive member adds one, which is how
|
|
292
|
+
the shape of a tree comes out of a flat table. The `0` is a value rather than
|
|
293
|
+
SQL — see [`value`](#aggregates-functions-and-aliases) below for why a number
|
|
294
|
+
can say `.as` directly.
|
|
295
|
+
|
|
296
|
+
The alias on the last line is there for ActiveRecord's sake, not SQL's:
|
|
297
|
+
written by hand that line would be `SELECT * FROM tree`. ActiveRecord goes on qualifying
|
|
298
|
+
columns with the model's table name, so without the alias that name is not in
|
|
299
|
+
the query and anything qualifying a column fails:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
Node.with_recursive(tree: [...]).from(:tree).where(name: 'root')
|
|
303
|
+
# PG::UndefinedTable: missing FROM-clause entry for table "nodes"
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Since the model's name is the only one that works, `from_cte` takes it from
|
|
307
|
+
the model rather than asking. `from(:tree, as: :nodes)` is the same thing
|
|
308
|
+
spelled out, and is what to reach for when the name wanted is not the model's.
|
|
309
|
+
|
|
310
|
+
What makes this worth spelling out is how selectively it breaks. `count`,
|
|
311
|
+
`order` and `select` never qualify, so they work without the alias on every
|
|
312
|
+
adapter; it is `where` and `find_by` that stop. A query can therefore look
|
|
313
|
+
right until the day a condition is added to it.
|
|
314
|
+
|
|
267
315
|
A non-recursive CTE joins the same way:
|
|
268
316
|
|
|
269
317
|
```ruby
|
|
@@ -358,6 +406,29 @@ written into the SQL as given — so those two have to be plain names,
|
|
|
358
406
|
optionally qualified by a schema in `fn`'s case. Anything else raises
|
|
359
407
|
`ArgumentError` rather than reaching the query.
|
|
360
408
|
|
|
409
|
+
One place asks for a value to be said out loud: the top of a select list.
|
|
410
|
+
Everywhere else a bare literal is already a value — `where { :age > 18 }`,
|
|
411
|
+
`concat(:name, '-x')` — but ActiveRecord reads a string in `select` as SQL,
|
|
412
|
+
so `value` is how you ask for the other meaning. It carries the predications
|
|
413
|
+
and arithmetic with it, so a literal can be compared and combined like
|
|
414
|
+
anything else. Numbers have a shorthand, since nothing else could be meant by
|
|
415
|
+
one:
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
Node.select { [:id, value(0).as(:depth)] }
|
|
419
|
+
# SELECT "nodes"."id", 0 AS depth FROM "nodes"
|
|
420
|
+
|
|
421
|
+
Node.select { [:id, 0.as(:depth)] } # the same thing
|
|
422
|
+
|
|
423
|
+
Post.select { [:title, value('draft').as(:state)] }
|
|
424
|
+
# SELECT "posts"."title", 'draft' AS state FROM "posts"
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The shorthand is `Integer` and `Float` only. `String` keeps its two meanings —
|
|
428
|
+
SQL in a select list, a value everywhere else — and refining it would make the
|
|
429
|
+
same literal mean one thing or the other depending on whether it had been sent
|
|
430
|
+
a message.
|
|
431
|
+
|
|
361
432
|
`fn` reaches functions without a method of their own. Its name is emitted as
|
|
362
433
|
written, so a case-sensitive one can be spelled exactly:
|
|
363
434
|
|
|
@@ -13,8 +13,9 @@ Gem::Specification.new do |gem|
|
|
|
13
13
|
gem.homepage = 'https://github.com/shugo/activerecord-refined'
|
|
14
14
|
|
|
15
15
|
# sandbox/ is a site, not part of the library: its Gemfile.lock and
|
|
16
|
-
# package-lock.json have no business in anyone's bundle.
|
|
17
|
-
|
|
16
|
+
# package-lock.json have no business in anyone's bundle. CLAUDE.md is
|
|
17
|
+
# addressed to whoever is working on the repository, not to anyone using it.
|
|
18
|
+
gem.files = `git ls-files`.split($/).grep_v(%r{^sandbox/|^CLAUDE\.md$})
|
|
18
19
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
|
19
20
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
|
20
21
|
gem.require_paths = ["lib"]
|
data/examples/ctes.rb
CHANGED
|
@@ -31,23 +31,54 @@ Product.create!(name: 'apple', category_id: groceries.id, price: 2)
|
|
|
31
31
|
|
|
32
32
|
# 1. Recursive CTE: every category below 'electronics', itself included.
|
|
33
33
|
# The recursive member joins the CTE by name, so its ON clause is a block
|
|
34
|
-
# rather than the string join Rails' own documentation reaches for.
|
|
35
|
-
# then selects the CTE under the model's table name
|
|
36
|
-
#
|
|
34
|
+
# rather than the string join Rails' own documentation reaches for.
|
|
35
|
+
# `from_cte` then selects the CTE under the model's table name.
|
|
36
|
+
#
|
|
37
|
+
# That alias is ActiveRecord's requirement rather than SQL's: by hand the
|
|
38
|
+
# last line would be `SELECT * FROM tree`. ActiveRecord keeps qualifying
|
|
39
|
+
# columns with the model's table name, so without it `where` and `find_by`
|
|
40
|
+
# look for a table the query does not have. `count`, `order` and `select`
|
|
41
|
+
# never qualify and would work either way, which makes it easy to miss.
|
|
37
42
|
subtree =
|
|
38
43
|
Category.with_recursive(
|
|
39
44
|
tree: [
|
|
40
45
|
Category.where { :id == electronics.id },
|
|
41
46
|
Category.joins(:tree) { :categories[:parent_id] == :tree[:id] },
|
|
42
47
|
]
|
|
43
|
-
).
|
|
48
|
+
).from_cte(:tree)
|
|
44
49
|
|
|
45
50
|
puts '--- 1. Recursive CTE walking a category tree ---'
|
|
46
51
|
puts subtree.to_sql
|
|
47
52
|
puts subtree.order { :name }.pluck(:name).inspect
|
|
48
53
|
puts
|
|
49
54
|
|
|
50
|
-
# 2.
|
|
55
|
+
# 2. One walk over every tree, carrying down where each row started and how
|
|
56
|
+
# far it has come. Which tree is wanted is then an ordinary `where`, asked
|
|
57
|
+
# afterwards, so the CTE is not rebuilt for each root. 0 is a value rather
|
|
58
|
+
# than SQL: at the top of a select list a bare string would be SQL, so
|
|
59
|
+
# numbers say `.as` directly and anything else says `value(...).as`.
|
|
60
|
+
forest =
|
|
61
|
+
Category.with_recursive(
|
|
62
|
+
tree: [
|
|
63
|
+
Category.where { :parent_id.null? }.
|
|
64
|
+
select { [:id, :name, :parent_id, :id.as(:root_id), 0.as(:depth)] },
|
|
65
|
+
Category.joins(:tree) { :categories[:parent_id] == :tree[:id] }.
|
|
66
|
+
select { [:categories[:id], :categories[:name], :categories[:parent_id],
|
|
67
|
+
:tree[:root_id], (:tree[:depth] + 1).as(:depth)] },
|
|
68
|
+
]
|
|
69
|
+
).from_cte(:tree).order { [:depth, :id] }
|
|
70
|
+
|
|
71
|
+
puts '--- 2. Recursive CTE carrying the root and the depth down ---'
|
|
72
|
+
puts forest.to_sql
|
|
73
|
+
puts forest.map {|c| [c.name, c.root_id, c.depth] }.inspect
|
|
74
|
+
|
|
75
|
+
# The alias from_cte puts on the CTE is what lets this `where` qualify
|
|
76
|
+
# root_id; without it the column would be looked for in a table the query no
|
|
77
|
+
# longer has.
|
|
78
|
+
puts forest.where { :root_id == electronics.id }.map {|c| [c.name, c.depth] }.inspect
|
|
79
|
+
puts
|
|
80
|
+
|
|
81
|
+
# 3. The same CTE as a subquery: products anywhere under 'electronics'.
|
|
51
82
|
# The outer query joins the CTE by name like any other table.
|
|
52
83
|
products_below =
|
|
53
84
|
Product.with_recursive(
|
|
@@ -57,12 +88,12 @@ products_below =
|
|
|
57
88
|
]
|
|
58
89
|
).joins(:tree) { :tree[:id] == :products[:category_id] }
|
|
59
90
|
|
|
60
|
-
puts '---
|
|
91
|
+
puts '--- 3. Recursive CTE joined from the outer query ---'
|
|
61
92
|
puts products_below.to_sql
|
|
62
93
|
puts products_below.order { :name }.pluck(:name).inspect
|
|
63
94
|
puts
|
|
64
95
|
|
|
65
|
-
#
|
|
96
|
+
# 4. A plain CTE, named once and used twice: categories that hold something
|
|
66
97
|
# expensive, and the count of products in each.
|
|
67
98
|
expensive =
|
|
68
99
|
Category.with(pricey: Product.where { :price >= 100 }).
|
|
@@ -76,7 +107,7 @@ expensive =
|
|
|
76
107
|
]
|
|
77
108
|
}
|
|
78
109
|
|
|
79
|
-
puts '---
|
|
110
|
+
puts '--- 4. Plain CTE joined and aggregated ---'
|
|
80
111
|
puts expensive.to_sql
|
|
81
112
|
puts expensive.map {|c| [c.category, c.pricey_count, c.top_price] }.inspect
|
|
82
113
|
puts
|
|
@@ -63,26 +63,50 @@ module ActiveRecord
|
|
|
63
63
|
Match.new(self, pattern, negated: true)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# `!` negates any predicate, so these are here for the four that SQL
|
|
67
|
+
# spells for itself: IS NOT NULL rather than NOT (... IS NULL), and
|
|
68
|
+
# likewise NOT IN and NOT LIKE. They mean the same thing either way,
|
|
69
|
+
# including when the column is NULL; what they save is the reading.
|
|
66
70
|
def null?
|
|
67
71
|
Comparison.new(self, :==, nil)
|
|
68
72
|
end
|
|
69
73
|
|
|
74
|
+
def not_null?
|
|
75
|
+
Comparison.new(self, :!=, nil)
|
|
76
|
+
end
|
|
77
|
+
|
|
70
78
|
def in?(values)
|
|
71
79
|
In.new(self, values)
|
|
72
80
|
end
|
|
73
81
|
|
|
82
|
+
def not_in?(values)
|
|
83
|
+
In.new(self, values, negated: true)
|
|
84
|
+
end
|
|
85
|
+
|
|
74
86
|
def between?(min, max)
|
|
75
87
|
In.new(self, min..max)
|
|
76
88
|
end
|
|
77
89
|
|
|
90
|
+
def not_between?(min, max)
|
|
91
|
+
In.new(self, min..max, negated: true)
|
|
92
|
+
end
|
|
93
|
+
|
|
78
94
|
def like?(pattern)
|
|
79
95
|
Like.new(self, pattern)
|
|
80
96
|
end
|
|
81
97
|
|
|
98
|
+
def not_like?(pattern)
|
|
99
|
+
Like.new(self, pattern, negated: true)
|
|
100
|
+
end
|
|
101
|
+
|
|
82
102
|
def ilike?(pattern)
|
|
83
103
|
Like.new(self, pattern, nil, case_sensitive: false)
|
|
84
104
|
end
|
|
85
105
|
|
|
106
|
+
def not_ilike?(pattern)
|
|
107
|
+
Like.new(self, pattern, nil, case_sensitive: false, negated: true)
|
|
108
|
+
end
|
|
109
|
+
|
|
86
110
|
# Case-insensitive equality, folded on both sides rather than left to
|
|
87
111
|
# the collation, so it means the same thing on every adapter.
|
|
88
112
|
def casecmp?(value)
|
|
@@ -246,6 +270,29 @@ module ActiveRecord
|
|
|
246
270
|
end
|
|
247
271
|
end
|
|
248
272
|
|
|
273
|
+
# A literal standing where an expression would: `select { value(0).as(:depth) }`.
|
|
274
|
+
#
|
|
275
|
+
# Values reach the SQL quoted wherever they appear as an operand, but the
|
|
276
|
+
# top of a select list is ActiveRecord's, and a bare string there is SQL
|
|
277
|
+
# rather than a string. Saying `value` is how you ask for the other
|
|
278
|
+
# meaning, and it carries the predications with it, so a literal can be
|
|
279
|
+
# compared and combined like anything else.
|
|
280
|
+
class Value < Node
|
|
281
|
+
include Predications
|
|
282
|
+
include Arithmetics
|
|
283
|
+
include Aggregations
|
|
284
|
+
|
|
285
|
+
attr_reader :value
|
|
286
|
+
|
|
287
|
+
def initialize(value)
|
|
288
|
+
@value = value
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def to_arel(_table)
|
|
292
|
+
Arel::Nodes.build_quoted(value)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
249
296
|
class Column < Node
|
|
250
297
|
include Predications
|
|
251
298
|
include Arithmetics
|
|
@@ -482,19 +529,21 @@ module ActiveRecord
|
|
|
482
529
|
# IN for a list of values, BETWEEN for a range, IN (SELECT ...) for a
|
|
483
530
|
# relation.
|
|
484
531
|
class In < Predicate
|
|
485
|
-
attr_reader :operand, :values
|
|
532
|
+
attr_reader :operand, :values, :negated
|
|
486
533
|
|
|
487
|
-
def initialize(operand, values)
|
|
534
|
+
def initialize(operand, values, negated: false)
|
|
488
535
|
@operand = operand
|
|
489
536
|
@values = values
|
|
537
|
+
@negated = negated
|
|
490
538
|
end
|
|
491
539
|
|
|
492
540
|
def to_arel(table)
|
|
493
541
|
arel_operand = to_arel_operand(operand, table)
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
542
|
+
if values.is_a?(Range)
|
|
543
|
+
arel_operand.public_send(negated ? :not_between : :between, values)
|
|
544
|
+
else
|
|
545
|
+
arg = values.is_a?(ActiveRecord::Relation) ? subquery(values) : values
|
|
546
|
+
arel_operand.public_send(negated ? :not_in : :in, arg)
|
|
498
547
|
end
|
|
499
548
|
end
|
|
500
549
|
|
|
@@ -555,19 +604,23 @@ module ActiveRecord
|
|
|
555
604
|
inject {|left, right| Or.new(left, right) }
|
|
556
605
|
end
|
|
557
606
|
|
|
558
|
-
attr_reader :operand, :pattern, :escape, :case_sensitive
|
|
607
|
+
attr_reader :operand, :pattern, :escape, :case_sensitive, :negated
|
|
559
608
|
|
|
560
|
-
def initialize(operand, pattern, escape = nil, case_sensitive: true
|
|
609
|
+
def initialize(operand, pattern, escape = nil, case_sensitive: true,
|
|
610
|
+
negated: false)
|
|
561
611
|
@operand = operand
|
|
562
612
|
@pattern = pattern
|
|
563
613
|
@escape = escape
|
|
564
614
|
@case_sensitive = case_sensitive
|
|
615
|
+
@negated = negated
|
|
565
616
|
end
|
|
566
617
|
|
|
567
618
|
def to_arel(table)
|
|
568
619
|
# Arel matches case-insensitively unless told otherwise, which is
|
|
569
620
|
# what picks ILIKE over LIKE on PostgreSQL.
|
|
570
|
-
to_arel_operand(operand, table).
|
|
621
|
+
to_arel_operand(operand, table).
|
|
622
|
+
public_send(negated ? :does_not_match : :matches,
|
|
623
|
+
pattern, escape, case_sensitive)
|
|
571
624
|
end
|
|
572
625
|
end
|
|
573
626
|
|
|
@@ -22,6 +22,18 @@ module ActiveRecord
|
|
|
22
22
|
AST::Column.new(self, column_name)
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
# Shorthand for `value(0).as(:depth)` and the like. Numbers only: a
|
|
27
|
+
# string in a select list already means SQL rather than a string, so
|
|
28
|
+
# giving String this would make the same literal mean two things
|
|
29
|
+
# depending on whether it had been sent a message.
|
|
30
|
+
[Integer, Float].each do |klass|
|
|
31
|
+
refine klass do
|
|
32
|
+
def as(alias_name)
|
|
33
|
+
AST::As.new(AST::Value.new(self), alias_name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
25
37
|
end
|
|
26
38
|
|
|
27
39
|
class BlockContext
|
|
@@ -164,6 +176,17 @@ module ActiveRecord
|
|
|
164
176
|
AST::Exists.new(relation)
|
|
165
177
|
end
|
|
166
178
|
|
|
179
|
+
# A literal where an expression is expected, quoted like any other value:
|
|
180
|
+
#
|
|
181
|
+
# select { [:id, value(0).as(:depth)] }
|
|
182
|
+
#
|
|
183
|
+
# Needed because the top of a select list is ActiveRecord's, and a bare
|
|
184
|
+
# string there is SQL rather than a string. Numbers have a shorthand --
|
|
185
|
+
# `0.as(:depth)` -- since nothing else could be meant by one.
|
|
186
|
+
def value(literal)
|
|
187
|
+
AST::Value.new(literal)
|
|
188
|
+
end
|
|
189
|
+
|
|
167
190
|
private
|
|
168
191
|
|
|
169
192
|
def function_name(name, functions)
|
|
@@ -230,9 +253,8 @@ module ActiveRecord
|
|
|
230
253
|
end
|
|
231
254
|
|
|
232
255
|
# A symbol names a table, which ActiveRecord's own from only takes as a
|
|
233
|
-
# string. With `as` it is selected under another name
|
|
234
|
-
#
|
|
235
|
-
# with_recursive(tree: [...]).from(:tree, as: :nodes)
|
|
256
|
+
# string. With `as` it is selected under another name; when that name
|
|
257
|
+
# is the model's own, from_cte says the same thing without repeating it.
|
|
236
258
|
def from(value, subquery_name = nil, as: nil)
|
|
237
259
|
unless value.is_a?(Symbol)
|
|
238
260
|
if as
|
|
@@ -245,6 +267,18 @@ module ActiveRecord
|
|
|
245
267
|
super(arel_table, subquery_name)
|
|
246
268
|
end
|
|
247
269
|
|
|
270
|
+
# Selects a CTE in place of the model's own table. The alias is not a
|
|
271
|
+
# choice -- ActiveRecord keeps qualifying columns with the table name,
|
|
272
|
+
# so the model's is the only name that works -- which is why it is
|
|
273
|
+
# taken from the model rather than asked for:
|
|
274
|
+
# with_recursive(tree: [...]).from_cte(:tree)
|
|
275
|
+
def from_cte(name)
|
|
276
|
+
unless name.is_a?(Symbol)
|
|
277
|
+
raise ArgumentError, "from_cte takes the CTE's name as a symbol"
|
|
278
|
+
end
|
|
279
|
+
from(name, as: klass.table_name)
|
|
280
|
+
end
|
|
281
|
+
|
|
248
282
|
# `as` names the table within the query, which is what makes a self
|
|
249
283
|
# join expressible: joins(:employees, as: :managers) { ... }.
|
|
250
284
|
def joins(*args, as: nil, &block)
|
data/lib/activerecord-refined.rb
CHANGED
|
@@ -5,3 +5,7 @@ require 'active_record/refined/ast'
|
|
|
5
5
|
require 'active_record/refined'
|
|
6
6
|
|
|
7
7
|
ActiveRecord::QueryMethods.prepend ActiveRecord::Refined::QueryMethods
|
|
8
|
+
|
|
9
|
+
# The methods above are ActiveRecord's own, so a model already forwards them
|
|
10
|
+
# to its relation. from_cte is new, and has to be added to that list itself.
|
|
11
|
+
ActiveRecord::Base.singleton_class.delegate :from_cte, to: :all
|
data/test/test_block_syntax.rb
CHANGED
|
@@ -91,11 +91,22 @@ class TestBlockSyntax < Minitest::Test
|
|
|
91
91
|
assert_equal(['Alice'], User.where { :name.casecmp?('aLiCe') }.pluck(:name))
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
def
|
|
94
|
+
def test_bang_negates_like
|
|
95
95
|
assert_sql(/WHERE NOT \("users"."name" LIKE 'tender%'\)/,
|
|
96
96
|
User.where { !:name.like?('tender%') }.to_sql)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
+
def test_not_like
|
|
100
|
+
assert_sql(/WHERE "users"."name" NOT LIKE 'tender%'/,
|
|
101
|
+
User.where { :name.not_like?('tender%') }.to_sql)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def test_not_ilike
|
|
105
|
+
expected = ADAPTER == 'postgresql' ? 'ILIKE' : 'LIKE'
|
|
106
|
+
assert_sql(/WHERE "users"."name" NOT #{expected} 'tender%'/,
|
|
107
|
+
User.where { :name.not_ilike?('tender%') }.to_sql)
|
|
108
|
+
end
|
|
109
|
+
|
|
99
110
|
def test_start_with
|
|
100
111
|
assert_sql(/WHERE "users"."name" LIKE 'tender%' ESCAPE '\\'/,
|
|
101
112
|
User.where { :name.start_with?('tender') }.to_sql)
|
|
@@ -298,11 +309,23 @@ class TestBlockSyntax < Minitest::Test
|
|
|
298
309
|
User.where { :age.in?(18...65) }.to_sql)
|
|
299
310
|
end
|
|
300
311
|
|
|
301
|
-
def
|
|
312
|
+
def test_bang_negates_between
|
|
302
313
|
assert_sql(/WHERE NOT \("users"."age" BETWEEN 18 AND 65\)/,
|
|
303
314
|
User.where { !:age.between?(18, 65) }.to_sql)
|
|
304
315
|
end
|
|
305
316
|
|
|
317
|
+
# Arel spells the negation as the two comparisons rather than NOT BETWEEN,
|
|
318
|
+
# which is the same set of rows, NULLs included.
|
|
319
|
+
def test_not_between
|
|
320
|
+
assert_sql(/WHERE \("users"."age" < 18 OR "users"."age" > 65\)/,
|
|
321
|
+
User.where { :age.not_between?(18, 65) }.to_sql)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def test_not_in_range
|
|
325
|
+
assert_sql(/WHERE \("users"."age" < 18 OR "users"."age" > 65\)/,
|
|
326
|
+
User.where { :age.not_in?(18..65) }.to_sql)
|
|
327
|
+
end
|
|
328
|
+
|
|
306
329
|
def test_is_null
|
|
307
330
|
assert_sql(/WHERE "users"."name" IS NULL/,
|
|
308
331
|
User.where { :name.null? }.to_sql)
|
|
@@ -313,6 +336,36 @@ class TestBlockSyntax < Minitest::Test
|
|
|
313
336
|
User.where { :users[:name].null? }.to_sql)
|
|
314
337
|
end
|
|
315
338
|
|
|
339
|
+
def test_is_not_null
|
|
340
|
+
assert_sql(/WHERE "users"."name" IS NOT NULL/,
|
|
341
|
+
User.where { :name.not_null? }.to_sql)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def test_is_not_null_qualified
|
|
345
|
+
assert_sql(/WHERE "users"."name" IS NOT NULL/,
|
|
346
|
+
User.where { :users[:name].not_null? }.to_sql)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# The claim these methods rest on: the direct spelling is the same rows as
|
|
350
|
+
# negating the positive one, which is where a NULL would show a difference
|
|
351
|
+
# if there were one.
|
|
352
|
+
def test_the_negations_match_what_bang_selects
|
|
353
|
+
User.delete_all
|
|
354
|
+
User.create!(name: 'alice', age: 60)
|
|
355
|
+
User.create!(name: 'bob', age: 20)
|
|
356
|
+
User.create!(name: nil, age: 40)
|
|
357
|
+
[
|
|
358
|
+
[-> { :name.not_null? }, -> { !:name.null? }],
|
|
359
|
+
[-> { :age.not_in?([20, 30]) }, -> { !:age.in?([20, 30]) }],
|
|
360
|
+
[-> { :age.not_between?(20, 30) }, -> { !:age.between?(20, 30) }],
|
|
361
|
+
[-> { :name.not_like?('a%') }, -> { !:name.like?('a%') }],
|
|
362
|
+
].each do |direct, negated|
|
|
363
|
+
assert_equal(User.where(&negated).pluck(:id).sort,
|
|
364
|
+
User.where(&direct).pluck(:id).sort,
|
|
365
|
+
"#{direct.source_location} did not match the ! form")
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
316
369
|
def test_equal_nil_is_rejected
|
|
317
370
|
e = assert_raises(ArgumentError) { User.where { :name == nil } }
|
|
318
371
|
assert_match(/null\?/, e.message)
|
|
@@ -333,11 +386,21 @@ class TestBlockSyntax < Minitest::Test
|
|
|
333
386
|
User.where { :users[:age].in?([1, 2, 3]) }.to_sql)
|
|
334
387
|
end
|
|
335
388
|
|
|
336
|
-
def
|
|
389
|
+
def test_bang_negates_in
|
|
337
390
|
assert_sql(/WHERE NOT \("users"."age" IN \(1, 2, 3\)\)/,
|
|
338
391
|
User.where { !:age.in?([1, 2, 3]) }.to_sql)
|
|
339
392
|
end
|
|
340
393
|
|
|
394
|
+
def test_not_in
|
|
395
|
+
assert_sql(/WHERE "users"."age" NOT IN \(1, 2, 3\)/,
|
|
396
|
+
User.where { :age.not_in?([1, 2, 3]) }.to_sql)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def test_not_in_qualified
|
|
400
|
+
assert_sql(/WHERE "users"."age" NOT IN \(1, 2, 3\)/,
|
|
401
|
+
User.where { :users[:age].not_in?([1, 2, 3]) }.to_sql)
|
|
402
|
+
end
|
|
403
|
+
|
|
341
404
|
# Spelled IS [NOT] DISTINCT FROM on PostgreSQL, IS / IS NOT on SQLite and
|
|
342
405
|
# <=> on MySQL, so only the resulting rows are portable.
|
|
343
406
|
def test_not_distinct_from_execution
|
|
@@ -547,6 +610,36 @@ class TestBlockSyntax < Minitest::Test
|
|
|
547
610
|
assert_raises(ArgumentError) { Node.from('tree', as: :nodes) }
|
|
548
611
|
end
|
|
549
612
|
|
|
613
|
+
def test_from_cte_takes_the_alias_from_the_model
|
|
614
|
+
assert_sql(/FROM "tree" (?:AS )?"nodes"/, Node.from_cte(:tree).to_sql)
|
|
615
|
+
assert_equal(Node.from(:tree, as: :nodes).to_sql, Node.from_cte(:tree).to_sql)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def test_from_cte_needs_a_symbol
|
|
619
|
+
assert_raises(ArgumentError) { Node.from_cte('tree') }
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# The alias is what lets a where find its column, which is the whole reason
|
|
623
|
+
# from_cte exists; without it the SQL names a table the query does not have.
|
|
624
|
+
def test_from_cte_leaves_where_able_to_qualify
|
|
625
|
+
Node.delete_all
|
|
626
|
+
root = Node.create!(name: 'root')
|
|
627
|
+
Node.create!(name: 'child', parent_id: root.id)
|
|
628
|
+
other = Node.create!(name: 'other root')
|
|
629
|
+
Node.create!(name: 'other child', parent_id: other.id)
|
|
630
|
+
forest = Node.with_recursive(
|
|
631
|
+
tree: [
|
|
632
|
+
Node.where { :parent_id.null? }.
|
|
633
|
+
select { [:id, :name, :parent_id, :id.as(:root_id)] },
|
|
634
|
+
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
|
|
635
|
+
select { [:nodes[:id], :nodes[:name], :nodes[:parent_id],
|
|
636
|
+
:tree[:root_id]] },
|
|
637
|
+
]
|
|
638
|
+
).from_cte(:tree)
|
|
639
|
+
assert_equal(%w[child root],
|
|
640
|
+
forest.where { :root_id == root.id }.pluck(:name).sort)
|
|
641
|
+
end
|
|
642
|
+
|
|
550
643
|
# A CTE is joined by name like any other table, so the recursive member's
|
|
551
644
|
# ON clause is a block rather than the string join Rails' own docs use.
|
|
552
645
|
def test_recursive_cte
|
|
@@ -1179,4 +1272,71 @@ class TestBlockSyntax < Minitest::Test
|
|
|
1179
1272
|
assert_sql(/WHERE "users"."name" = 'Ruby' AND "users"."age" = 19/,
|
|
1180
1273
|
User.where(name: 'Ruby', age: 19).to_sql)
|
|
1181
1274
|
end
|
|
1275
|
+
|
|
1276
|
+
def test_value_in_a_select_list
|
|
1277
|
+
assert_sql(/SELECT "users"."name", 0 AS depth/,
|
|
1278
|
+
User.select { [:name, value(0).as(:depth)] }.to_sql)
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
# Each adapter escapes the apostrophe its own way, so what is asserted is
|
|
1282
|
+
# that the string stays a value rather than reaching the SQL as written.
|
|
1283
|
+
def test_value_is_quoted
|
|
1284
|
+
User.delete_all
|
|
1285
|
+
User.create!(name: 'alice')
|
|
1286
|
+
payload = "it's a value"
|
|
1287
|
+
assert_sql(/SELECT 'draft' AS state/,
|
|
1288
|
+
User.select { value('draft').as(:state) }.to_sql)
|
|
1289
|
+
assert_equal([payload],
|
|
1290
|
+
User.select { value(payload).as(:note) }.map(&:note))
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
def test_a_bare_string_is_still_sql
|
|
1294
|
+
assert_sql(/SELECT "users"."name", 1 \+ 1 AS two/,
|
|
1295
|
+
User.select { [:name, '1 + 1 AS two'] }.to_sql)
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
def test_value_takes_the_predications
|
|
1299
|
+
assert_sql(/WHERE 1 = "users"."age"/, User.where { value(1) == :users[:age] }.to_sql)
|
|
1300
|
+
assert_sql(/WHERE 1 IS NULL/, User.where { value(1).null? }.to_sql)
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
def test_value_takes_the_arithmetics
|
|
1304
|
+
assert_sql(/SELECT \(1 \+ "users"."age"\) AS next_year/,
|
|
1305
|
+
User.select { (value(1) + :age).as(:next_year) }.to_sql)
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
def test_value_as_a_function_argument
|
|
1309
|
+
assert_sql(/SELECT COALESCE\("users"."age", 0\)/,
|
|
1310
|
+
User.select { coalesce(:age, value(0)) }.to_sql)
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
def test_integer_shorthand_for_value
|
|
1314
|
+
assert_sql(/SELECT "users"."name", 0 AS depth/,
|
|
1315
|
+
User.select { [:name, 0.as(:depth)] }.to_sql)
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
def test_float_shorthand_for_value
|
|
1319
|
+
assert_sql(/SELECT 1\.5 AS rate/, User.select { 1.5.as(:rate) }.to_sql)
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
def test_numeric_shorthand_has_no_orderings
|
|
1323
|
+
assert_raises(NoMethodError) { User.order { 1.asc } }
|
|
1324
|
+
assert_raises(NoMethodError) { User.order { 1.desc } }
|
|
1325
|
+
end
|
|
1326
|
+
|
|
1327
|
+
def test_value_alias_rejects_an_injected_name
|
|
1328
|
+
assert_raises(ArgumentError) { User.select { value(0).as(INJECTION.to_sym) } }
|
|
1329
|
+
assert_raises(ArgumentError) { User.select { 0.as(INJECTION.to_sym) } }
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
def test_a_value_selected_reaches_the_row
|
|
1333
|
+
User.delete_all
|
|
1334
|
+
User.create!(name: 'alice', age: 60)
|
|
1335
|
+
assert_equal([['alice', 0]],
|
|
1336
|
+
User.select { [:name, 0.as(:depth)] }.map {|u| [u.name, u.depth] })
|
|
1337
|
+
end
|
|
1338
|
+
|
|
1339
|
+
def test_numeric_shorthand_is_confined_to_the_block
|
|
1340
|
+
assert_raises(NoMethodError) { 0.as(:depth) }
|
|
1341
|
+
end
|
|
1182
1342
|
end
|