activerecord-refined 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/sandbox.yml +158 -0
- data/README.md +128 -13
- data/activerecord-refined.gemspec +4 -1
- data/benchmark/query_building.rb +12 -12
- data/examples/ctes.rb +39 -8
- data/examples/predicates.rb +11 -11
- data/examples/subqueries.rb +5 -5
- data/lib/active_record/refined/ast.rb +144 -15
- data/lib/active_record/refined.rb +109 -10
- data/lib/activerecord-refined/version.rb +1 -1
- data/lib/activerecord-refined.rb +4 -0
- data/test/test_block_syntax.rb +349 -38
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98f198f59d3bb4b6f4c51a16940d4e7ee44b890aa7e033a327a650b73cb3dfbd
|
|
4
|
+
data.tar.gz: 5fb7cdd8964b82d6bb14ffdfecf1400ea875f1c5bc12d7fa4645c431caf47d19
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ea7a29d21b31e3bf5f262a54eacb8e4e27590d9031f560d8de635da958cf91e6c5ef0872e77caf7b4e32907d2985f13fcd2ff8492ffbeda0edff1d8b7f658f4
|
|
7
|
+
data.tar.gz: 4949bb4ce9b0e4cd0f964e7f2759460a72707734a56d746cbf1221f80f9f7a87161c4a494c0226b880d7ad905e6c83933b29254c5ce3e59c41de415339557f56
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
name: sandbox
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: sandbox-${{ github.ref }}
|
|
11
|
+
cancel-in-progress: true
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
build:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
defaults:
|
|
17
|
+
run:
|
|
18
|
+
working-directory: sandbox
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
|
|
23
|
+
# The host Ruby cannot be 4.1: ruby_wasm's native extension depends on
|
|
24
|
+
# rb-sys, which does not build against Ruby 4.1's headers. rbwasm is
|
|
25
|
+
# only a build tool -- the Ruby it produces is the one that matters.
|
|
26
|
+
- uses: ruby/setup-ruby@v1
|
|
27
|
+
with:
|
|
28
|
+
ruby-version: '4.0'
|
|
29
|
+
bundler-cache: false
|
|
30
|
+
working-directory: sandbox
|
|
31
|
+
|
|
32
|
+
- uses: actions/setup-node@v7
|
|
33
|
+
with:
|
|
34
|
+
node-version: '24'
|
|
35
|
+
|
|
36
|
+
# The Ruby checkout and the build tree have to be cached together. make
|
|
37
|
+
# decides by mtime, so restoring objects next to a freshly cloned Ruby
|
|
38
|
+
# would rebuild everything.
|
|
39
|
+
#
|
|
40
|
+
# The build directory is keyed on the gems that have C extensions, so
|
|
41
|
+
# Gemfile.lock covers it; RUBY_REV is pinned in bin/build-wasm, and the
|
|
42
|
+
# staged extension comes from ext/ and the sqlite3 gem.
|
|
43
|
+
#
|
|
44
|
+
# restore-keys is what keeps this worth caching -- a key change would
|
|
45
|
+
# otherwise mean a cold 16-minute build rather than two minutes -- but it
|
|
46
|
+
# also means a bad tree outlives the key that produced it, since the
|
|
47
|
+
# prefix still matches. Editing a file in the key does not clear it;
|
|
48
|
+
# deleting the cache does. bin/build-wasm rejects a ruby.wasm built from
|
|
49
|
+
# such a tree, so it fails the run rather than shipping.
|
|
50
|
+
- name: Restore the WASM build tree
|
|
51
|
+
uses: actions/cache@v6
|
|
52
|
+
with:
|
|
53
|
+
path: sandbox/build
|
|
54
|
+
key: wasm-${{ runner.os }}-${{ hashFiles('sandbox/Gemfile.lock', 'sandbox/bin/build-wasm', 'sandbox/ext/**') }}
|
|
55
|
+
restore-keys: wasm-${{ runner.os }}-
|
|
56
|
+
|
|
57
|
+
- run: bundle install --jobs 4
|
|
58
|
+
- run: npm ci
|
|
59
|
+
|
|
60
|
+
- name: Build ruby.wasm
|
|
61
|
+
run: ./bin/build-wasm
|
|
62
|
+
|
|
63
|
+
- name: Assemble the Ruby files served to the page
|
|
64
|
+
run: ./bin/prepare-rb
|
|
65
|
+
|
|
66
|
+
# Runs every example through the same WASI shim the page uses. The
|
|
67
|
+
# larger stack is needed to compile ActiveRecord's relation.rb; see the
|
|
68
|
+
# note in sandbox/README.md.
|
|
69
|
+
- name: Check the examples
|
|
70
|
+
run: node --stack-size=4000 check-examples.mjs
|
|
71
|
+
|
|
72
|
+
# ruby.wasm is 40 MB, which a Cloudflare Pages asset may not be and which
|
|
73
|
+
# is nearly all of the bandwidth this page uses, so it is served from R2
|
|
74
|
+
# -- egress there is free -- and only the page itself from Pages.
|
|
75
|
+
#
|
|
76
|
+
# The key carries a hash of the binary, so a rebuild lands on a new URL
|
|
77
|
+
# and nothing has to be purged. R2 serves what it is given and does not
|
|
78
|
+
# compress, hence gzip with the encoding recorded on the object: 40 MB
|
|
79
|
+
# becomes 12 MB on the wire.
|
|
80
|
+
#
|
|
81
|
+
# Without the secrets configured the upload is skipped and ruby.wasm
|
|
82
|
+
# rides along in the Pages artifact as before, which keeps forks working.
|
|
83
|
+
# Uploaded over R2's S3-compatible API rather than with wrangler, so that
|
|
84
|
+
# the token can be an Object Read & Write one scoped to this bucket.
|
|
85
|
+
# Those permissions exist only on the S3 API: wrangler goes through
|
|
86
|
+
# Cloudflare's REST API, which answers 403 to them and wants Admin Read &
|
|
87
|
+
# Write -- a token that can create and delete every bucket in the
|
|
88
|
+
# account, which is not what a public repository's CI should hold.
|
|
89
|
+
- name: Upload ruby.wasm to R2
|
|
90
|
+
id: r2
|
|
91
|
+
if: github.ref == 'refs/heads/master' && vars.R2_PUBLIC_URL != ''
|
|
92
|
+
env:
|
|
93
|
+
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
|
94
|
+
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
|
95
|
+
AWS_DEFAULT_REGION: auto
|
|
96
|
+
# R2 sends no checksum headers back, which newer AWS CLIs insist on.
|
|
97
|
+
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
|
|
98
|
+
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
|
|
99
|
+
run: |
|
|
100
|
+
gzip -9 -c ruby.wasm > /tmp/ruby.wasm.gz
|
|
101
|
+
|
|
102
|
+
# One key, overwritten each time, since only the newest build is of
|
|
103
|
+
# any use. That rules out caching it as immutable: the URL no longer
|
|
104
|
+
# changes with the contents, so a long max-age would go on serving
|
|
105
|
+
# the previous binary. Five minutes bounds how stale a visitor's
|
|
106
|
+
# copy can be just after a deploy, and revalidation costs a 304
|
|
107
|
+
# rather than 12 MB.
|
|
108
|
+
aws s3api put-object \
|
|
109
|
+
--endpoint-url "https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com" \
|
|
110
|
+
--bucket "${{ vars.R2_BUCKET }}" \
|
|
111
|
+
--key ruby-head.wasm \
|
|
112
|
+
--body /tmp/ruby.wasm.gz \
|
|
113
|
+
--content-type application/wasm \
|
|
114
|
+
--content-encoding gzip \
|
|
115
|
+
--cache-control "public, max-age=300" \
|
|
116
|
+
--no-cli-pager
|
|
117
|
+
|
|
118
|
+
echo '{"rubyWasmUrl":"${{ vars.R2_PUBLIC_URL }}/ruby-head.wasm"}' > config.json
|
|
119
|
+
echo "uploaded=true" >> "$GITHUB_OUTPUT"
|
|
120
|
+
|
|
121
|
+
- uses: actions/upload-artifact@v7
|
|
122
|
+
with:
|
|
123
|
+
name: sandbox
|
|
124
|
+
path: |
|
|
125
|
+
sandbox/index.html
|
|
126
|
+
sandbox/boot.rb
|
|
127
|
+
sandbox/examples.js
|
|
128
|
+
sandbox/manifest.json
|
|
129
|
+
sandbox/config.json
|
|
130
|
+
${{ steps.r2.outputs.uploaded != 'true' && 'sandbox/ruby.wasm' || '' }}
|
|
131
|
+
sandbox/rb
|
|
132
|
+
sandbox/assets
|
|
133
|
+
if-no-files-found: ignore
|
|
134
|
+
retention-days: 7
|
|
135
|
+
|
|
136
|
+
deploy:
|
|
137
|
+
if: github.ref == 'refs/heads/master'
|
|
138
|
+
needs: build
|
|
139
|
+
runs-on: ubuntu-latest
|
|
140
|
+
permissions:
|
|
141
|
+
pages: write
|
|
142
|
+
id-token: write
|
|
143
|
+
environment:
|
|
144
|
+
name: github-pages
|
|
145
|
+
url: ${{ steps.deploy.outputs.page_url }}
|
|
146
|
+
|
|
147
|
+
steps:
|
|
148
|
+
- uses: actions/download-artifact@v8
|
|
149
|
+
with:
|
|
150
|
+
name: sandbox
|
|
151
|
+
path: site
|
|
152
|
+
|
|
153
|
+
- uses: actions/configure-pages@v6
|
|
154
|
+
- uses: actions/upload-pages-artifact@v5
|
|
155
|
+
with:
|
|
156
|
+
path: site
|
|
157
|
+
- id: deploy
|
|
158
|
+
uses: actions/deploy-pages@v5
|
data/README.md
CHANGED
|
@@ -14,6 +14,10 @@ Author.
|
|
|
14
14
|
# WHERE "authors"."age" BETWEEN 20 AND 40 AND "posts"."published" = TRUE
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
**[Try it in your browser](https://shugo.github.io/activerecord-refined/)** —
|
|
18
|
+
Ruby 4.1, ActiveRecord and SQLite run in the page, so the examples build real
|
|
19
|
+
SQL and return real rows without a `ruby-master` build of your own.
|
|
20
|
+
|
|
17
21
|
## History
|
|
18
22
|
|
|
19
23
|
This gem was formerly known as **activerecord-refinements**, created by Akira Matsuda
|
|
@@ -41,6 +45,10 @@ works again without monkey-patching `Symbol` globally.
|
|
|
41
45
|
* Ruby 4.1 or later (for `Proc#refined`; not released yet, so a `ruby-master` build is needed for now)
|
|
42
46
|
* ActiveRecord 7.0 or later
|
|
43
47
|
|
|
48
|
+
The [sandbox](https://shugo.github.io/activerecord-refined/) is there to skip
|
|
49
|
+
that build: it carries its own Ruby 4.1. `sandbox/` in this repository is what
|
|
50
|
+
it is made of.
|
|
51
|
+
|
|
44
52
|
## Installation
|
|
45
53
|
|
|
46
54
|
Add this line to your application's Gemfile:
|
|
@@ -79,6 +87,24 @@ Author.where { :country.in?(%w[JP US]) } # IN
|
|
|
79
87
|
Author.where { :country.null? } # IS NULL
|
|
80
88
|
```
|
|
81
89
|
|
|
90
|
+
`!` negates any of these. Where SQL has a negative of its own, so does the
|
|
91
|
+
block, which is the same rows written the way they would be written by hand:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
Author.where { :country.not_null? } # IS NOT NULL
|
|
95
|
+
Author.where { :country.not_in?(%w[JP US]) } # NOT IN
|
|
96
|
+
Author.where { :age.not_between?(20, 40) } # not between 20 and 40
|
|
97
|
+
Author.where { :name.not_like?('A%') } # NOT LIKE
|
|
98
|
+
Author.where { :name.not_ilike?('a%') } # NOT ILIKE / NOT LIKE
|
|
99
|
+
|
|
100
|
+
Author.where { !:name.start_with?('A') } # NOT (name LIKE 'A%')
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Nothing turns on the choice: `NOT (country IS NULL)` and `country IS NOT NULL`
|
|
104
|
+
select the same rows, NULLs included. `not_between?` is the one whose SQL
|
|
105
|
+
looks unlike its name — Arel writes it as the two comparisons, `age < 20 OR
|
|
106
|
+
age > 40`, which is again the same rows.
|
|
107
|
+
|
|
82
108
|
`in?` also takes a relation as a subquery. Without an explicit select list the
|
|
83
109
|
subquery selects the relation's primary key, the same way ActiveRecord's own
|
|
84
110
|
`where(id: relation)` does:
|
|
@@ -118,7 +144,7 @@ collation, so it means the same thing everywhere:
|
|
|
118
144
|
|
|
119
145
|
```ruby
|
|
120
146
|
Author.where { :name.ilike?('ma%') } # ILIKE 'ma%' / LIKE 'ma%'
|
|
121
|
-
Author.where { :name.casecmp?('
|
|
147
|
+
Author.where { :name.casecmp?('Alice') } # LOWER(name) = LOWER('Alice')
|
|
122
148
|
```
|
|
123
149
|
|
|
124
150
|
`not_distinct_from?` and `distinct_from?` compare with NULL treated as a
|
|
@@ -239,23 +265,53 @@ ActiveRecord's `with` and `with_recursive` need nothing from this gem: a CTE
|
|
|
239
265
|
is joined by name like any other table, so its `ON` clause is a block, where
|
|
240
266
|
Rails' own documentation reaches for a string join.
|
|
241
267
|
|
|
242
|
-
`
|
|
243
|
-
|
|
268
|
+
`from_cte` takes the CTE's name and selects it under the model's own table
|
|
269
|
+
name, so the model's columns resolve:
|
|
244
270
|
|
|
245
271
|
```ruby
|
|
246
272
|
Node.with_recursive(
|
|
247
273
|
tree: [
|
|
248
|
-
Node.where { :id == root.id }
|
|
249
|
-
|
|
274
|
+
Node.where { :id == root.id }.
|
|
275
|
+
select { [:id, :name, :parent_id, 0.as(:depth)] },
|
|
276
|
+
Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
|
|
277
|
+
select { [:nodes[:id], :nodes[:name], :nodes[:parent_id],
|
|
278
|
+
(:tree[:depth] + 1).as(:depth)] },
|
|
250
279
|
]
|
|
251
|
-
).
|
|
280
|
+
).from_cte(:tree)
|
|
252
281
|
# WITH RECURSIVE "tree" AS (
|
|
253
|
-
# SELECT "nodes"
|
|
282
|
+
# SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id", 0 AS depth
|
|
283
|
+
# FROM "nodes" WHERE "nodes"."id" = 1
|
|
254
284
|
# UNION ALL
|
|
255
|
-
# SELECT "nodes"
|
|
285
|
+
# SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id",
|
|
286
|
+
# ("tree"."depth" + 1) AS depth
|
|
287
|
+
# FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
|
|
256
288
|
# ) SELECT "nodes".* FROM "tree" AS "nodes"
|
|
257
289
|
```
|
|
258
290
|
|
|
291
|
+
The anchor starts the count and the recursive member adds one, which is how
|
|
292
|
+
the shape of a tree comes out of a flat table. The `0` is a value rather than
|
|
293
|
+
SQL — see [`value`](#aggregates-functions-and-aliases) below for why a number
|
|
294
|
+
can say `.as` directly.
|
|
295
|
+
|
|
296
|
+
The alias on the last line is there for ActiveRecord's sake, not SQL's:
|
|
297
|
+
written by hand that line would be `SELECT * FROM tree`. ActiveRecord goes on qualifying
|
|
298
|
+
columns with the model's table name, so without the alias that name is not in
|
|
299
|
+
the query and anything qualifying a column fails:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
Node.with_recursive(tree: [...]).from(:tree).where(name: 'root')
|
|
303
|
+
# PG::UndefinedTable: missing FROM-clause entry for table "nodes"
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Since the model's name is the only one that works, `from_cte` takes it from
|
|
307
|
+
the model rather than asking. `from(:tree, as: :nodes)` is the same thing
|
|
308
|
+
spelled out, and is what to reach for when the name wanted is not the model's.
|
|
309
|
+
|
|
310
|
+
What makes this worth spelling out is how selectively it breaks. `count`,
|
|
311
|
+
`order` and `select` never qualify, so they work without the alias on every
|
|
312
|
+
adapter; it is `where` and `find_by` that stop. A query can therefore look
|
|
313
|
+
right until the day a condition is added to it.
|
|
314
|
+
|
|
259
315
|
A non-recursive CTE joins the same way:
|
|
260
316
|
|
|
261
317
|
```ruby
|
|
@@ -277,18 +333,54 @@ so a misspelling is a `NoMethodError` where you wrote it, and a name Ruby also
|
|
|
277
333
|
answers to — `rand` — means the SQL one inside a block:
|
|
278
334
|
|
|
279
335
|
```
|
|
280
|
-
abs ceil char_length coalesce concat
|
|
281
|
-
|
|
282
|
-
|
|
336
|
+
abs acos asin atan atan2 cast ceil char_length coalesce concat
|
|
337
|
+
cos current_date current_time current_timestamp date_trunc degrees
|
|
338
|
+
exp extract floor format greatest least length ln localtime
|
|
339
|
+
localtimestamp log log10 log2 lower ltrim mod now nullif pi
|
|
340
|
+
power radians rand replace round rtrim sign sin sqrt substr tan
|
|
341
|
+
trim trunc upper
|
|
283
342
|
```
|
|
284
343
|
|
|
285
344
|
Most are spelled the same everywhere. Where they are not, the method names one
|
|
286
345
|
meaning and each adapter gets its own spelling: `char_length`, `greatest` and
|
|
287
346
|
`least` become `LENGTH`, `MAX` and `MIN` on SQLite, and `rand` is `RAND` on
|
|
288
|
-
MySQL and `RANDOM` elsewhere
|
|
289
|
-
|
|
347
|
+
MySQL and `RANDOM` elsewhere, and `trunc` is `TRUNCATE` on MySQL, which
|
|
348
|
+
insists on the second argument the others default to zero — SQLite's takes
|
|
349
|
+
only the one. Where an adapter has no equivalent — `date_trunc` outside
|
|
350
|
+
PostgreSQL, `now` and the `local*` pair on SQLite, `log2` on PostgreSQL,
|
|
351
|
+
whose spelling is `log(2, x)` — the block raises `NotImplementedError`
|
|
290
352
|
rather than leaving the database to reject the SQL.
|
|
291
353
|
|
|
354
|
+
`current_date`, `current_time`, `current_timestamp`, `localtime` and
|
|
355
|
+
`localtimestamp` come out without parentheses, as the grammar has them —
|
|
356
|
+
written as calls, PostgreSQL and SQLite would reject them. What does go into
|
|
357
|
+
parentheses is an optional precision — `current_timestamp(3)` — which
|
|
358
|
+
`current_date` never takes and SQLite never accepts. `current_timestamp` is
|
|
359
|
+
the portable spelling of what `now` means, and reaches SQLite where `now`
|
|
360
|
+
does not:
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
Post.where { :published_at <= current_timestamp }
|
|
364
|
+
# SELECT "posts".* FROM "posts" WHERE "posts"."published_at" <= CURRENT_TIMESTAMP
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`extract` and `cast` are grammar as well: the field and the type go where no
|
|
368
|
+
value could. The field has to be a plain name, and the type has to look like
|
|
369
|
+
a type — a plain name, at most parenthesized with lengths, so the adapters'
|
|
370
|
+
own spellings like `double precision` or `decimal(10,2)` pass; anything else
|
|
371
|
+
raises `ArgumentError`. The type is the adapter's own name for the type, and
|
|
372
|
+
whether it exists is the database's to say. SQLite spells everything
|
|
373
|
+
`extract` does as `strftime` formats, which no renaming carries, so `extract`
|
|
374
|
+
raises there:
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
Post.where { extract(:year, :created_at) == 2026 }
|
|
378
|
+
# SELECT "posts".* FROM "posts" WHERE EXTRACT(YEAR FROM "posts"."created_at") = 2026
|
|
379
|
+
|
|
380
|
+
Post.select { cast(:price, 'decimal(10,2)').as(:price) }
|
|
381
|
+
# SELECT CAST("posts"."price" AS decimal(10,2)) AS price
|
|
382
|
+
```
|
|
383
|
+
|
|
292
384
|
`format` is printf formatting, and raises on MySQL, where a function of the
|
|
293
385
|
same name does something else entirely: it puts separators in a number, and
|
|
294
386
|
reads a printf template as the number zero rather than complaining. `fn` still
|
|
@@ -314,6 +406,29 @@ written into the SQL as given — so those two have to be plain names,
|
|
|
314
406
|
optionally qualified by a schema in `fn`'s case. Anything else raises
|
|
315
407
|
`ArgumentError` rather than reaching the query.
|
|
316
408
|
|
|
409
|
+
One place asks for a value to be said out loud: the top of a select list.
|
|
410
|
+
Everywhere else a bare literal is already a value — `where { :age > 18 }`,
|
|
411
|
+
`concat(:name, '-x')` — but ActiveRecord reads a string in `select` as SQL,
|
|
412
|
+
so `value` is how you ask for the other meaning. It carries the predications
|
|
413
|
+
and arithmetic with it, so a literal can be compared and combined like
|
|
414
|
+
anything else. Numbers have a shorthand, since nothing else could be meant by
|
|
415
|
+
one:
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
Node.select { [:id, value(0).as(:depth)] }
|
|
419
|
+
# SELECT "nodes"."id", 0 AS depth FROM "nodes"
|
|
420
|
+
|
|
421
|
+
Node.select { [:id, 0.as(:depth)] } # the same thing
|
|
422
|
+
|
|
423
|
+
Post.select { [:title, value('draft').as(:state)] }
|
|
424
|
+
# SELECT "posts"."title", 'draft' AS state FROM "posts"
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The shorthand is `Integer` and `Float` only. `String` keeps its two meanings —
|
|
428
|
+
SQL in a select list, a value everywhere else — and refining it would make the
|
|
429
|
+
same literal mean one thing or the other depending on whether it had been sent
|
|
430
|
+
a message.
|
|
431
|
+
|
|
317
432
|
`fn` reaches functions without a method of their own. Its name is emitted as
|
|
318
433
|
written, so a case-sensitive one can be spelled exactly:
|
|
319
434
|
|
|
@@ -12,7 +12,10 @@ Gem::Specification.new do |gem|
|
|
|
12
12
|
gem.summary = 'ActiveRecord + Ruby 4.1 Proc#refined'
|
|
13
13
|
gem.homepage = 'https://github.com/shugo/activerecord-refined'
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# sandbox/ is a site, not part of the library: its Gemfile.lock and
|
|
16
|
+
# package-lock.json have no business in anyone's bundle. CLAUDE.md is
|
|
17
|
+
# addressed to whoever is working on the repository, not to anyone using it.
|
|
18
|
+
gem.files = `git ls-files`.split($/).grep_v(%r{^sandbox/|^CLAUDE\.md$})
|
|
16
19
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
|
17
20
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
|
18
21
|
gem.require_paths = ["lib"]
|
data/benchmark/query_building.rb
CHANGED
|
@@ -28,10 +28,10 @@ T = User.arel_table
|
|
|
28
28
|
# Each variant must generate equivalent SQL; sanity-print once.
|
|
29
29
|
VARIANTS = {
|
|
30
30
|
"simple equality" => {
|
|
31
|
-
"hash" => -> { User.where(name: "
|
|
32
|
-
"string" => -> { User.where("name = ?", "
|
|
33
|
-
"arel" => -> { User.where(T[:name].eq("
|
|
34
|
-
"block" => -> { User.where { :name == "
|
|
31
|
+
"hash" => -> { User.where(name: "alice").to_sql },
|
|
32
|
+
"string" => -> { User.where("name = ?", "alice").to_sql },
|
|
33
|
+
"arel" => -> { User.where(T[:name].eq("alice")).to_sql },
|
|
34
|
+
"block" => -> { User.where { :name == "alice" }.to_sql },
|
|
35
35
|
},
|
|
36
36
|
"range (BETWEEN)" => {
|
|
37
37
|
"hash" => -> { User.where(age: 20..40).to_sql },
|
|
@@ -41,13 +41,13 @@ VARIANTS = {
|
|
|
41
41
|
"LIKE" => {
|
|
42
42
|
"string" => -> { User.where("name LIKE ?", "ma%").to_sql },
|
|
43
43
|
"arel" => -> { User.where(T[:name].matches("ma%", nil, true)).to_sql },
|
|
44
|
-
"block" => -> { User.where { :name.like?("
|
|
44
|
+
"block" => -> { User.where { :name.like?("al%") }.to_sql },
|
|
45
45
|
},
|
|
46
46
|
"compound AND/OR" => {
|
|
47
|
-
"string" => -> { User.where("age >= ? AND (name = ? OR name = ?)", 18, "
|
|
48
|
-
"arel" => -> { User.where(T[:age].gteq(18).and(T[:name].eq("
|
|
49
|
-
"relation" => -> { User.where(age: 18..).and(User.where(name: "
|
|
50
|
-
"block" => -> { User.where { (:age >= 18) & ((:name == "
|
|
47
|
+
"string" => -> { User.where("age >= ? AND (name = ? OR name = ?)", 18, "alice", "bob").to_sql },
|
|
48
|
+
"arel" => -> { User.where(T[:age].gteq(18).and(T[:name].eq("alice").or(T[:name].eq("bob")))).to_sql },
|
|
49
|
+
"relation" => -> { User.where(age: 18..).and(User.where(name: "alice").or(User.where(name: "bob"))).to_sql },
|
|
50
|
+
"block" => -> { User.where { (:age >= 18) & ((:name == "alice") | (:name == "bob")) }.to_sql },
|
|
51
51
|
},
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -97,8 +97,8 @@ context = ActiveRecord::Refined::BlockContext.new
|
|
|
97
97
|
syntax = ActiveRecord::Refined::BlockSyntax
|
|
98
98
|
|
|
99
99
|
{
|
|
100
|
-
"simple equality" => proc { :name == "
|
|
101
|
-
"compound AND/OR" => proc { (:age >= 18) & ((:name == "
|
|
100
|
+
"simple equality" => proc { :name == "alice" },
|
|
101
|
+
"compound AND/OR" => proc { (:age >= 18) & ((:name == "alice") | (:name == "bob")) },
|
|
102
102
|
}.each do |label, blk|
|
|
103
103
|
refined = blk.refined(syntax)
|
|
104
104
|
context.instance_exec(&refined) # the copy is made here, on the first call
|
|
@@ -115,7 +115,7 @@ puts "1000 more calls from the same call site copied #{iseq_count.call - before}
|
|
|
115
115
|
|
|
116
116
|
puts
|
|
117
117
|
puts "=== where the block path spends its time ==="
|
|
118
|
-
block = proc { :name == "
|
|
118
|
+
block = proc { :name == "alice" }
|
|
119
119
|
refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
|
|
120
120
|
context = ActiveRecord::Refined::BlockContext.new
|
|
121
121
|
Benchmark.ips do |x|
|
data/examples/ctes.rb
CHANGED
|
@@ -31,23 +31,54 @@ Product.create!(name: 'apple', category_id: groceries.id, price: 2)
|
|
|
31
31
|
|
|
32
32
|
# 1. Recursive CTE: every category below 'electronics', itself included.
|
|
33
33
|
# The recursive member joins the CTE by name, so its ON clause is a block
|
|
34
|
-
# rather than the string join Rails' own documentation reaches for.
|
|
35
|
-
# then selects the CTE under the model's table name
|
|
36
|
-
#
|
|
34
|
+
# rather than the string join Rails' own documentation reaches for.
|
|
35
|
+
# `from_cte` then selects the CTE under the model's table name.
|
|
36
|
+
#
|
|
37
|
+
# That alias is ActiveRecord's requirement rather than SQL's: by hand the
|
|
38
|
+
# last line would be `SELECT * FROM tree`. ActiveRecord keeps qualifying
|
|
39
|
+
# columns with the model's table name, so without it `where` and `find_by`
|
|
40
|
+
# look for a table the query does not have. `count`, `order` and `select`
|
|
41
|
+
# never qualify and would work either way, which makes it easy to miss.
|
|
37
42
|
subtree =
|
|
38
43
|
Category.with_recursive(
|
|
39
44
|
tree: [
|
|
40
45
|
Category.where { :id == electronics.id },
|
|
41
46
|
Category.joins(:tree) { :categories[:parent_id] == :tree[:id] },
|
|
42
47
|
]
|
|
43
|
-
).
|
|
48
|
+
).from_cte(:tree)
|
|
44
49
|
|
|
45
50
|
puts '--- 1. Recursive CTE walking a category tree ---'
|
|
46
51
|
puts subtree.to_sql
|
|
47
52
|
puts subtree.order { :name }.pluck(:name).inspect
|
|
48
53
|
puts
|
|
49
54
|
|
|
50
|
-
# 2.
|
|
55
|
+
# 2. One walk over every tree, carrying down where each row started and how
|
|
56
|
+
# far it has come. Which tree is wanted is then an ordinary `where`, asked
|
|
57
|
+
# afterwards, so the CTE is not rebuilt for each root. 0 is a value rather
|
|
58
|
+
# than SQL: at the top of a select list a bare string would be SQL, so
|
|
59
|
+
# numbers say `.as` directly and anything else says `value(...).as`.
|
|
60
|
+
forest =
|
|
61
|
+
Category.with_recursive(
|
|
62
|
+
tree: [
|
|
63
|
+
Category.where { :parent_id.null? }.
|
|
64
|
+
select { [:id, :name, :parent_id, :id.as(:root_id), 0.as(:depth)] },
|
|
65
|
+
Category.joins(:tree) { :categories[:parent_id] == :tree[:id] }.
|
|
66
|
+
select { [:categories[:id], :categories[:name], :categories[:parent_id],
|
|
67
|
+
:tree[:root_id], (:tree[:depth] + 1).as(:depth)] },
|
|
68
|
+
]
|
|
69
|
+
).from_cte(:tree).order { [:depth, :id] }
|
|
70
|
+
|
|
71
|
+
puts '--- 2. Recursive CTE carrying the root and the depth down ---'
|
|
72
|
+
puts forest.to_sql
|
|
73
|
+
puts forest.map {|c| [c.name, c.root_id, c.depth] }.inspect
|
|
74
|
+
|
|
75
|
+
# The alias from_cte puts on the CTE is what lets this `where` qualify
|
|
76
|
+
# root_id; without it the column would be looked for in a table the query no
|
|
77
|
+
# longer has.
|
|
78
|
+
puts forest.where { :root_id == electronics.id }.map {|c| [c.name, c.depth] }.inspect
|
|
79
|
+
puts
|
|
80
|
+
|
|
81
|
+
# 3. The same CTE as a subquery: products anywhere under 'electronics'.
|
|
51
82
|
# The outer query joins the CTE by name like any other table.
|
|
52
83
|
products_below =
|
|
53
84
|
Product.with_recursive(
|
|
@@ -57,12 +88,12 @@ products_below =
|
|
|
57
88
|
]
|
|
58
89
|
).joins(:tree) { :tree[:id] == :products[:category_id] }
|
|
59
90
|
|
|
60
|
-
puts '---
|
|
91
|
+
puts '--- 3. Recursive CTE joined from the outer query ---'
|
|
61
92
|
puts products_below.to_sql
|
|
62
93
|
puts products_below.order { :name }.pluck(:name).inspect
|
|
63
94
|
puts
|
|
64
95
|
|
|
65
|
-
#
|
|
96
|
+
# 4. A plain CTE, named once and used twice: categories that hold something
|
|
66
97
|
# expensive, and the count of products in each.
|
|
67
98
|
expensive =
|
|
68
99
|
Category.with(pricey: Product.where { :price >= 100 }).
|
|
@@ -76,7 +107,7 @@ expensive =
|
|
|
76
107
|
]
|
|
77
108
|
}
|
|
78
109
|
|
|
79
|
-
puts '---
|
|
110
|
+
puts '--- 4. Plain CTE joined and aggregated ---'
|
|
80
111
|
puts expensive.to_sql
|
|
81
112
|
puts expensive.map {|c| [c.category, c.pricey_count, c.top_price] }.inspect
|
|
82
113
|
puts
|
data/examples/predicates.rb
CHANGED
|
@@ -16,11 +16,11 @@ Setup.new.up
|
|
|
16
16
|
class Account < ActiveRecord::Base
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
Account.create!(login: '
|
|
20
|
-
Account.create!(login: '
|
|
21
|
-
Account.create!(login: '
|
|
22
|
-
Account.create!(login: '100%_pure',
|
|
23
|
-
Account.create!(login: '1002000',
|
|
19
|
+
Account.create!(login: 'alice', country: 'JP', age: 60)
|
|
20
|
+
Account.create!(login: 'bob', country: 'JP', age: 50)
|
|
21
|
+
Account.create!(login: 'carol', country: 'US', age: 45)
|
|
22
|
+
Account.create!(login: '100%_pure', country: nil, age: 30)
|
|
23
|
+
Account.create!(login: '1002000', country: 'US', age: 25)
|
|
24
24
|
|
|
25
25
|
def show(title, relation, rows)
|
|
26
26
|
puts "--- #{title} ---"
|
|
@@ -63,12 +63,12 @@ show('distinct_from? keeps NULLs, != drops them',
|
|
|
63
63
|
# literals, so % and _ in them are escaped rather than matched as
|
|
64
64
|
# wildcards. Note the last row matches only the literal-minded one.
|
|
65
65
|
show('like? takes a pattern',
|
|
66
|
-
Account.where { :login.like?('%
|
|
67
|
-
Account.where { :login.like?('%
|
|
66
|
+
Account.where { :login.like?('%rol') },
|
|
67
|
+
Account.where { :login.like?('%rol') }.pluck(:login))
|
|
68
68
|
|
|
69
69
|
show('start_with? takes any number of literals, like String#start_with?',
|
|
70
|
-
Account.where { :login.start_with?('
|
|
71
|
-
Account.where { :login.start_with?('
|
|
70
|
+
Account.where { :login.start_with?('al', 'bo') },
|
|
71
|
+
Account.where { :login.start_with?('al', 'bo') }.pluck(:login))
|
|
72
72
|
|
|
73
73
|
# The % in the argument is escaped, so only the account whose login really
|
|
74
74
|
# contains "100%" matches; the same pattern spelled with like? treats it as a
|
|
@@ -83,8 +83,8 @@ show('include? escapes wildcards; like? does not',
|
|
|
83
83
|
# casecmp? folds both sides rather than trusting the collation, so it means
|
|
84
84
|
# the same thing on every adapter.
|
|
85
85
|
show('casecmp? is case-insensitive equality',
|
|
86
|
-
Account.where { :login.casecmp?('
|
|
87
|
-
Account.where { :login.casecmp?('
|
|
86
|
+
Account.where { :login.casecmp?('AlIcE') },
|
|
87
|
+
Account.where { :login.casecmp?('AlIcE') }.pluck(:login))
|
|
88
88
|
|
|
89
89
|
# 4. Combining. & | ! build the tree; Ruby's precedence puts & and | above
|
|
90
90
|
# the comparison operators, hence the parentheses around each comparison.
|
data/examples/subqueries.rb
CHANGED
|
@@ -24,13 +24,13 @@ class Post < ActiveRecord::Base
|
|
|
24
24
|
def self.published = where { :published == true }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
alice = Author.create!(name: 'alice')
|
|
28
|
+
bob = Author.create!(name: 'bob')
|
|
29
29
|
quiet = Author.create!(name: 'quiet')
|
|
30
30
|
|
|
31
|
-
Post.create!(title: 'refinements', author_id:
|
|
32
|
-
Post.create!(title: 'parser', author_id:
|
|
33
|
-
Post.create!(title: 'draft', author_id:
|
|
31
|
+
Post.create!(title: 'refinements', author_id: alice.id, likes: 100, published: true)
|
|
32
|
+
Post.create!(title: 'parser', author_id: alice.id, likes: 40, published: true)
|
|
33
|
+
Post.create!(title: 'draft', author_id: bob.id, likes: 5, published: false)
|
|
34
34
|
|
|
35
35
|
def show(title, relation)
|
|
36
36
|
puts "--- #{title} ---"
|