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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3818aa9d45162578a7a0f01eae7b45f943e2a2b1708ec689cd7ba484af9011e
4
- data.tar.gz: 27674049c82ce8ba096f6a9416b603b25c97d326cb3ea78e557b43ffe76b25a0
3
+ metadata.gz: 98f198f59d3bb4b6f4c51a16940d4e7ee44b890aa7e033a327a650b73cb3dfbd
4
+ data.tar.gz: 5fb7cdd8964b82d6bb14ffdfecf1400ea875f1c5bc12d7fa4645c431caf47d19
5
5
  SHA512:
6
- metadata.gz: 6a3d6b39152e68a21a21a14abd1092484eb335137a182db8068396ef7fa98ca17bd4bb152b0b028bc3ef2e1de17e8d257dcdb03a57cf093a650f387196e22ee9
7
- data.tar.gz: 5e44084905b8c8d22bdde49aad72ceab1b22b1be4e139e8f60237a134a05c258413503595af0f49409ac19923ed296ecbd3f7c075c64b20e6e61f8c2100e5dd3
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?('Matz') } # LOWER(name) = LOWER('Matz')
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
- `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:
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
- Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] },
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
- ).from(:tree, as: :nodes)
280
+ ).from_cte(:tree)
252
281
  # WITH RECURSIVE "tree" AS (
253
- # SELECT "nodes".* FROM "nodes" WHERE "nodes"."id" = 1
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".* FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
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 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
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. Where an adapter has no equivalent — `date_trunc`
289
- outside PostgreSQL, `now` on SQLite the block raises `NotImplementedError`
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
- gem.files = `git ls-files`.split($/)
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"]
@@ -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: "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 },
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?("ma%") }.to_sql },
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, "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 },
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 == "matz" },
101
- "compound AND/OR" => proc { (:age >= 18) & ((:name == "matz") | (:name == "nobu")) },
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 == "matz" }
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. `from`
35
- # then selects the CTE under the model's table name, which is what lets
36
- # Category's own columns resolve against it.
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
- ).from(:tree, as: :categories)
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. The same CTE as a subquery: products anywhere under 'electronics'.
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 '--- 2. Recursive CTE joined from the outer query ---'
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
- # 3. A plain CTE, named once and used twice: categories that hold something
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 '--- 3. Plain CTE joined and aggregated ---'
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
@@ -16,11 +16,11 @@ Setup.new.up
16
16
  class Account < ActiveRecord::Base
17
17
  end
18
18
 
19
- Account.create!(login: 'matz', country: 'JP', age: 60)
20
- Account.create!(login: 'nobu', country: 'JP', age: 50)
21
- Account.create!(login: 'tenderlove', country: 'US', age: 45)
22
- Account.create!(login: '100%_pure', country: nil, age: 30)
23
- Account.create!(login: '1002000', country: 'US', age: 25)
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?('%love') },
67
- Account.where { :login.like?('%love') }.pluck(:login))
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?('ma', 'no') },
71
- Account.where { :login.start_with?('ma', 'no') }.pluck(:login))
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?('MaTz') },
87
- Account.where { :login.casecmp?('MaTz') }.pluck(:login))
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.
@@ -24,13 +24,13 @@ class Post < ActiveRecord::Base
24
24
  def self.published = where { :published == true }
25
25
  end
26
26
 
27
- matz = Author.create!(name: 'matz')
28
- nobu = Author.create!(name: 'nobu')
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: matz.id, likes: 100, published: true)
32
- Post.create!(title: 'parser', author_id: matz.id, likes: 40, published: true)
33
- Post.create!(title: 'draft', author_id: nobu.id, likes: 5, published: false)
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} ---"