activerecord-refined 0.3.3 → 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 +219 -17
- 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 +238 -27
- data/lib/active_record/refined.rb +115 -20
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +428 -2
- data/test/test_helper.rb +12 -0
- 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.
|
|
@@ -87,6 +88,15 @@ Author.where { :id.in?(Post.published.select(:author_id)) }
|
|
|
87
88
|
# "authors"."id" IN (SELECT "posts"."author_id" FROM "posts" WHERE ...)
|
|
88
89
|
```
|
|
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
|
+
|
|
90
100
|
`exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
|
|
91
101
|
subquery with the outer table through qualified columns — its `where` block
|
|
92
102
|
goes through the DSL like any other:
|
|
@@ -100,7 +110,26 @@ Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
|
|
|
100
110
|
```
|
|
101
111
|
|
|
102
112
|
`like?` is case-sensitive `LIKE` on every adapter, including PostgreSQL, where
|
|
103
|
-
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
|
+
```
|
|
104
133
|
|
|
105
134
|
`start_with?`, `end_with?` and `include?` are shortcuts for the usual `like?`
|
|
106
135
|
patterns. Unlike `like?`, they treat their argument as a literal string, so `%`
|
|
@@ -120,17 +149,24 @@ Author.where { :name.start_with?('A', 'B') }
|
|
|
120
149
|
# (name LIKE 'A%' OR name LIKE 'B%')
|
|
121
150
|
```
|
|
122
151
|
|
|
123
|
-
`member
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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":
|
|
127
157
|
|
|
128
158
|
```ruby
|
|
129
|
-
Article.where { :tags.member?('ruby') }
|
|
130
|
-
Article.where { :
|
|
131
|
-
Article.where { :
|
|
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}'
|
|
132
164
|
```
|
|
133
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
|
+
|
|
134
170
|
`=~` and `!~` match a regular expression: `REGEXP` and `NOT REGEXP` on MySQL,
|
|
135
171
|
`~` and `!~` on PostgreSQL. SQLite has no regexp operator of its own, so it
|
|
136
172
|
raises there.
|
|
@@ -155,8 +191,10 @@ Reservation.where { :period == (from...to) } # daterange = '[from,to)'
|
|
|
155
191
|
Article.where { :tags == %w[ruby rails] } # text[] = '{ruby,rails}'
|
|
156
192
|
```
|
|
157
193
|
|
|
158
|
-
|
|
159
|
-
|
|
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?`:
|
|
160
198
|
|
|
161
199
|
```ruby
|
|
162
200
|
Author.where { :country.null? } # country IS NULL
|
|
@@ -186,18 +224,118 @@ Author.
|
|
|
186
224
|
Author.left_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
|
|
187
225
|
```
|
|
188
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
|
+
|
|
189
268
|
### Aggregates, functions and aliases
|
|
190
269
|
|
|
191
|
-
`count`, `sum`, `avg`, `min` and `max` are available as methods, as are the
|
|
192
|
-
functions
|
|
193
|
-
|
|
194
|
-
|
|
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:
|
|
278
|
+
|
|
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.
|
|
195
291
|
|
|
196
|
-
|
|
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 ...)`:
|
|
197
303
|
|
|
198
304
|
```ruby
|
|
199
305
|
Author.group { :country }.having { count(:*) > 1 }
|
|
200
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 }
|
|
201
339
|
```
|
|
202
340
|
|
|
203
341
|
```ruby
|
|
@@ -216,7 +354,68 @@ Author.
|
|
|
216
354
|
}
|
|
217
355
|
```
|
|
218
356
|
|
|
219
|
-
|
|
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.
|
|
220
419
|
|
|
221
420
|
## Running the tests
|
|
222
421
|
|
|
@@ -237,12 +436,15 @@ database is created on first use.
|
|
|
237
436
|
|
|
238
437
|
The `pg` and `mysql2` gems are in the Gemfile's `db` group, since building them
|
|
239
438
|
needs the client libraries installed. Skip them if SQLite is all you need,
|
|
240
|
-
which is what CI does:
|
|
439
|
+
which is what CI's SQLite job does:
|
|
241
440
|
|
|
242
441
|
```sh
|
|
243
442
|
bundle config set --local without db
|
|
244
443
|
```
|
|
245
444
|
|
|
445
|
+
CI runs all three, one job per adapter, with PostgreSQL and MySQL as service
|
|
446
|
+
containers.
|
|
447
|
+
|
|
246
448
|
## Releasing
|
|
247
449
|
|
|
248
450
|
Pushing a `v*` tag runs `.github/workflows/push_gem.yml`, which builds the gem
|
|
@@ -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
|