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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f20876b4eb0b36ece84fc2f6a1c828461846b29bd829e279d31b22735cbf0c15
4
- data.tar.gz: 4a717ffaf4991fa3a113ea4a6a619b16d431e8308e1869e22c988885022d450a
3
+ metadata.gz: b3818aa9d45162578a7a0f01eae7b45f943e2a2b1708ec689cd7ba484af9011e
4
+ data.tar.gz: 27674049c82ce8ba096f6a9416b603b25c97d326cb3ea78e557b43ffe76b25a0
5
5
  SHA512:
6
- metadata.gz: 56734a22e7667270892abf1d6ab9fb0816a37043c5f1fe61a7367c40403f782af6dff7875b0f520ada5aef6ce89155f39919414a71d3abec279ccf3c538db578
7
- data.tar.gz: f0aa350bbf6b80d1e36b156d2e997930195f2fcfe99c53ae449f3d0f44efe3870671c45bae52a7eac5f39c5f39adf129cbe5a1a1d3776b07eb8d29b1cfa470d2
6
+ metadata.gz: 6a3d6b39152e68a21a21a14abd1092484eb335137a182db8068396ef7fa98ca17bd4bb152b0b028bc3ef2e1de17e8d257dcdb03a57cf093a650f387196e22ee9
7
+ data.tar.gz: 5e44084905b8c8d22bdde49aad72ceab1b22b1be4e139e8f60237a134a05c258413503595af0f49409ac19923ed296ecbd3f7c075c64b20e6e61f8c2100e5dd3
@@ -7,10 +7,10 @@ on:
7
7
  pull_request:
8
8
 
9
9
  jobs:
10
- test:
10
+ sqlite:
11
11
  runs-on: ubuntu-latest
12
12
  env:
13
- # SQLite only here, so skip building the pg and mysql2 extensions.
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
+ [![gem](https://img.shields.io/gem/v/activerecord-refined.svg)](https://rubygems.org/gems/activerecord-refined)
3
4
  [![test](https://github.com/shugo/activerecord-refined/actions/workflows/test.yml/badge.svg)](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?` tests containment in a PostgreSQL array column. The two flavors of
124
- "does it contain this?" split by name the way Ruby's own classes do: `include?`
125
- is String's substring match, `member?` is Enumerable's element test, which
126
- String does not have. Pass an array to require every element:
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') } # "tags" @> '{ruby}'
130
- Article.where { :tags.member?(%w[ruby rails]) } # "tags" @> '{ruby,rails}'
131
- Article.where { :scores.member?(80) } # "scores" @> '{80}'
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
- For the same reason `== nil` raises `ArgumentError`: `= NULL` is never true in
159
- SQL, so a NULL test has to be spelled as one. Use `null?`:
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 scalar
192
- functions `upper`, `lower`, `length`, `trim`, `coalesce`, `abs` and `round`. Use `.as`
193
- for a column alias, and `.asc` / `.desc` for the sort direction. Return an array to
194
- select or order by multiple expressions.
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
- Pass `:*` to `count` for `COUNT(*)`:
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
- See `examples/` for complete, runnable scripts.
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
@@ -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