activerecord-refined 0.9.0 → 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 +93 -965
- data/activerecord-refined.gemspec +12 -7
- 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 +18 -0
- data/examples/expressions.rb +35 -5
- data/lib/active_record/refined/ast.rb +461 -246
- 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 +682 -192
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +1 -0
- metadata +58 -16
- data/.github/workflows/push_gem.yml +0 -45
- data/.github/workflows/sandbox.yml +0 -295
- data/.github/workflows/test.yml +0 -104
- data/.gitignore +0 -19
- data/.rubocop.yml +0 -393
- data/Gemfile +0 -14
- data/Rakefile +0 -53
- data/benchmark/query_building.rb +0 -129
- data/test/test_block_syntax.rb +0 -2999
- data/test/test_helper.rb +0 -238
|
@@ -13,14 +13,12 @@ Gem::Specification.new do |gem|
|
|
|
13
13
|
gem.description = "Adding clean and powerful query syntax on Active Record using refinements"
|
|
14
14
|
gem.summary = "Write Active Record queries as Ruby expressions"
|
|
15
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
|
+
}
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
# package-lock.json have no business in anyone's bundle. CLAUDE.md and
|
|
19
|
-
# .claude/ are addressed to whoever is working on the repository, not to
|
|
20
|
-
# anyone using it.
|
|
21
|
-
gem.files = `git ls-files`.split($/).grep_v(%r{^sandbox/|^CLAUDE\.md$|^\.claude/})
|
|
22
|
-
gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
|
|
23
|
-
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($/)
|
|
24
22
|
gem.require_paths = ["lib"]
|
|
25
23
|
|
|
26
24
|
# Proc#refined is available since Ruby 4.1. 4.1.0.dev is required to allow
|
|
@@ -44,4 +42,11 @@ Gem::Specification.new do |gem|
|
|
|
44
42
|
gem.add_development_dependency "rubocop-performance", [">= 0"]
|
|
45
43
|
gem.add_development_dependency "rubocop-rails", [">= 0"]
|
|
46
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"]
|
|
47
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
|
+
```
|
data/docs/functions.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Aggregates and functions
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
`count`, `sum`, `avg`, `min` and `max` are available as methods, as are the
|
|
5
|
+
bit aggregates and the scalar functions below, with `fn` for anything else. Return an array to select
|
|
6
|
+
or order by multiple expressions.
|
|
7
|
+
|
|
8
|
+
`filter` takes the aggregate over the rows a condition holds for, as a value
|
|
9
|
+
or a block:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
Author.select { count(:*).filter { :age < 50 }.as(:young) }
|
|
13
|
+
# COUNT(*) FILTER (WHERE "age" < 50) AS "young"
|
|
14
|
+
|
|
15
|
+
Author.select {
|
|
16
|
+
[count(:*).as(:all), sum(:age).filter { :country == "JP" }.as(:jp_years)]
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
MySQL has no `FILTER` clause, and gets the case that means the same thing —
|
|
21
|
+
`COUNT(CASE WHEN "age" < 50 THEN 1 END)`. An aggregate passes over a NULL, so
|
|
22
|
+
a row the condition misses is a row it does not see, and the number that comes
|
|
23
|
+
back is the same on all three.
|
|
24
|
+
|
|
25
|
+
Pass `:*` to `count` for `COUNT(*)`, and `distinct: true` for
|
|
26
|
+
`COUNT(DISTINCT ...)`. `sum` and `avg` take `distinct: true` as well, for the
|
|
27
|
+
aggregate over each value once; `min` and `max` would give the same with or
|
|
28
|
+
without it, so they take no such thing:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Author.group { :country }.having { count(:*) > 1 }
|
|
32
|
+
# SELECT "authors".* FROM "authors" GROUP BY "authors"."country" HAVING COUNT(*) > 1
|
|
33
|
+
|
|
34
|
+
Post.select { count(:author_id, distinct: true) } # COUNT(DISTINCT "author_id")
|
|
35
|
+
Post.select { sum(:likes, distinct: true) } # SUM(DISTINCT "likes")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`string_agg` joins the strings of a group into one, a separator between —
|
|
39
|
+
`,` unless another is given — and `.order` says the order they are joined
|
|
40
|
+
in. Each database has it under a name of its own, with the `ORDER BY` in a
|
|
41
|
+
place of its own:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
Post.group { :author_id }.select { string_agg(:title, ", ").order(:title).as(:titles) }
|
|
45
|
+
# STRING_AGG("title", ', ' ORDER BY "title") PostgreSQL
|
|
46
|
+
# group_concat("title", ', ' ORDER BY "title") SQLite
|
|
47
|
+
# GROUP_CONCAT("title" ORDER BY "title" SEPARATOR ', ') MySQL
|
|
48
|
+
# STRING_AGG("title", ', ') WITHIN GROUP (ORDER BY "title") SQL Server
|
|
49
|
+
# LISTAGG("title", ', ') WITHIN GROUP (ORDER BY "title") Oracle
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`filter` and `over` come along as with any aggregate, `over` where the
|
|
53
|
+
database takes it — the MySQL family and SQL Server do not, and raise.
|
|
54
|
+
Oracle's insists on an order and is given the values' own when none is asked
|
|
55
|
+
for. What is joined is text: PostgreSQL's `STRING_AGG` takes nothing else, so
|
|
56
|
+
a column the model does not declare a string is cast there, where the others
|
|
57
|
+
convert for themselves. MySQL cuts the result at `group_concat_max_len`, 1024
|
|
58
|
+
bytes unless the session says otherwise, and the `ORDER BY` inside SQLite's
|
|
59
|
+
call needs SQLite 3.44.
|
|
60
|
+
|
|
61
|
+
The scalar functions are real methods rather than anything caught dynamically,
|
|
62
|
+
so a misspelling is a `NoMethodError` where you wrote it, and a name Ruby also
|
|
63
|
+
answers to — `rand` — means the SQL one inside a block:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
abs acos asin atan atan2 bit_and bit_count bit_or bit_xor cast
|
|
67
|
+
ceil char_length coalesce concat
|
|
68
|
+
cos current_date current_time current_timestamp date_trunc degrees
|
|
69
|
+
exp extract floor format greatest least length ln localtime
|
|
70
|
+
localtimestamp log log10 log2 lower ltrim mod now nullif pi
|
|
71
|
+
power radians rand replace round rtrim sign sin sqrt substr tan
|
|
72
|
+
trim trunc upper
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Most are spelled the same everywhere. Where they are not, the method names one
|
|
76
|
+
meaning and each adapter gets its own spelling: `char_length`, `greatest` and
|
|
77
|
+
`least` become `LENGTH`, `MAX` and `MIN` on SQLite, and `rand` is `RAND` on
|
|
78
|
+
MySQL and `RANDOM` elsewhere, and `trunc` is `TRUNCATE` on MySQL, which
|
|
79
|
+
insists on the second argument the others default to zero — SQLite's takes
|
|
80
|
+
only the one. Where an adapter has no equivalent — `date_trunc` outside
|
|
81
|
+
PostgreSQL, `now` and the `local*` pair on SQLite, `log2` on PostgreSQL,
|
|
82
|
+
whose spelling is `log(2, x)`, the four `bit_*` on SQLite — the block raises
|
|
83
|
+
`NotImplementedError` rather than leaving the database to reject the SQL.
|
|
84
|
+
|
|
85
|
+
`format` is printf formatting, and raises on MySQL, where a function of the
|
|
86
|
+
same name does something else entirely: it puts separators in a number, and
|
|
87
|
+
reads a printf template as the number zero rather than complaining. `fn` still
|
|
88
|
+
reaches it, spelled as the different thing it is:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Post.select { fn(:format, :amount, 2) } # MySQL's, on purpose
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`fn` reaches functions without a method of their own. Its name is emitted as
|
|
95
|
+
written, so a case-sensitive one can be spelled exactly:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
Post.select { fn(:date_trunc, "day", :created_at).as(:day) }
|
|
99
|
+
# SELECT date_trunc('day', "posts"."created_at") AS day
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`op` is the same escape hatch for operators — PostgreSQL alone has dozens
|
|
103
|
+
with no method here, `<@` and `&&` and the geometric ones among them. The
|
|
104
|
+
operator is emitted as written and, like `fn`'s names, whether the adapter
|
|
105
|
+
has it is your assertion; it is checked against the characters PostgreSQL
|
|
106
|
+
allows an operator, so a letter, a space or a quote is refused rather than
|
|
107
|
+
written into the SQL. Both sides take what `fn`'s arguments take — a
|
|
108
|
+
column, an expression, a value quoted by the adapter — and a value is
|
|
109
|
+
spelled in the adapter's own syntax, `to_json` saying a document, `'{a,b}'`
|
|
110
|
+
an array; a Ruby Hash or Array is refused rather than guessed at. The
|
|
111
|
+
result is parenthesized, its precedence being unknown, and so is an
|
|
112
|
+
expression on either side, so a dug value cannot be re-grouped out from
|
|
113
|
+
under it:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
Post.where { op("&&", :tags, "{ruby,sql}") }
|
|
117
|
+
# WHERE ("posts"."tags" && '{ruby,sql}')
|
|
118
|
+
|
|
119
|
+
Post.where { op("<@", :meta.dig(:author), { name: "alice" }.to_json) }
|
|
120
|
+
# WHERE (("meta" #> '{author}') <@ '{"name":"alice"}')
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`sql` is the last resort, for what neither `fn` nor `op` can spell: the
|
|
124
|
+
statement goes out as written. It is the one way a string means SQL inside a
|
|
125
|
+
block — everywhere else a string is a value — so writing SQL is always asked
|
|
126
|
+
for by name, and an interpolation has a spelling that is not it: `?` and
|
|
127
|
+
`:name` placeholders take values quoted by the adapter, through
|
|
128
|
+
`sanitize_sql_array`. A `?` is rewritten only when there are positional binds
|
|
129
|
+
to put in it, so PostgreSQL's `?` operators can share a statement with named
|
|
130
|
+
binds, or with none. The result carries the predications and arithmetic, and
|
|
131
|
+
is parenthesized where it stands inside a larger expression — its precedence
|
|
132
|
+
is whatever was written — but comes out bare at the top of a select list,
|
|
133
|
+
where parentheses would refuse an alias written into the string:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
Post.where { sql("length(title) > ?", 10) }
|
|
137
|
+
# WHERE (length(title) > 10)
|
|
138
|
+
|
|
139
|
+
Post.where { sql("score + ?", 10) * 2 >= 60 }
|
|
140
|
+
# WHERE (score + 10) * 2 >= 60
|
|
141
|
+
|
|
142
|
+
Post.select { sql("count(*) FILTER (WHERE score > 0) AS positive") }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Values are quoted by the adapter wherever they appear, as they are in
|
|
146
|
+
Active Record, and so is a column alias. That is what makes the name asked for
|
|
147
|
+
the name that comes back: unquoted, PostgreSQL folds a capital away where the
|
|
148
|
+
other two keep it, so one block would mean two things. It also leaves nothing
|
|
149
|
+
to refuse — a name that would have been SQL becomes an identifier with a
|
|
150
|
+
strange name instead:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
Author.select { count(:*).as(:postCount) } # AS "postCount" everywhere
|
|
154
|
+
Author.select { count(:*).as(:'total sales') } # AS "total sales"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`quote: false` asks for the name as written, for a schema that wants the
|
|
158
|
+
folding. Nothing quotes it then, so a name that is not plain is refused:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
Author.select { count(:*).as(:post_count, quote: false) } # AS post_count
|
|
162
|
+
Author.select { count(:*).as(:'total sales', quote: false) } # ArgumentError
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`fn`'s function name is the one that cannot be quoted: quoting stops
|
|
166
|
+
PostgreSQL folding it, and `"UPPER"(x)` is a function that does not exist.
|
|
167
|
+
That one, `cast`'s type and `extract`'s field are neither values nor
|
|
168
|
+
identifiers, so they have to be plain names and anything else raises
|
|
169
|
+
`ArgumentError` rather than reaching the query.
|
|
170
|
+
|
|
171
|
+
`current_date`, `current_time`, `current_timestamp`, `localtime` and
|
|
172
|
+
`localtimestamp` come out without parentheses, as the grammar has them —
|
|
173
|
+
written as calls, PostgreSQL and SQLite would reject them. What does go into
|
|
174
|
+
parentheses is an optional precision — `current_timestamp(3)` — which
|
|
175
|
+
`current_date` never takes and SQLite never accepts. `current_timestamp` is
|
|
176
|
+
the portable spelling of what `now` means, and reaches SQLite where `now`
|
|
177
|
+
does not — and SQL Server, which has none of the other four:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
Post.where { :published_at <= current_timestamp }
|
|
181
|
+
# SELECT "posts".* FROM "posts" WHERE "posts"."published_at" <= CURRENT_TIMESTAMP
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`extract` and `cast` are grammar as well: the field and the type go where no
|
|
185
|
+
value could. The field has to be a plain name, and the type has to look like
|
|
186
|
+
a type — a plain name, at most parenthesized with lengths, so the adapters'
|
|
187
|
+
own spellings like `double precision` or `decimal(10,2)` pass; anything else
|
|
188
|
+
raises `ArgumentError`. The type is the adapter's own name for the type, and
|
|
189
|
+
whether it exists is the database's to say. SQLite spells everything
|
|
190
|
+
`extract` does as `strftime` formats, which no renaming carries, so `extract`
|
|
191
|
+
raises there:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
Post.where { extract(:year, :created_at) == 2026 }
|
|
195
|
+
# SELECT "posts".* FROM "posts" WHERE EXTRACT(YEAR FROM "posts"."created_at") = 2026
|
|
196
|
+
|
|
197
|
+
Post.select { cast(:price, "decimal(10,2)").as(:price) }
|
|
198
|
+
# SELECT CAST("posts"."price" AS decimal(10,2)) AS price
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
A duration moves a date. Active Support's `7.days` and the rest go on the
|
|
202
|
+
right of `+` or `-`, and the move comes out the way the adapter spells it — an
|
|
203
|
+
`INTERVAL` literal on PostgreSQL and MySQL, `DATEADD` on SQL Server,
|
|
204
|
+
`datetime(x, '-7 day')` on SQLite:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
Post.where { :created_at > current_timestamp - 7.days }
|
|
208
|
+
# WHERE "posts"."created_at" > (CURRENT_TIMESTAMP - INTERVAL '7' DAY)
|
|
209
|
+
|
|
210
|
+
Post.select { (:published_on + 1.month).as(:review_on) }
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A duration of several parts, `1.month + 2.days`, is applied a part at a time
|
|
214
|
+
in the order Active Support keeps them, and a week is seven days, which is
|
|
215
|
+
what SQLite and Oracle have of one. Each part has to be a whole number, since
|
|
216
|
+
it is written into the SQL as one: `1.5.days` raises `ArgumentError`. SQLite
|
|
217
|
+
has no date type, so there a column the model declares a date is moved by
|
|
218
|
+
`date()` and stays a date; everything else goes through `datetime()` and comes
|
|
219
|
+
back with a time of day.
|