activerecord-refined 0.8.1 → 0.10.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 +17 -0
- data/README.md +111 -815
- data/activerecord-refined.gemspec +36 -17
- data/docs/conditions.md +206 -0
- data/docs/ctes.md +65 -0
- data/docs/expressions.md +125 -0
- data/docs/functions.md +219 -0
- data/docs/grouping.md +55 -0
- data/docs/joins.md +73 -0
- data/docs/json.md +230 -0
- data/docs/ordering.md +55 -0
- data/docs/time_zones.md +30 -0
- data/docs/windows.md +41 -0
- data/docs/writing.md +33 -0
- data/examples/aggregations.rb +31 -11
- data/examples/complex_joins.rb +12 -10
- data/examples/ctes.rb +22 -20
- data/examples/expressions.rb +110 -45
- data/examples/json.rb +77 -38
- data/examples/postgresql.rb +64 -53
- data/examples/predicates.rb +35 -33
- data/examples/subqueries.rb +20 -18
- data/examples/windows.rb +23 -21
- data/examples/writes.rb +26 -24
- data/lib/active_record/refined/ast.rb +1117 -373
- data/lib/active_record/refined/dialect/mariadb.rb +25 -0
- data/lib/active_record/refined/dialect/mysql.rb +18 -0
- data/lib/active_record/refined/dialect/mysql_compat.rb +67 -0
- data/lib/active_record/refined/dialect/oracle.rb +110 -0
- data/lib/active_record/refined/dialect/postgresql.rb +120 -0
- data/lib/active_record/refined/dialect/sql_server.rb +115 -0
- data/lib/active_record/refined/dialect/sqlite.rb +57 -0
- data/lib/active_record/refined/dialect.rb +340 -0
- data/lib/active_record/refined.rb +877 -307
- data/lib/activerecord-refined/version.rb +3 -1
- data/lib/activerecord-refined.rb +9 -5
- metadata +186 -15
- data/.github/workflows/push_gem.yml +0 -45
- data/.github/workflows/sandbox.yml +0 -295
- data/.github/workflows/test.yml +0 -90
- data/.gitignore +0 -19
- data/Gemfile +0 -12
- data/Rakefile +0 -25
- data/benchmark/query_building.rb +0 -129
- data/test/test_block_syntax.rb +0 -2493
- data/test/test_helper.rb +0 -221
|
@@ -1,33 +1,52 @@
|
|
|
1
1
|
# -*- encoding: utf-8 -*-
|
|
2
|
-
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
lib = File.expand_path("../lib", __FILE__)
|
|
3
5
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
|
-
require
|
|
6
|
+
require "activerecord-refined/version"
|
|
5
7
|
|
|
6
8
|
Gem::Specification.new do |gem|
|
|
7
9
|
gem.name = "activerecord-refined"
|
|
8
10
|
gem.version = Activerecord::Refined::VERSION
|
|
9
11
|
gem.authors = ["Shugo Maeda"]
|
|
10
12
|
gem.email = ["shugo@ruby-lang.org"]
|
|
11
|
-
gem.description =
|
|
12
|
-
gem.summary =
|
|
13
|
-
gem.homepage =
|
|
13
|
+
gem.description = "Adding clean and powerful query syntax on Active Record using refinements"
|
|
14
|
+
gem.summary = "Write Active Record queries as Ruby expressions"
|
|
15
|
+
gem.homepage = "https://github.com/shugo/activerecord-refined"
|
|
16
|
+
gem.license = "MIT"
|
|
17
|
+
gem.metadata = {
|
|
18
|
+
"documentation_uri" => "https://rubydoc.info/gems/activerecord-refined",
|
|
19
|
+
}
|
|
14
20
|
|
|
15
|
-
|
|
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$})
|
|
19
|
-
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
|
20
|
-
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
|
21
|
+
gem.files = `git ls-files -- lib docs examples README.md LICENSE.txt .yardopts activerecord-refined.gemspec`.split($/)
|
|
21
22
|
gem.require_paths = ["lib"]
|
|
22
23
|
|
|
23
24
|
# Proc#refined is available since Ruby 4.1. 4.1.0.dev is required to allow
|
|
24
25
|
# ruby-master builds, which sort before the 4.1.0 release.
|
|
25
|
-
gem.required_ruby_version =
|
|
26
|
+
gem.required_ruby_version = ">= 4.1.0.dev"
|
|
26
27
|
|
|
27
|
-
gem.add_dependency
|
|
28
|
-
gem.add_development_dependency
|
|
29
|
-
gem.add_development_dependency
|
|
30
|
-
gem.add_development_dependency
|
|
28
|
+
gem.add_dependency "activerecord", [">= 7.0"]
|
|
29
|
+
gem.add_development_dependency "sqlite3", [">= 0"]
|
|
30
|
+
gem.add_development_dependency "minitest", [">= 0"]
|
|
31
|
+
gem.add_development_dependency "rake", [">= 0"]
|
|
31
32
|
# What cuts a release: `bump patch --tag`, then push with --follow-tags.
|
|
32
|
-
gem.add_development_dependency
|
|
33
|
+
gem.add_development_dependency "bump", [">= 0"]
|
|
34
|
+
# What benchmark/query_building.rb measures with.
|
|
35
|
+
gem.add_development_dependency "benchmark-ips", [">= 0"]
|
|
36
|
+
gem.add_development_dependency "memory_profiler", [">= 0"]
|
|
37
|
+
# The style is Rails' own: .rubocop.yml is theirs with this repository's
|
|
38
|
+
# paths, and these are the plugins it loads.
|
|
39
|
+
gem.add_development_dependency "rubocop", [">= 0"]
|
|
40
|
+
gem.add_development_dependency "rubocop-minitest", [">= 0"]
|
|
41
|
+
gem.add_development_dependency "rubocop-packaging", [">= 0"]
|
|
42
|
+
gem.add_development_dependency "rubocop-performance", [">= 0"]
|
|
43
|
+
gem.add_development_dependency "rubocop-rails", [">= 0"]
|
|
44
|
+
gem.add_development_dependency "rubocop-md", [">= 0"]
|
|
45
|
+
# What renders the reference: `yard server --reload` serves it locally, and
|
|
46
|
+
# rubydoc.info renders the same .yardopts. The plugin turns the README's
|
|
47
|
+
# relative links to docs/*.md -- which GitHub follows as they are -- into
|
|
48
|
+
# links to YARD's file pages; where it is not installed, rubydoc.info among
|
|
49
|
+
# them, YARD says so and leaves the links relative.
|
|
50
|
+
gem.add_development_dependency "yard", [">= 0"]
|
|
51
|
+
gem.add_development_dependency "yard-markdown-relative-links", [">= 0"]
|
|
33
52
|
end
|
data/docs/conditions.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Conditions
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
```ruby
|
|
5
|
+
Author.where { :age >= 18 }
|
|
6
|
+
Author.where { :name.like?("A%") } # LIKE
|
|
7
|
+
Author.where { :age.in?(20..40) } # BETWEEN
|
|
8
|
+
Author.where { :age.between?(20, 40) } # BETWEEN
|
|
9
|
+
Author.where { :age.in?(18..) } # >= 18
|
|
10
|
+
Author.where { :country.in?(%w[JP US]) } # IN
|
|
11
|
+
Author.where { :country.null? } # IS NULL
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`!` negates any of these. Where SQL has a negative of its own, so does the
|
|
15
|
+
block, which is the same rows written the way they would be written by hand:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
Author.where { :country.not_null? } # IS NOT NULL
|
|
19
|
+
Author.where { :country.not_in?(%w[JP US]) } # NOT IN
|
|
20
|
+
Author.where { :age.not_between?(20, 40) } # not between 20 and 40
|
|
21
|
+
Author.where { :name.not_like?("A%") } # NOT LIKE
|
|
22
|
+
Author.where { :name.not_ilike?("a%") } # NOT ILIKE / NOT LIKE
|
|
23
|
+
|
|
24
|
+
Author.where { !:name.start_with?("A") } # NOT (name LIKE 'A%')
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Nothing turns on the choice: `NOT (country IS NULL)` and `country IS NOT NULL`
|
|
28
|
+
select the same rows, NULLs included. `not_between?` is the one whose SQL
|
|
29
|
+
looks unlike its name — Arel writes it as the two comparisons, `age < 20 OR
|
|
30
|
+
age > 40`, which is again the same rows.
|
|
31
|
+
|
|
32
|
+
A number compares as itself, the way a bound `?` does: `:age >= 99.5` says
|
|
33
|
+
`>= 99.5`, where `where(age: 99.5..)` casts to the column's type and says
|
|
34
|
+
`>= 99`, letting an age of 99 through a bound it does not satisfy. Everything
|
|
35
|
+
that is not an `Integer`, `Float` or `BigDecimal` keeps the column's own
|
|
36
|
+
serialization — an enum's name, a time's zone, a custom type's scaling — so a
|
|
37
|
+
custom type that scales a number, money kept in cents, is the one place the
|
|
38
|
+
number has to be written as the column stores it.
|
|
39
|
+
|
|
40
|
+
A boolean column has `true?` and `false?`, which become SQL's `IS TRUE` and
|
|
41
|
+
`IS FALSE`, and the two negations to go with them:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
Post.where { :published.true? } # IS TRUE
|
|
45
|
+
Post.where { :published.not_true? } # IS NOT TRUE
|
|
46
|
+
Post.where { :published.false? } # IS FALSE
|
|
47
|
+
Post.where { :published.not_false? } # IS NOT FALSE
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`published = TRUE` selects the same rows as `published IS TRUE`, so the
|
|
51
|
+
difference is in the negation: `published = TRUE` is itself NULL for a row
|
|
52
|
+
where the column is, and a NULL predicate selects nothing, while `IS TRUE`
|
|
53
|
+
answers false there. `not_true?` is therefore "false or never set" and
|
|
54
|
+
`!(:published == true)` only "false". Every adapter spells all four the same
|
|
55
|
+
way and answers them alike.
|
|
56
|
+
|
|
57
|
+
`in?` also takes a relation as a subquery. Without an explicit select list the
|
|
58
|
+
subquery selects the relation's primary key, the same way Active Record's own
|
|
59
|
+
`where(id: relation)` does:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Author.where { :id.in?(Post.published.select(:author_id)) }
|
|
63
|
+
# "authors"."id" IN (SELECT "posts"."author_id" FROM "posts" WHERE ...)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A relation on the right of a comparison is a scalar subquery. It has to select
|
|
67
|
+
one value, so unlike `in?` there is no default select list and one is
|
|
68
|
+
required:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
Author.where { :age >= Author.select { avg(:age) } }
|
|
72
|
+
# "authors"."age" >= (SELECT AVG("authors"."age") FROM "authors")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`any` and `all` quantify that comparison instead, which is what lifts the
|
|
76
|
+
one-row rule: `> any` asks whether the subquery holds a smaller value anywhere,
|
|
77
|
+
`>= all` whether it holds a larger one nowhere.
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
Author.where { :age > any(Author.where(country: "JP").select(:age)) }
|
|
81
|
+
# "authors"."age" > ANY(SELECT "authors"."age" FROM "authors" WHERE ...)
|
|
82
|
+
|
|
83
|
+
Author.where { :age >= all(Author.select(:age)) }
|
|
84
|
+
# "authors"."age" >= ALL(SELECT "authors"."age" FROM "authors")
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The select list follows `in?`'s rule rather than the scalar one: without an
|
|
88
|
+
explicit select the subquery selects the primary key. `== any` is what `IN`
|
|
89
|
+
says and `!= all` what `NOT IN` says, so what the quantifiers add is the four
|
|
90
|
+
comparisons `IN` has no spelling for. SQLite has neither quantifier, and says
|
|
91
|
+
so with `NotImplementedError` rather than leaving its parser to.
|
|
92
|
+
|
|
93
|
+
`exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
|
|
94
|
+
subquery with the outer table through qualified columns — its `where` block
|
|
95
|
+
goes through the DSL like any other:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
Author.where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
|
|
99
|
+
# EXISTS (SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = "authors"."id")
|
|
100
|
+
|
|
101
|
+
Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
|
|
102
|
+
# NOT (EXISTS (...))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`like?` is case-sensitive `LIKE` on every adapter, including PostgreSQL, where
|
|
106
|
+
Arel would otherwise reach for `ILIKE`. `ilike?` is the one that asks for
|
|
107
|
+
`ILIKE`; off PostgreSQL it is plain `LIKE`, which those adapters already match
|
|
108
|
+
case-insensitively under their default collations. `casecmp?` is
|
|
109
|
+
case-insensitive equality, folded on both sides rather than left to the
|
|
110
|
+
collation, so it means the same thing everywhere:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
Author.where { :name.ilike?("ma%") } # ILIKE 'ma%' / LIKE 'ma%'
|
|
114
|
+
Author.where { :name.casecmp?("Alice") } # LOWER(name) = LOWER('Alice')
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`not_distinct_from?` and `distinct_from?` compare with NULL treated as a
|
|
118
|
+
value, rather than as the unknown that makes `=` and `<>` neither true nor
|
|
119
|
+
false. PostgreSQL spells this `IS [NOT] DISTINCT FROM`, SQLite `IS` / `IS NOT`
|
|
120
|
+
and MySQL `<=>`, and the rows that come back are the same on all three:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
Author.where { :country.not_distinct_from?(params[:country]) } # matches NULL to nil
|
|
124
|
+
Author.where { :country.distinct_from?("JP") } # keeps the NULL rows
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`start_with?`, `end_with?` and `include?` are shortcuts for the usual `like?`
|
|
128
|
+
patterns. Unlike `like?`, they treat their argument as a literal string, so `%`
|
|
129
|
+
and `_` in it are escaped rather than matched as wildcards:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
Author.where { :name.start_with?("A") } # LIKE 'A%'
|
|
133
|
+
Author.where { :name.end_with?("son") } # LIKE '%son'
|
|
134
|
+
Author.where { :name.include?("test") } # LIKE '%test%'
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Like their String namesakes, `start_with?` and `end_with?` take any number of
|
|
138
|
+
literals; matching any one of them is enough:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
Author.where { :name.start_with?("A", "B") }
|
|
142
|
+
# (name LIKE 'A%' OR name LIKE 'B%')
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`member?`, `superset?`, `subset?` and `intersect?` compare against a
|
|
146
|
+
PostgreSQL array column, each carrying the meaning of its Ruby namesake:
|
|
147
|
+
`member?` is Enumerable's element test (which String does not have — that is
|
|
148
|
+
what separates it from `include?`), `superset?` and `subset?` are Set's
|
|
149
|
+
whole-array containment, and `intersect?` is Array's "any element in common":
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
Article.where { :tags.member?("ruby") } # tags @> '{ruby}'
|
|
153
|
+
Article.where { :scores.member?(80) } # scores @> '{80}'
|
|
154
|
+
Article.where { :tags.superset?(%w[ruby rails]) } # tags @> '{ruby,rails}'
|
|
155
|
+
Article.where { :tags.subset?(%w[ruby rails go]) } # tags <@ '{ruby,rails,go}'
|
|
156
|
+
Article.where { :tags.intersect?(%w[ruby go]) } # tags && '{ruby,go}'
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Like its namesake, `member?` takes one element — `[1, 2].member?([1])` is
|
|
160
|
+
false in Ruby, so an Array argument raises rather than quietly meaning
|
|
161
|
+
something `Array#member?` does not. Requiring every element is `superset?`.
|
|
162
|
+
|
|
163
|
+
`=~` and `!~` match a regular expression: `REGEXP` and `NOT REGEXP` on MySQL,
|
|
164
|
+
`~` and `!~` on PostgreSQL. SQLite has no regexp operator of its own, so it
|
|
165
|
+
raises there.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
Author.where { :name =~ "^A" } # REGEXP / ~
|
|
169
|
+
Author.where { :name !~ "^A" } # NOT REGEXP / !~
|
|
170
|
+
Author.where { :name =~ /son$/ } # a Regexp literal works too
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Only a literal's source crosses over; the database has its own dialect and no
|
|
174
|
+
equivalent of Ruby's flags. Dropping one would silently change what the query
|
|
175
|
+
matches, so `/son$/i` raises instead — pass the pattern as a string if the
|
|
176
|
+
database can express what you mean.
|
|
177
|
+
|
|
178
|
+
`==` always means SQL `=`, and passes its value through untouched. A Range or an
|
|
179
|
+
Array therefore compares against a PostgreSQL range or array column, the same
|
|
180
|
+
way Active Record's own `where(period: from...to)` does for those column types:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
Reservation.where { :period == (from...to) } # daterange = '[from,to)'
|
|
184
|
+
Article.where { :tags == %w[ruby rails] } # text[] = '{ruby,rails}'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`!=` is SQL `!=` under the same rules, value passed through untouched.
|
|
188
|
+
|
|
189
|
+
For the same reason `== nil` and `!= nil` raise `ArgumentError`: `= NULL` is
|
|
190
|
+
never true in SQL, so a NULL test has to be spelled as one. Use `null?`:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
Author.where { :country.null? } # country IS NULL
|
|
194
|
+
Author.where { !:country.null? } # NOT (country IS NULL)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Combine predicates with `&`, `|` and `!`. Ruby's operator precedence makes the
|
|
198
|
+
parentheses around each comparison necessary, though the `?` methods above need
|
|
199
|
+
none:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
Author.where { (:age >= 18) & ((:country == "JP") | (:country == "US")) }
|
|
203
|
+
Author.where { !(:age.in?(0..17) | :country.null?) }
|
|
204
|
+
Author.where { !:country.in?(%w[JP US]) } # NOT (country IN ('JP', 'US'))
|
|
205
|
+
Author.where { !:name.like?("%test%") } # NOT (name LIKE '%test%')
|
|
206
|
+
```
|
data/docs/ctes.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Common table expressions
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
Active Record's `with` and `with_recursive` need nothing from this gem: a CTE
|
|
5
|
+
is joined by name like any other table, so its `ON` clause is a block, where
|
|
6
|
+
Rails' own documentation reaches for a string join.
|
|
7
|
+
|
|
8
|
+
`from_cte` takes the CTE's name and selects it under the model's own table
|
|
9
|
+
name, so the model's columns resolve:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
Node.with_recursive(
|
|
13
|
+
tree: [
|
|
14
|
+
Node.where { :id == root.id }.
|
|
15
|
+
select { [:id, :name, :parent_id, 0.as(:depth)] },
|
|
16
|
+
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
|
|
17
|
+
select { [:id, :name, :parent_id, (:tree[:depth] + 1).as(:depth)] },
|
|
18
|
+
]
|
|
19
|
+
).from_cte(:tree)
|
|
20
|
+
# WITH RECURSIVE "tree" AS (
|
|
21
|
+
# SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id", 0 AS depth
|
|
22
|
+
# FROM "nodes" WHERE "nodes"."id" = 1
|
|
23
|
+
# UNION ALL
|
|
24
|
+
# SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id",
|
|
25
|
+
# ("tree"."depth" + 1) AS depth
|
|
26
|
+
# FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
|
|
27
|
+
# ) SELECT "nodes".* FROM "tree" AS "nodes"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The anchor starts the count and the recursive member adds one, which is how
|
|
31
|
+
the shape of a tree comes out of a flat table. The `0` is a value rather than
|
|
32
|
+
SQL — see [`value`](functions.md) for why a number can say `.as` directly.
|
|
33
|
+
|
|
34
|
+
The alias on the last line is there for Active Record's sake, not SQL's:
|
|
35
|
+
written by hand that line would be `SELECT * FROM tree`. Active Record goes on qualifying
|
|
36
|
+
columns with the model's table name, so without the alias that name is not in
|
|
37
|
+
the query and anything qualifying a column fails:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
Node.with_recursive(tree: [...]).from(:tree).where(name: 'root')
|
|
41
|
+
# PG::UndefinedTable: missing FROM-clause entry for table "nodes"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Since the model's name is the only one that works, `from_cte` takes it from
|
|
45
|
+
the model rather than asking. It also checks that the name is one `with`
|
|
46
|
+
declares, so a typo is an `ArgumentError` here rather than a query against a
|
|
47
|
+
table nobody has — checked when the SQL is built, so the CTE may be declared
|
|
48
|
+
later in the chain or by a scope merged into it.
|
|
49
|
+
|
|
50
|
+
`from(:tree, as: :nodes)` is the same thing spelled out, without the check,
|
|
51
|
+
and is what to reach for when the name wanted is not the model's.
|
|
52
|
+
|
|
53
|
+
What makes this worth spelling out is how selectively it breaks. `count`,
|
|
54
|
+
`order` and `select` never qualify, so they work without the alias on every
|
|
55
|
+
adapter; it is `where` and `find_by` that stop. A query can therefore look
|
|
56
|
+
right until the day a condition is added to it.
|
|
57
|
+
|
|
58
|
+
A non-recursive CTE joins the same way:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
Node.with(roots: Node.where { :parent_id.null? }).
|
|
62
|
+
joins(:roots) { :roots[:id] == :nodes[:parent_id] }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`examples/ctes.rb` walks a category tree with these.
|
data/docs/expressions.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Expressions
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
`+`, `-`, `*` and `/` build arithmetic. Ruby puts them above the comparison
|
|
5
|
+
operators, so an expression groups the way it reads:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
Item.where { :price * :quantity > 1000 }
|
|
9
|
+
Item.select { sum(:price * :quantity).as(:total) }
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The number may stand on the left — only a column or an expression on the
|
|
13
|
+
right builds a query, so Ruby's own arithmetic is untouched — and
|
|
14
|
+
`BigDecimal` is a number here, being what a decimal column's values are,
|
|
15
|
+
quoted as the exact decimal on either side. A `Rational` is refused: no
|
|
16
|
+
decimal spells `1/3r` exactly, and `to_d` is what says the decimal meant.
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
Item.select { greatest(20 - :quantity, 0).as(:shortfall) }
|
|
20
|
+
Item.where { BigDecimal("1.08") * :price > 500 }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`&`, `|`, `^`, `~`, `<<` and `>>` are SQL's bitwise operators. Between
|
|
24
|
+
conditions `&` and `|` are AND and OR, and that is where they are defined,
|
|
25
|
+
which leaves them free to mean here what SQL means by them:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
Post.where { :flags & 4 > 0 }
|
|
29
|
+
# WHERE ("posts"."flags" & 4) > 0
|
|
30
|
+
|
|
31
|
+
Post.select { (:flags | 4).as(:flags) }
|
|
32
|
+
Post.select { (~:flags).as(:inverted) }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Each parenthesises itself, which is what keeps Ruby's grouping: PostgreSQL
|
|
36
|
+
gives `&` and `|` the same precedence and reads `a | b & c` from the left,
|
|
37
|
+
where Ruby reads the `&` first.
|
|
38
|
+
|
|
39
|
+
A boolean column is refused rather than taken for the one bit it is stored as.
|
|
40
|
+
MySQL and SQLite would quietly answer as `AND` would, PostgreSQL has no such
|
|
41
|
+
operator at all, and one block meaning two things is worse than an
|
|
42
|
+
`ArgumentError` saying that `true?` is what makes a boolean column a
|
|
43
|
+
condition. A condition as an operand is refused for the same reason.
|
|
44
|
+
|
|
45
|
+
XOR is the one the three do not share, and the one where guessing costs most:
|
|
46
|
+
MySQL spells it `^`, which is exponentiation to PostgreSQL, and PostgreSQL
|
|
47
|
+
spells it `#`, which is where a comment starts on MySQL — either way a wrong
|
|
48
|
+
answer rather than an error. Each adapter gets its own, and SQLite, which has
|
|
49
|
+
no XOR at all, gets the two operations it is made of, `(a | b) - (a & b)`.
|
|
50
|
+
That names each operand twice, so keep them cheap.
|
|
51
|
+
|
|
52
|
+
`bit_and`, `bit_or` and `bit_xor` are the aggregates of the first three, and
|
|
53
|
+
`bit_count` counts the bits that are set. SQLite has none of the four.
|
|
54
|
+
PostgreSQL counts the bits of a bit string rather than of a number, so the
|
|
55
|
+
argument is cast there, to `bit(64)` because that is what makes a negative
|
|
56
|
+
count as it does on MySQL:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Post.group { :author_id }.select { bit_or(:flags).as(:flags) }
|
|
60
|
+
# SELECT BIT_OR("posts"."flags") AS "flags" ... GROUP BY "posts"."author_id"
|
|
61
|
+
|
|
62
|
+
Post.select { bit_count(:flags).as(:bits) }
|
|
63
|
+
# MySQL: BIT_COUNT("posts"."flags")
|
|
64
|
+
# PostgreSQL: BIT_COUNT(CAST("posts"."flags" AS bit(64)))
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`bit_xor` arrived in PostgreSQL 14. `~` is where the three disagree about the
|
|
68
|
+
answer rather than the question: MySQL reads it back as the unsigned 64-bit
|
|
69
|
+
number, the others as a negative one, and the bits are the same either way.
|
|
70
|
+
|
|
71
|
+
One place asks for a value to be said out loud: the top of a select list.
|
|
72
|
+
Everywhere else a bare literal is already a value — `where { :age > 18 }`,
|
|
73
|
+
`concat(:name, '-x')` — but a bare string at the top of the list would be SQL
|
|
74
|
+
to Active Record and a value everywhere else in the block, so it is refused
|
|
75
|
+
rather than read either way: `sql` says the SQL, `value` the value. `value`
|
|
76
|
+
carries the predications and arithmetic with it, so a literal can be compared
|
|
77
|
+
and combined like anything else, and numbers and strings have a shorthand,
|
|
78
|
+
since a literal that has been sent `as` has already said it is a value:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
Node.select { [:id, value(0).as(:depth)] }
|
|
82
|
+
# SELECT "nodes"."id", 0 AS depth FROM "nodes"
|
|
83
|
+
|
|
84
|
+
Node.select { [:id, 0.as(:depth)] } # the same thing
|
|
85
|
+
|
|
86
|
+
Post.select { [:title, "draft".as(:state)] }
|
|
87
|
+
# SELECT "posts"."title", 'draft' AS state FROM "posts"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
What the shorthand does not cover, `value` still spells: `value(true)`,
|
|
91
|
+
`value(nil)`, or a literal that goes on to be compared rather than selected.
|
|
92
|
+
|
|
93
|
+
`CASE` is grammar rather than a function, and has two shapes. With an operand, each `when` is
|
|
94
|
+
something to compare it against; without one, each `when` carries a condition
|
|
95
|
+
of its own. `case` is a Ruby keyword, so the method behind both is only
|
|
96
|
+
reachable through the receiver — `self.case` — and each shape has a shorthand
|
|
97
|
+
that does not need it:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
Author.select { :country.when("JP").then("Japan").else("elsewhere").as(:where) }
|
|
101
|
+
# CASE "country" WHEN 'JP' THEN 'Japan' ELSE 'elsewhere' END AS where
|
|
102
|
+
|
|
103
|
+
Author.select { case_when { :age >= 60 }.then("senior").else("adult").as(:band) }
|
|
104
|
+
# CASE WHEN "age" >= 60 THEN 'senior' ELSE 'adult' END AS band
|
|
105
|
+
|
|
106
|
+
Author.select { self.case(mod(:age, 10)).when(0).then("round").else("not").as(:v) }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A `when` takes a value or a block, and so do `then` and `else`; the block is
|
|
110
|
+
there to read like the blocks around it, since an argument works just as well
|
|
111
|
+
— `:age >= 60` has already become an expression by the time it is passed.
|
|
112
|
+
Leaving the `else` off is SQL's own default, which is NULL. `when` and `then`
|
|
113
|
+
come in pairs, and one without the other is an `ArgumentError` rather than
|
|
114
|
+
something that reaches the database:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
Author.select {
|
|
118
|
+
case_when { :age < 18 }.then("minor").
|
|
119
|
+
when { :age >= 60 }.then("senior").
|
|
120
|
+
else("adult").as(:band)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
Author.select { sum(case_when { :age >= 60 }.then(1).else(0)).as(:seniors) }
|
|
124
|
+
# SUM(CASE WHEN "age" >= 60 THEN 1 ELSE 0 END) AS seniors
|
|
125
|
+
```
|