activerecord-refined 0.3.2 → 0.4.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/.github/workflows/test.yml +56 -2
- data/LICENSE.txt +2 -1
- data/README.md +260 -21
- data/benchmark/query_building.rb +129 -0
- data/examples/complex_joins.rb +14 -1
- data/examples/ctes.rb +82 -0
- data/examples/expressions.rb +87 -0
- data/examples/postgresql.rb +105 -0
- data/examples/predicates.rb +93 -0
- data/examples/subqueries.rb +67 -0
- data/lib/active_record/refined/ast.rb +335 -20
- data/lib/active_record/refined.rb +119 -20
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +579 -0
- data/test/test_helper.rb +21 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3818aa9d45162578a7a0f01eae7b45f943e2a2b1708ec689cd7ba484af9011e
|
|
4
|
+
data.tar.gz: 27674049c82ce8ba096f6a9416b603b25c97d326cb3ea78e557b43ffe76b25a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6a3d6b39152e68a21a21a14abd1092484eb335137a182db8068396ef7fa98ca17bd4bb152b0b028bc3ef2e1de17e8d257dcdb03a57cf093a650f387196e22ee9
|
|
7
|
+
data.tar.gz: 5e44084905b8c8d22bdde49aad72ceab1b22b1be4e139e8f60237a134a05c258413503595af0f49409ac19923ed296ecbd3f7c075c64b20e6e61f8c2100e5dd3
|
data/.github/workflows/test.yml
CHANGED
|
@@ -7,10 +7,10 @@ on:
|
|
|
7
7
|
pull_request:
|
|
8
8
|
|
|
9
9
|
jobs:
|
|
10
|
-
|
|
10
|
+
sqlite:
|
|
11
11
|
runs-on: ubuntu-latest
|
|
12
12
|
env:
|
|
13
|
-
#
|
|
13
|
+
# Nothing here loads pg or mysql2, so skip building the extensions.
|
|
14
14
|
BUNDLE_WITHOUT: db
|
|
15
15
|
steps:
|
|
16
16
|
- uses: actions/checkout@v5
|
|
@@ -21,3 +21,57 @@ jobs:
|
|
|
21
21
|
ruby-version: head
|
|
22
22
|
bundler-cache: true
|
|
23
23
|
- run: bundle exec rake test
|
|
24
|
+
|
|
25
|
+
postgresql:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
env:
|
|
28
|
+
ADAPTER: postgresql
|
|
29
|
+
DB_USERNAME: postgres
|
|
30
|
+
DB_PASSWORD: postgres
|
|
31
|
+
services:
|
|
32
|
+
postgres:
|
|
33
|
+
image: postgres:16
|
|
34
|
+
env:
|
|
35
|
+
POSTGRES_PASSWORD: postgres
|
|
36
|
+
ports:
|
|
37
|
+
- 5432:5432
|
|
38
|
+
options: >-
|
|
39
|
+
--health-cmd pg_isready
|
|
40
|
+
--health-interval 10s
|
|
41
|
+
--health-timeout 5s
|
|
42
|
+
--health-retries 5
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v5
|
|
45
|
+
- uses: ruby/setup-ruby@v1
|
|
46
|
+
with:
|
|
47
|
+
ruby-version: head
|
|
48
|
+
bundler-cache: true
|
|
49
|
+
- run: bundle exec rake test
|
|
50
|
+
|
|
51
|
+
mysql:
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
env:
|
|
54
|
+
ADAPTER: mysql2
|
|
55
|
+
# The suite creates its own database, which MYSQL_USER would not be
|
|
56
|
+
# granted; root is the account that can.
|
|
57
|
+
DB_USERNAME: root
|
|
58
|
+
DB_PASSWORD: root
|
|
59
|
+
services:
|
|
60
|
+
mysql:
|
|
61
|
+
image: mysql:8
|
|
62
|
+
env:
|
|
63
|
+
MYSQL_ROOT_PASSWORD: root
|
|
64
|
+
ports:
|
|
65
|
+
- 3306:3306
|
|
66
|
+
options: >-
|
|
67
|
+
--health-cmd "mysqladmin ping -proot"
|
|
68
|
+
--health-interval 10s
|
|
69
|
+
--health-timeout 5s
|
|
70
|
+
--health-retries 5
|
|
71
|
+
steps:
|
|
72
|
+
- uses: actions/checkout@v5
|
|
73
|
+
- uses: ruby/setup-ruby@v1
|
|
74
|
+
with:
|
|
75
|
+
ruby-version: head
|
|
76
|
+
bundler-cache: true
|
|
77
|
+
- run: bundle exec rake test
|
data/LICENSE.txt
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
Copyright (c) 2012 Akira Matsuda
|
|
2
|
+
Copyright (c) 2026 Shugo Maeda
|
|
2
3
|
|
|
3
4
|
MIT License
|
|
4
5
|
|
|
@@ -19,4 +20,4 @@ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
|
19
20
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
21
|
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
22
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
-
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# ActiveRecord::Refined
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/activerecord-refined)
|
|
3
4
|
[](https://github.com/shugo/activerecord-refined/actions/workflows/test.yml)
|
|
4
5
|
|
|
5
6
|
Adding clean and powerful query syntax on ActiveRecord using refinements.
|
|
@@ -78,8 +79,57 @@ Author.where { :country.in?(%w[JP US]) } # IN
|
|
|
78
79
|
Author.where { :country.null? } # IS NULL
|
|
79
80
|
```
|
|
80
81
|
|
|
82
|
+
`in?` also takes a relation as a subquery. Without an explicit select list the
|
|
83
|
+
subquery selects the relation's primary key, the same way ActiveRecord's own
|
|
84
|
+
`where(id: relation)` does:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
Author.where { :id.in?(Post.published.select(:author_id)) }
|
|
88
|
+
# "authors"."id" IN (SELECT "posts"."author_id" FROM "posts" WHERE ...)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
A relation on the right of a comparison is a scalar subquery. It has to select
|
|
92
|
+
one value, so unlike `in?` there is no default select list and one is
|
|
93
|
+
required:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
Author.where { :age >= Author.select { avg(:age) } }
|
|
97
|
+
# "authors"."age" >= (SELECT AVG("authors"."age") FROM "authors")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
|
|
101
|
+
subquery with the outer table through qualified columns — its `where` block
|
|
102
|
+
goes through the DSL like any other:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
Author.where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
|
|
106
|
+
# EXISTS (SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = "authors"."id")
|
|
107
|
+
|
|
108
|
+
Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
|
|
109
|
+
# NOT (EXISTS (...))
|
|
110
|
+
```
|
|
111
|
+
|
|
81
112
|
`like?` is case-sensitive `LIKE` on every adapter, including PostgreSQL, where
|
|
82
|
-
Arel would otherwise reach for `ILIKE`.
|
|
113
|
+
Arel would otherwise reach for `ILIKE`. `ilike?` is the one that asks for
|
|
114
|
+
`ILIKE`; off PostgreSQL it is plain `LIKE`, which those adapters already match
|
|
115
|
+
case-insensitively under their default collations. `casecmp?` is
|
|
116
|
+
case-insensitive equality, folded on both sides rather than left to the
|
|
117
|
+
collation, so it means the same thing everywhere:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
Author.where { :name.ilike?('ma%') } # ILIKE 'ma%' / LIKE 'ma%'
|
|
121
|
+
Author.where { :name.casecmp?('Matz') } # LOWER(name) = LOWER('Matz')
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`not_distinct_from?` and `distinct_from?` compare with NULL treated as a
|
|
125
|
+
value, rather than as the unknown that makes `=` and `<>` neither true nor
|
|
126
|
+
false. PostgreSQL spells this `IS [NOT] DISTINCT FROM`, SQLite `IS` / `IS NOT`
|
|
127
|
+
and MySQL `<=>`, and the rows that come back are the same on all three:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
Author.where { :country.not_distinct_from?(params[:country]) } # matches NULL to nil
|
|
131
|
+
Author.where { :country.distinct_from?('JP') } # keeps the NULL rows
|
|
132
|
+
```
|
|
83
133
|
|
|
84
134
|
`start_with?`, `end_with?` and `include?` are shortcuts for the usual `like?`
|
|
85
135
|
patterns. Unlike `like?`, they treat their argument as a literal string, so `%`
|
|
@@ -91,6 +141,32 @@ Author.where { :name.end_with?('son') } # LIKE '%son'
|
|
|
91
141
|
Author.where { :name.include?('test') } # LIKE '%test%'
|
|
92
142
|
```
|
|
93
143
|
|
|
144
|
+
Like their String namesakes, `start_with?` and `end_with?` take any number of
|
|
145
|
+
literals; matching any one of them is enough:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
Author.where { :name.start_with?('A', 'B') }
|
|
149
|
+
# (name LIKE 'A%' OR name LIKE 'B%')
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`member?`, `superset?`, `subset?` and `intersect?` compare against a
|
|
153
|
+
PostgreSQL array column, each carrying the meaning of its Ruby namesake:
|
|
154
|
+
`member?` is Enumerable's element test (which String does not have — that is
|
|
155
|
+
what separates it from `include?`), `superset?` and `subset?` are Set's
|
|
156
|
+
whole-array containment, and `intersect?` is Array's "any element in common":
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
Article.where { :tags.member?('ruby') } # tags @> '{ruby}'
|
|
160
|
+
Article.where { :scores.member?(80) } # scores @> '{80}'
|
|
161
|
+
Article.where { :tags.superset?(%w[ruby rails]) } # tags @> '{ruby,rails}'
|
|
162
|
+
Article.where { :tags.subset?(%w[ruby rails go]) } # tags <@ '{ruby,rails,go}'
|
|
163
|
+
Article.where { :tags.intersect?(%w[ruby go]) } # tags && '{ruby,go}'
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Like its namesake, `member?` takes one element — `[1, 2].member?([1])` is
|
|
167
|
+
false in Ruby, so an Array argument raises rather than quietly meaning
|
|
168
|
+
something `Array#member?` does not. Requiring every element is `superset?`.
|
|
169
|
+
|
|
94
170
|
`=~` and `!~` match a regular expression: `REGEXP` and `NOT REGEXP` on MySQL,
|
|
95
171
|
`~` and `!~` on PostgreSQL. SQLite has no regexp operator of its own, so it
|
|
96
172
|
raises there.
|
|
@@ -115,6 +191,16 @@ Reservation.where { :period == (from...to) } # daterange = '[from,to)'
|
|
|
115
191
|
Article.where { :tags == %w[ruby rails] } # text[] = '{ruby,rails}'
|
|
116
192
|
```
|
|
117
193
|
|
|
194
|
+
`!=` is SQL `!=` under the same rules, value passed through untouched.
|
|
195
|
+
|
|
196
|
+
For the same reason `== nil` and `!= nil` raise `ArgumentError`: `= NULL` is
|
|
197
|
+
never true in SQL, so a NULL test has to be spelled as one. Use `null?`:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
Author.where { :country.null? } # country IS NULL
|
|
201
|
+
Author.where { !:country.null? } # NOT (country IS NULL)
|
|
202
|
+
```
|
|
203
|
+
|
|
118
204
|
Combine predicates with `&`, `|` and `!`. Ruby's operator precedence makes the
|
|
119
205
|
parentheses around each comparison necessary, though the `?` methods above need
|
|
120
206
|
none:
|
|
@@ -138,18 +224,118 @@ Author.
|
|
|
138
224
|
Author.left_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
139
225
|
```
|
|
140
226
|
|
|
227
|
+
`as` names the table within the query, which is what makes a self join
|
|
228
|
+
expressible — the qualified columns in the block go by that name:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
Employee.joins(:employees, as: :managers) { :managers[:id] == :employees[:manager_id] }
|
|
232
|
+
# SELECT "employees".* FROM "employees"
|
|
233
|
+
# INNER JOIN "employees" "managers" ON "managers"."id" = "employees"."manager_id"
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Common table expressions
|
|
237
|
+
|
|
238
|
+
ActiveRecord's `with` and `with_recursive` need nothing from this gem: a CTE
|
|
239
|
+
is joined by name like any other table, so its `ON` clause is a block, where
|
|
240
|
+
Rails' own documentation reaches for a string join.
|
|
241
|
+
|
|
242
|
+
`from` takes the CTE's name as a symbol, with `as` to select it under the
|
|
243
|
+
model's own table name so the model's columns resolve:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
Node.with_recursive(
|
|
247
|
+
tree: [
|
|
248
|
+
Node.where { :id == root.id },
|
|
249
|
+
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] },
|
|
250
|
+
]
|
|
251
|
+
).from(:tree, as: :nodes)
|
|
252
|
+
# WITH RECURSIVE "tree" AS (
|
|
253
|
+
# SELECT "nodes".* FROM "nodes" WHERE "nodes"."id" = 1
|
|
254
|
+
# UNION ALL
|
|
255
|
+
# SELECT "nodes".* FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
|
|
256
|
+
# ) SELECT "nodes".* FROM "tree" AS "nodes"
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
A non-recursive CTE joins the same way:
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
Node.with(roots: Node.where { :parent_id.null? }).
|
|
263
|
+
joins(:roots) { :roots[:id] == :nodes[:parent_id] }
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`examples/ctes.rb` walks a category tree with these.
|
|
267
|
+
|
|
141
268
|
### Aggregates, functions and aliases
|
|
142
269
|
|
|
143
|
-
`count`, `sum`, `avg`, `min` and `max` are available as methods, as are the
|
|
144
|
-
functions
|
|
145
|
-
|
|
146
|
-
|
|
270
|
+
`count`, `sum`, `avg`, `min` and `max` are available as methods, as are the
|
|
271
|
+
scalar functions below, with `fn` for anything else. Use `.as` for a column
|
|
272
|
+
alias, and `.asc` / `.desc` for the sort direction. Return an array to select
|
|
273
|
+
or order by multiple expressions.
|
|
274
|
+
|
|
275
|
+
The scalar functions are real methods rather than anything caught dynamically,
|
|
276
|
+
so a misspelling is a `NoMethodError` where you wrote it, and a name Ruby also
|
|
277
|
+
answers to — `rand` — means the SQL one inside a block:
|
|
147
278
|
|
|
148
|
-
|
|
279
|
+
```
|
|
280
|
+
abs ceil char_length coalesce concat date_trunc exp floor format
|
|
281
|
+
greatest least length ln log lower ltrim mod now nullif power rand
|
|
282
|
+
replace round rtrim sqrt substr trim upper
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Most are spelled the same everywhere. Where they are not, the method names one
|
|
286
|
+
meaning and each adapter gets its own spelling: `char_length`, `greatest` and
|
|
287
|
+
`least` become `LENGTH`, `MAX` and `MIN` on SQLite, and `rand` is `RAND` on
|
|
288
|
+
MySQL and `RANDOM` elsewhere. Where an adapter has no equivalent — `date_trunc`
|
|
289
|
+
outside PostgreSQL, `now` on SQLite — the block raises `NotImplementedError`
|
|
290
|
+
rather than leaving the database to reject the SQL.
|
|
291
|
+
|
|
292
|
+
`format` is printf formatting, and raises on MySQL, where a function of the
|
|
293
|
+
same name does something else entirely: it puts separators in a number, and
|
|
294
|
+
reads a printf template as the number zero rather than complaining. `fn` still
|
|
295
|
+
reaches it, spelled as the different thing it is:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
Post.select { fn(:format, :amount, 2) } # MySQL's, on purpose
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Pass `:*` to `count` for `COUNT(*)`, and `distinct: true` for
|
|
302
|
+
`COUNT(DISTINCT ...)`:
|
|
149
303
|
|
|
150
304
|
```ruby
|
|
151
305
|
Author.group { :country }.having { count(:*) > 1 }
|
|
152
306
|
# SELECT "authors".* FROM "authors" GROUP BY "authors"."country" HAVING COUNT(*) > 1
|
|
307
|
+
|
|
308
|
+
Post.select { count(:author_id, distinct: true) } # COUNT(DISTINCT "author_id")
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Values are quoted by the adapter wherever they appear, as they are in
|
|
312
|
+
ActiveRecord. Column aliases and `fn`'s function name are not — they are
|
|
313
|
+
written into the SQL as given — so those two have to be plain names,
|
|
314
|
+
optionally qualified by a schema in `fn`'s case. Anything else raises
|
|
315
|
+
`ArgumentError` rather than reaching the query.
|
|
316
|
+
|
|
317
|
+
`fn` reaches functions without a method of their own. Its name is emitted as
|
|
318
|
+
written, so a case-sensitive one can be spelled exactly:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
Post.select { fn(:date_trunc, 'day', :created_at).as(:day) }
|
|
322
|
+
# SELECT date_trunc('day', "posts"."created_at") AS day
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`+`, `-`, `*` and `/` build arithmetic. Ruby puts them above the comparison
|
|
326
|
+
operators, so an expression groups the way it reads:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
Item.where { :price * :quantity > 1000 }
|
|
330
|
+
Item.select { sum(:price * :quantity).as(:total) }
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`.asc` and `.desc` take `.nulls_first` / `.nulls_last`. MySQL has no such
|
|
334
|
+
syntax, but Arel emulates it there, so the resulting order is the same
|
|
335
|
+
everywhere:
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
Author.order { :country.asc.nulls_last }
|
|
153
339
|
```
|
|
154
340
|
|
|
155
341
|
```ruby
|
|
@@ -168,7 +354,68 @@ Author.
|
|
|
168
354
|
}
|
|
169
355
|
```
|
|
170
356
|
|
|
171
|
-
|
|
357
|
+
## Examples
|
|
358
|
+
|
|
359
|
+
`examples/` holds runnable scripts, each printing the SQL it builds and, where
|
|
360
|
+
the result is the point, the rows that come back. All but the last run against
|
|
361
|
+
an in-memory SQLite database and need no setup.
|
|
362
|
+
|
|
363
|
+
| | |
|
|
364
|
+
| --- | --- |
|
|
365
|
+
| `predicates.rb` | the `where` vocabulary: ranges, sets, NULL, text matching |
|
|
366
|
+
| `subqueries.rb` | `in?` with a relation, `exists?`, scalar subqueries |
|
|
367
|
+
| `expressions.rb` | arithmetic, aggregates, functions, `NULLS LAST` |
|
|
368
|
+
| `complex_joins.rb` | compound `ON` clauses, outer joins, a self join |
|
|
369
|
+
| `aggregations.rb` | `GROUP BY`, `HAVING` and aggregates across joins |
|
|
370
|
+
| `ctes.rb` | `with` and `with_recursive` |
|
|
371
|
+
| `postgresql.rb` | array columns, regular expressions, `ILIKE` (needs a server) |
|
|
372
|
+
|
|
373
|
+
## Performance
|
|
374
|
+
|
|
375
|
+
`benchmark/query_building.rb` compares building the same queries through the
|
|
376
|
+
block DSL and through ActiveRecord's other argument styles. Only query
|
|
377
|
+
construction (through `to_sql`) is measured — every style produces the same
|
|
378
|
+
SQL, so execution costs the same regardless.
|
|
379
|
+
|
|
380
|
+
Queries built per second (ruby 4.1.0dev, ActiveRecord 8.1.3, one machine —
|
|
381
|
+
treat the ratios, not the absolute numbers, as the result):
|
|
382
|
+
|
|
383
|
+
| query | string | arel | block (this gem) | hash | relation and/or |
|
|
384
|
+
| --- | --- | --- | --- | --- | --- |
|
|
385
|
+
| simple equality | 42.5k | 42.0k | 37.7k | 31.5k | — |
|
|
386
|
+
| range (BETWEEN) | — | 34.6k | 32.7k | 24.8k | — |
|
|
387
|
+
| LIKE | 41.5k | 41.0k | 36.8k | — | — |
|
|
388
|
+
| compound AND/OR | 34.4k | 27.7k | 24.4k | — | 11.9k |
|
|
389
|
+
|
|
390
|
+
Allocated memory per built query:
|
|
391
|
+
|
|
392
|
+
| query | arel | block (this gem) | hash | string | relation and/or |
|
|
393
|
+
| --- | --- | --- | --- | --- | --- |
|
|
394
|
+
| simple equality | 2,600 B | 2,832 B | 3,328 B | 3,448 B | — |
|
|
395
|
+
| compound AND/OR | 3,208 B | 3,584 B | — | 4,680 B | 9,120 B |
|
|
396
|
+
|
|
397
|
+
In short: the block DSL is 6–13% slower than hand-written Arel (which it
|
|
398
|
+
compiles to), a little faster than hash conditions, and both faster and
|
|
399
|
+
leaner than `where(...).and(where(...).or(where(...)))` relation chains,
|
|
400
|
+
which pay for structural-compatibility checks and relation copies. The
|
|
401
|
+
`Proc#refined` call itself costs about 150 ns of the ~25 μs build — the
|
|
402
|
+
re-interpretation of the block is not where the time goes. Against a
|
|
403
|
+
database round trip of tens to hundreds of microseconds, none of these
|
|
404
|
+
differences are visible in an application.
|
|
405
|
+
|
|
406
|
+
One memory cost sits outside the per-query numbers above: to run a block
|
|
407
|
+
under the refinements, `Proc#refined` deep-copies its instruction sequence,
|
|
408
|
+
nested blocks included. The copy is made lazily on the refined proc's first
|
|
409
|
+
call and memoized per block and refinement list for the life of the process,
|
|
410
|
+
so it is paid once per `where { ... }` call site, not per query — the
|
|
411
|
+
benchmark measures the copy at the size of the original (568 bytes for the
|
|
412
|
+
simple-equality block, 888 bytes for the compound one), and a thousand
|
|
413
|
+
further calls from the same call site copy nothing. Steady state, an
|
|
414
|
+
application holds one extra copy of each distinct query block's bytecode:
|
|
415
|
+
a few hundred bytes per call site. "Per call site" assumes blocks compiled
|
|
416
|
+
once, as normal code is — building query blocks with a string `eval` mints
|
|
417
|
+
a fresh instruction sequence per pass, each earning a copy of its own, and
|
|
418
|
+
the memo keeps both alive for the life of the process.
|
|
172
419
|
|
|
173
420
|
## Running the tests
|
|
174
421
|
|
|
@@ -189,12 +436,15 @@ database is created on first use.
|
|
|
189
436
|
|
|
190
437
|
The `pg` and `mysql2` gems are in the Gemfile's `db` group, since building them
|
|
191
438
|
needs the client libraries installed. Skip them if SQLite is all you need,
|
|
192
|
-
which is what CI does:
|
|
439
|
+
which is what CI's SQLite job does:
|
|
193
440
|
|
|
194
441
|
```sh
|
|
195
442
|
bundle config set --local without db
|
|
196
443
|
```
|
|
197
444
|
|
|
445
|
+
CI runs all three, one job per adapter, with PostgreSQL and MySQL as service
|
|
446
|
+
containers.
|
|
447
|
+
|
|
198
448
|
## Releasing
|
|
199
449
|
|
|
200
450
|
Pushing a `v*` tag runs `.github/workflows/push_gem.yml`, which builds the gem
|
|
@@ -202,21 +452,10 @@ and publishes it through RubyGems.org's trusted publishing, so no API key is
|
|
|
202
452
|
stored anywhere.
|
|
203
453
|
|
|
204
454
|
```sh
|
|
205
|
-
|
|
206
|
-
git
|
|
207
|
-
git push origin v0.4.0
|
|
455
|
+
bump patch --tag # or bump {major,minor} etc.
|
|
456
|
+
git push --follow-tags
|
|
208
457
|
```
|
|
209
458
|
|
|
210
|
-
This needs a trusted publisher registered once at
|
|
211
|
-
<https://rubygems.org/gems/activerecord-refined/trusted_publishers>:
|
|
212
|
-
|
|
213
|
-
| Field | Value |
|
|
214
|
-
| --- | --- |
|
|
215
|
-
| Repository owner | `shugo` |
|
|
216
|
-
| Repository name | `activerecord-refined` |
|
|
217
|
-
| Workflow filename | `push_gem.yml` |
|
|
218
|
-
| Environment | `release` |
|
|
219
|
-
|
|
220
459
|
## Contributing
|
|
221
460
|
|
|
222
461
|
1. Fork it
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Compares the cost of building the same queries through this gem's block
|
|
2
|
+
# DSL and through ActiveRecord's other argument styles: hash conditions,
|
|
3
|
+
# string conditions, raw Arel, and relation and/or chains. Only query
|
|
4
|
+
# construction (through to_sql) is measured; every style produces the same
|
|
5
|
+
# SQL, which the script prints first as a sanity check.
|
|
6
|
+
#
|
|
7
|
+
# Run without bundler, so the profiling gems don't need to live in the
|
|
8
|
+
# Gemfile:
|
|
9
|
+
#
|
|
10
|
+
# gem install benchmark-ips memory_profiler
|
|
11
|
+
# ruby -Ilib benchmark/query_building.rb
|
|
12
|
+
|
|
13
|
+
require "benchmark/ips"
|
|
14
|
+
require "memory_profiler"
|
|
15
|
+
require "active_record"
|
|
16
|
+
require "activerecord-refined"
|
|
17
|
+
|
|
18
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
19
|
+
ActiveRecord::Schema.verbose = false
|
|
20
|
+
ActiveRecord::Schema.define do
|
|
21
|
+
create_table(:users) {|t| t.string :name; t.integer :age }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class User < ActiveRecord::Base; end
|
|
25
|
+
|
|
26
|
+
T = User.arel_table
|
|
27
|
+
|
|
28
|
+
# Each variant must generate equivalent SQL; sanity-print once.
|
|
29
|
+
VARIANTS = {
|
|
30
|
+
"simple equality" => {
|
|
31
|
+
"hash" => -> { User.where(name: "matz").to_sql },
|
|
32
|
+
"string" => -> { User.where("name = ?", "matz").to_sql },
|
|
33
|
+
"arel" => -> { User.where(T[:name].eq("matz")).to_sql },
|
|
34
|
+
"block" => -> { User.where { :name == "matz" }.to_sql },
|
|
35
|
+
},
|
|
36
|
+
"range (BETWEEN)" => {
|
|
37
|
+
"hash" => -> { User.where(age: 20..40).to_sql },
|
|
38
|
+
"arel" => -> { User.where(T[:age].between(20..40)).to_sql },
|
|
39
|
+
"block" => -> { User.where { :age.in?(20..40) }.to_sql },
|
|
40
|
+
},
|
|
41
|
+
"LIKE" => {
|
|
42
|
+
"string" => -> { User.where("name LIKE ?", "ma%").to_sql },
|
|
43
|
+
"arel" => -> { User.where(T[:name].matches("ma%", nil, true)).to_sql },
|
|
44
|
+
"block" => -> { User.where { :name.like?("ma%") }.to_sql },
|
|
45
|
+
},
|
|
46
|
+
"compound AND/OR" => {
|
|
47
|
+
"string" => -> { User.where("age >= ? AND (name = ? OR name = ?)", 18, "matz", "nobu").to_sql },
|
|
48
|
+
"arel" => -> { User.where(T[:age].gteq(18).and(T[:name].eq("matz").or(T[:name].eq("nobu")))).to_sql },
|
|
49
|
+
"relation" => -> { User.where(age: 18..).and(User.where(name: "matz").or(User.where(name: "nobu"))).to_sql },
|
|
50
|
+
"block" => -> { User.where { (:age >= 18) & ((:name == "matz") | (:name == "nobu")) }.to_sql },
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
puts "=== generated SQL (sanity) ==="
|
|
55
|
+
VARIANTS.each do |group, variants|
|
|
56
|
+
puts "--- #{group} ---"
|
|
57
|
+
variants.each {|name, thunk| puts " #{name.ljust(8)} #{thunk.call}" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts
|
|
61
|
+
puts "=== speed (queries built per second) ==="
|
|
62
|
+
VARIANTS.each do |group, variants|
|
|
63
|
+
puts "--- #{group} ---"
|
|
64
|
+
Benchmark.ips do |x|
|
|
65
|
+
x.config(warmup: 0.5, time: 2)
|
|
66
|
+
variants.each {|name, thunk| x.report(name, &thunk) }
|
|
67
|
+
x.compare!
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
puts
|
|
72
|
+
puts "=== memory (per single call) ==="
|
|
73
|
+
fmt = "%-18s %-9s %12s %12s"
|
|
74
|
+
puts format(fmt, "group", "variant", "allocated B", "objects")
|
|
75
|
+
VARIANTS.each do |group, variants|
|
|
76
|
+
variants.each do |name, thunk|
|
|
77
|
+
thunk.call # warm caches (schema, statement caches) outside the report
|
|
78
|
+
report = MemoryProfiler.report { thunk.call }
|
|
79
|
+
puts format(fmt, group, name, report.total_allocated_memsize, report.total_allocated)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
puts
|
|
84
|
+
puts "=== Proc#refined ISeq copy (memory) ==="
|
|
85
|
+
require "objspace"
|
|
86
|
+
|
|
87
|
+
# Proc#refined runs the block under the refinements by deep-copying its
|
|
88
|
+
# instruction sequence, nested blocks included. The copy is made lazily on
|
|
89
|
+
# the refined proc's first call and memoized per source iseq and refinement
|
|
90
|
+
# list for the life of the VM, so it is paid once per block call site, not
|
|
91
|
+
# per query.
|
|
92
|
+
iseq_count = -> {
|
|
93
|
+
counts = ObjectSpace.count_imemo_objects
|
|
94
|
+
counts[:imemo_iseq] || counts[:iseq]
|
|
95
|
+
}
|
|
96
|
+
context = ActiveRecord::Refined::BlockContext.new
|
|
97
|
+
syntax = ActiveRecord::Refined::BlockSyntax
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
"simple equality" => proc { :name == "matz" },
|
|
101
|
+
"compound AND/OR" => proc { (:age >= 18) & ((:name == "matz") | (:name == "nobu")) },
|
|
102
|
+
}.each do |label, blk|
|
|
103
|
+
refined = blk.refined(syntax)
|
|
104
|
+
context.instance_exec(&refined) # the copy is made here, on the first call
|
|
105
|
+
original = ObjectSpace.memsize_of(RubyVM::InstructionSequence.of(blk))
|
|
106
|
+
copy = ObjectSpace.memsize_of(RubyVM::InstructionSequence.of(refined))
|
|
107
|
+
puts "#{label}: original iseq #{original} B, refined copy #{copy} B"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
make_proc = -> { proc { :age > 20 } }
|
|
111
|
+
context.instance_exec(&make_proc.call.refined(syntax))
|
|
112
|
+
before = iseq_count.call
|
|
113
|
+
1000.times { context.instance_exec(&make_proc.call.refined(syntax)) }
|
|
114
|
+
puts "1000 more calls from the same call site copied #{iseq_count.call - before} iseqs"
|
|
115
|
+
|
|
116
|
+
puts
|
|
117
|
+
puts "=== where the block path spends its time ==="
|
|
118
|
+
block = proc { :name == "matz" }
|
|
119
|
+
refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
|
|
120
|
+
context = ActiveRecord::Refined::BlockContext.new
|
|
121
|
+
Benchmark.ips do |x|
|
|
122
|
+
x.config(warmup: 0.5, time: 2)
|
|
123
|
+
x.report("Proc#refined alone") { block.refined(ActiveRecord::Refined::BlockSyntax) }
|
|
124
|
+
x.report("instance_exec of pre-refined proc") { context.instance_exec(&refined_block) }
|
|
125
|
+
x.report("refined + instance_exec") {
|
|
126
|
+
ActiveRecord::Refined::BlockContext.new.instance_exec(&block.refined(ActiveRecord::Refined::BlockSyntax))
|
|
127
|
+
}
|
|
128
|
+
x.compare!
|
|
129
|
+
end
|
data/examples/complex_joins.rb
CHANGED
|
@@ -8,7 +8,7 @@ ActiveRecord::Migration.verbose = false
|
|
|
8
8
|
|
|
9
9
|
class Setup < ActiveRecord::Migration[8.1]
|
|
10
10
|
def up
|
|
11
|
-
create_table(:authors) {|t| t.string :name; t.integer :age; t.string :country }
|
|
11
|
+
create_table(:authors) {|t| t.string :name; t.integer :age; t.string :country; t.integer :mentor_id }
|
|
12
12
|
create_table(:posts) {|t| t.string :title; t.integer :author_id; t.integer :likes; t.boolean :published }
|
|
13
13
|
create_table(:comments){|t| t.string :body; t.integer :post_id; t.integer :score }
|
|
14
14
|
end
|
|
@@ -67,3 +67,16 @@ query3 =
|
|
|
67
67
|
puts "--- 3. LEFT OUTER JOIN with negation ---"
|
|
68
68
|
puts query3.to_sql
|
|
69
69
|
puts
|
|
70
|
+
|
|
71
|
+
# 4. Self join. `as` names the table within the query, and the block's
|
|
72
|
+
# qualified columns go by that name, which is what makes a table joinable
|
|
73
|
+
# to itself.
|
|
74
|
+
query4 =
|
|
75
|
+
Author.
|
|
76
|
+
joins(:authors, as: :mentors) { :mentors[:id] == :authors[:mentor_id] }.
|
|
77
|
+
where { :mentors[:country] != :authors[:country] }.
|
|
78
|
+
select { [:authors[:name].as(:author), :mentors[:name].as(:mentor)] }
|
|
79
|
+
|
|
80
|
+
puts "--- 4. Self join through an alias ---"
|
|
81
|
+
puts query4.to_sql
|
|
82
|
+
puts
|
data/examples/ctes.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
4
|
+
require 'activerecord-refined'
|
|
5
|
+
|
|
6
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
|
|
7
|
+
ActiveRecord::Migration.verbose = false
|
|
8
|
+
|
|
9
|
+
class Setup < ActiveRecord::Migration[8.1]
|
|
10
|
+
def up
|
|
11
|
+
create_table(:categories) {|t| t.string :name; t.integer :parent_id }
|
|
12
|
+
create_table(:products) {|t| t.string :name; t.integer :category_id; t.integer :price }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
Setup.new.up
|
|
16
|
+
|
|
17
|
+
class Category < ActiveRecord::Base
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Product < ActiveRecord::Base
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
electronics = Category.create!(name: 'electronics')
|
|
24
|
+
computers = Category.create!(name: 'computers', parent_id: electronics.id)
|
|
25
|
+
laptops = Category.create!(name: 'laptops', parent_id: computers.id)
|
|
26
|
+
groceries = Category.create!(name: 'groceries')
|
|
27
|
+
|
|
28
|
+
Product.create!(name: 'ultrabook', category_id: laptops.id, price: 1200)
|
|
29
|
+
Product.create!(name: 'keyboard', category_id: computers.id, price: 80)
|
|
30
|
+
Product.create!(name: 'apple', category_id: groceries.id, price: 2)
|
|
31
|
+
|
|
32
|
+
# 1. Recursive CTE: every category below 'electronics', itself included.
|
|
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. `from`
|
|
35
|
+
# then selects the CTE under the model's table name, which is what lets
|
|
36
|
+
# Category's own columns resolve against it.
|
|
37
|
+
subtree =
|
|
38
|
+
Category.with_recursive(
|
|
39
|
+
tree: [
|
|
40
|
+
Category.where { :id == electronics.id },
|
|
41
|
+
Category.joins(:tree) { :categories[:parent_id] == :tree[:id] },
|
|
42
|
+
]
|
|
43
|
+
).from(:tree, as: :categories)
|
|
44
|
+
|
|
45
|
+
puts '--- 1. Recursive CTE walking a category tree ---'
|
|
46
|
+
puts subtree.to_sql
|
|
47
|
+
puts subtree.order { :name }.pluck(:name).inspect
|
|
48
|
+
puts
|
|
49
|
+
|
|
50
|
+
# 2. The same CTE as a subquery: products anywhere under 'electronics'.
|
|
51
|
+
# The outer query joins the CTE by name like any other table.
|
|
52
|
+
products_below =
|
|
53
|
+
Product.with_recursive(
|
|
54
|
+
tree: [
|
|
55
|
+
Category.where { :id == electronics.id },
|
|
56
|
+
Category.joins(:tree) { :categories[:parent_id] == :tree[:id] },
|
|
57
|
+
]
|
|
58
|
+
).joins(:tree) { :tree[:id] == :products[:category_id] }
|
|
59
|
+
|
|
60
|
+
puts '--- 2. Recursive CTE joined from the outer query ---'
|
|
61
|
+
puts products_below.to_sql
|
|
62
|
+
puts products_below.order { :name }.pluck(:name).inspect
|
|
63
|
+
puts
|
|
64
|
+
|
|
65
|
+
# 3. A plain CTE, named once and used twice: categories that hold something
|
|
66
|
+
# expensive, and the count of products in each.
|
|
67
|
+
expensive =
|
|
68
|
+
Category.with(pricey: Product.where { :price >= 100 }).
|
|
69
|
+
joins(:pricey) { :pricey[:category_id] == :categories[:id] }.
|
|
70
|
+
group { :categories[:id] }.
|
|
71
|
+
select {
|
|
72
|
+
[
|
|
73
|
+
:categories[:name].as(:category),
|
|
74
|
+
count(:pricey[:id]).as(:pricey_count),
|
|
75
|
+
max(:pricey[:price]).as(:top_price),
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
puts '--- 3. Plain CTE joined and aggregated ---'
|
|
80
|
+
puts expensive.to_sql
|
|
81
|
+
puts expensive.map {|c| [c.category, c.pricey_count, c.top_price] }.inspect
|
|
82
|
+
puts
|