activerecord-refined 0.3.3 → 0.5.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: ba52b9c3529288eeedb0f5ca06a28284669f5a89027d2e372155108f0447ecc1
4
+ data.tar.gz: bb6d96021512a8891577781aa8e2abb14c2dc7cb8716423270ce0ccd074ed8c9
5
5
  SHA512:
6
- metadata.gz: 56734a22e7667270892abf1d6ab9fb0816a37043c5f1fe61a7367c40403f782af6dff7875b0f520ada5aef6ce89155f39919414a71d3abec279ccf3c538db578
7
- data.tar.gz: f0aa350bbf6b80d1e36b156d2e997930195f2fcfe99c53ae449f3d0f44efe3870671c45bae52a7eac5f39c5f39adf129cbe5a1a1d3776b07eb8d29b1cfa470d2
6
+ metadata.gz: db9e3ca29892b86f19af39e59e7a52ff987b187df8cb03249ab3456f5bc16ce7d6570b7309f77f28d9600e9aeb21f3c6874b27a63e86d0efeed13bc567f07d9a
7
+ data.tar.gz: 3d65a461bab4a0c204702be0f532769c6fe7fafaa8106ea4e33994355b3fd3049ed2c5195f53460e9a9749007b5a133c7405ffdcd61485f3d07824e3fa686f9b
@@ -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/vendor/browser.umd.js
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
@@ -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.
@@ -13,6 +14,10 @@ Author.
13
14
  # WHERE "authors"."age" BETWEEN 20 AND 40 AND "posts"."published" = TRUE
14
15
  ```
15
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
+
16
21
  ## History
17
22
 
18
23
  This gem was formerly known as **activerecord-refinements**, created by Akira Matsuda
@@ -40,6 +45,10 @@ works again without monkey-patching `Symbol` globally.
40
45
  * Ruby 4.1 or later (for `Proc#refined`; not released yet, so a `ruby-master` build is needed for now)
41
46
  * ActiveRecord 7.0 or later
42
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
+
43
52
  ## Installation
44
53
 
45
54
  Add this line to your application's Gemfile:
@@ -87,6 +96,15 @@ Author.where { :id.in?(Post.published.select(:author_id)) }
87
96
  # "authors"."id" IN (SELECT "posts"."author_id" FROM "posts" WHERE ...)
88
97
  ```
89
98
 
99
+ A relation on the right of a comparison is a scalar subquery. It has to select
100
+ one value, so unlike `in?` there is no default select list and one is
101
+ required:
102
+
103
+ ```ruby
104
+ Author.where { :age >= Author.select { avg(:age) } }
105
+ # "authors"."age" >= (SELECT AVG("authors"."age") FROM "authors")
106
+ ```
107
+
90
108
  `exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
91
109
  subquery with the outer table through qualified columns — its `where` block
92
110
  goes through the DSL like any other:
@@ -100,7 +118,26 @@ Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
100
118
  ```
101
119
 
102
120
  `like?` is case-sensitive `LIKE` on every adapter, including PostgreSQL, where
103
- Arel would otherwise reach for `ILIKE`.
121
+ Arel would otherwise reach for `ILIKE`. `ilike?` is the one that asks for
122
+ `ILIKE`; off PostgreSQL it is plain `LIKE`, which those adapters already match
123
+ case-insensitively under their default collations. `casecmp?` is
124
+ case-insensitive equality, folded on both sides rather than left to the
125
+ collation, so it means the same thing everywhere:
126
+
127
+ ```ruby
128
+ Author.where { :name.ilike?('ma%') } # ILIKE 'ma%' / LIKE 'ma%'
129
+ Author.where { :name.casecmp?('Alice') } # LOWER(name) = LOWER('Alice')
130
+ ```
131
+
132
+ `not_distinct_from?` and `distinct_from?` compare with NULL treated as a
133
+ value, rather than as the unknown that makes `=` and `<>` neither true nor
134
+ false. PostgreSQL spells this `IS [NOT] DISTINCT FROM`, SQLite `IS` / `IS NOT`
135
+ and MySQL `<=>`, and the rows that come back are the same on all three:
136
+
137
+ ```ruby
138
+ Author.where { :country.not_distinct_from?(params[:country]) } # matches NULL to nil
139
+ Author.where { :country.distinct_from?('JP') } # keeps the NULL rows
140
+ ```
104
141
 
105
142
  `start_with?`, `end_with?` and `include?` are shortcuts for the usual `like?`
106
143
  patterns. Unlike `like?`, they treat their argument as a literal string, so `%`
@@ -120,17 +157,24 @@ Author.where { :name.start_with?('A', 'B') }
120
157
  # (name LIKE 'A%' OR name LIKE 'B%')
121
158
  ```
122
159
 
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:
160
+ `member?`, `superset?`, `subset?` and `intersect?` compare against a
161
+ PostgreSQL array column, each carrying the meaning of its Ruby namesake:
162
+ `member?` is Enumerable's element test (which String does not have — that is
163
+ what separates it from `include?`), `superset?` and `subset?` are Set's
164
+ whole-array containment, and `intersect?` is Array's "any element in common":
127
165
 
128
166
  ```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}'
167
+ Article.where { :tags.member?('ruby') } # tags @> '{ruby}'
168
+ Article.where { :scores.member?(80) } # scores @> '{80}'
169
+ Article.where { :tags.superset?(%w[ruby rails]) } # tags @> '{ruby,rails}'
170
+ Article.where { :tags.subset?(%w[ruby rails go]) } # tags <@ '{ruby,rails,go}'
171
+ Article.where { :tags.intersect?(%w[ruby go]) } # tags && '{ruby,go}'
132
172
  ```
133
173
 
174
+ Like its namesake, `member?` takes one element — `[1, 2].member?([1])` is
175
+ false in Ruby, so an Array argument raises rather than quietly meaning
176
+ something `Array#member?` does not. Requiring every element is `superset?`.
177
+
134
178
  `=~` and `!~` match a regular expression: `REGEXP` and `NOT REGEXP` on MySQL,
135
179
  `~` and `!~` on PostgreSQL. SQLite has no regexp operator of its own, so it
136
180
  raises there.
@@ -155,8 +199,10 @@ Reservation.where { :period == (from...to) } # daterange = '[from,to)'
155
199
  Article.where { :tags == %w[ruby rails] } # text[] = '{ruby,rails}'
156
200
  ```
157
201
 
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?`:
202
+ `!=` is SQL `!=` under the same rules, value passed through untouched.
203
+
204
+ For the same reason `== nil` and `!= nil` raise `ArgumentError`: `= NULL` is
205
+ never true in SQL, so a NULL test has to be spelled as one. Use `null?`:
160
206
 
161
207
  ```ruby
162
208
  Author.where { :country.null? } # country IS NULL
@@ -186,18 +232,154 @@ Author.
186
232
  Author.left_outer_joins(:posts) { :posts[:author_id] == :authors[:id] }
187
233
  ```
188
234
 
235
+ `as` names the table within the query, which is what makes a self join
236
+ expressible — the qualified columns in the block go by that name:
237
+
238
+ ```ruby
239
+ Employee.joins(:employees, as: :managers) { :managers[:id] == :employees[:manager_id] }
240
+ # SELECT "employees".* FROM "employees"
241
+ # INNER JOIN "employees" "managers" ON "managers"."id" = "employees"."manager_id"
242
+ ```
243
+
244
+ ### Common table expressions
245
+
246
+ ActiveRecord's `with` and `with_recursive` need nothing from this gem: a CTE
247
+ is joined by name like any other table, so its `ON` clause is a block, where
248
+ Rails' own documentation reaches for a string join.
249
+
250
+ `from` takes the CTE's name as a symbol, with `as` to select it under the
251
+ model's own table name so the model's columns resolve:
252
+
253
+ ```ruby
254
+ Node.with_recursive(
255
+ tree: [
256
+ Node.where { :id == root.id },
257
+ Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] },
258
+ ]
259
+ ).from(:tree, as: :nodes)
260
+ # WITH RECURSIVE "tree" AS (
261
+ # SELECT "nodes".* FROM "nodes" WHERE "nodes"."id" = 1
262
+ # UNION ALL
263
+ # SELECT "nodes".* FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
264
+ # ) SELECT "nodes".* FROM "tree" AS "nodes"
265
+ ```
266
+
267
+ A non-recursive CTE joins the same way:
268
+
269
+ ```ruby
270
+ Node.with(roots: Node.where { :parent_id.null? }).
271
+ joins(:roots) { :roots[:id] == :nodes[:parent_id] }
272
+ ```
273
+
274
+ `examples/ctes.rb` walks a category tree with these.
275
+
189
276
  ### Aggregates, functions and aliases
190
277
 
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.
278
+ `count`, `sum`, `avg`, `min` and `max` are available as methods, as are the
279
+ scalar functions below, with `fn` for anything else. Use `.as` for a column
280
+ alias, and `.asc` / `.desc` for the sort direction. Return an array to select
281
+ or order by multiple expressions.
282
+
283
+ The scalar functions are real methods rather than anything caught dynamically,
284
+ so a misspelling is a `NoMethodError` where you wrote it, and a name Ruby also
285
+ answers to — `rand` — means the SQL one inside a block:
195
286
 
196
- Pass `:*` to `count` for `COUNT(*)`:
287
+ ```
288
+ abs acos asin atan atan2 cast ceil char_length coalesce concat
289
+ cos current_date current_time current_timestamp date_trunc degrees
290
+ exp extract floor format greatest least length ln localtime
291
+ localtimestamp log log10 log2 lower ltrim mod now nullif pi
292
+ power radians rand replace round rtrim sign sin sqrt substr tan
293
+ trim trunc upper
294
+ ```
295
+
296
+ Most are spelled the same everywhere. Where they are not, the method names one
297
+ meaning and each adapter gets its own spelling: `char_length`, `greatest` and
298
+ `least` become `LENGTH`, `MAX` and `MIN` on SQLite, and `rand` is `RAND` on
299
+ MySQL and `RANDOM` elsewhere, and `trunc` is `TRUNCATE` on MySQL, which
300
+ insists on the second argument the others default to zero — SQLite's takes
301
+ only the one. Where an adapter has no equivalent — `date_trunc` outside
302
+ PostgreSQL, `now` and the `local*` pair on SQLite, `log2` on PostgreSQL,
303
+ whose spelling is `log(2, x)` — the block raises `NotImplementedError`
304
+ rather than leaving the database to reject the SQL.
305
+
306
+ `current_date`, `current_time`, `current_timestamp`, `localtime` and
307
+ `localtimestamp` come out without parentheses, as the grammar has them —
308
+ written as calls, PostgreSQL and SQLite would reject them. What does go into
309
+ parentheses is an optional precision — `current_timestamp(3)` — which
310
+ `current_date` never takes and SQLite never accepts. `current_timestamp` is
311
+ the portable spelling of what `now` means, and reaches SQLite where `now`
312
+ does not:
313
+
314
+ ```ruby
315
+ Post.where { :published_at <= current_timestamp }
316
+ # SELECT "posts".* FROM "posts" WHERE "posts"."published_at" <= CURRENT_TIMESTAMP
317
+ ```
318
+
319
+ `extract` and `cast` are grammar as well: the field and the type go where no
320
+ value could. The field has to be a plain name, and the type has to look like
321
+ a type — a plain name, at most parenthesized with lengths, so the adapters'
322
+ own spellings like `double precision` or `decimal(10,2)` pass; anything else
323
+ raises `ArgumentError`. The type is the adapter's own name for the type, and
324
+ whether it exists is the database's to say. SQLite spells everything
325
+ `extract` does as `strftime` formats, which no renaming carries, so `extract`
326
+ raises there:
327
+
328
+ ```ruby
329
+ Post.where { extract(:year, :created_at) == 2026 }
330
+ # SELECT "posts".* FROM "posts" WHERE EXTRACT(YEAR FROM "posts"."created_at") = 2026
331
+
332
+ Post.select { cast(:price, 'decimal(10,2)').as(:price) }
333
+ # SELECT CAST("posts"."price" AS decimal(10,2)) AS price
334
+ ```
335
+
336
+ `format` is printf formatting, and raises on MySQL, where a function of the
337
+ same name does something else entirely: it puts separators in a number, and
338
+ reads a printf template as the number zero rather than complaining. `fn` still
339
+ reaches it, spelled as the different thing it is:
340
+
341
+ ```ruby
342
+ Post.select { fn(:format, :amount, 2) } # MySQL's, on purpose
343
+ ```
344
+
345
+ Pass `:*` to `count` for `COUNT(*)`, and `distinct: true` for
346
+ `COUNT(DISTINCT ...)`:
197
347
 
198
348
  ```ruby
199
349
  Author.group { :country }.having { count(:*) > 1 }
200
350
  # SELECT "authors".* FROM "authors" GROUP BY "authors"."country" HAVING COUNT(*) > 1
351
+
352
+ Post.select { count(:author_id, distinct: true) } # COUNT(DISTINCT "author_id")
353
+ ```
354
+
355
+ Values are quoted by the adapter wherever they appear, as they are in
356
+ ActiveRecord. Column aliases and `fn`'s function name are not — they are
357
+ written into the SQL as given — so those two have to be plain names,
358
+ optionally qualified by a schema in `fn`'s case. Anything else raises
359
+ `ArgumentError` rather than reaching the query.
360
+
361
+ `fn` reaches functions without a method of their own. Its name is emitted as
362
+ written, so a case-sensitive one can be spelled exactly:
363
+
364
+ ```ruby
365
+ Post.select { fn(:date_trunc, 'day', :created_at).as(:day) }
366
+ # SELECT date_trunc('day', "posts"."created_at") AS day
367
+ ```
368
+
369
+ `+`, `-`, `*` and `/` build arithmetic. Ruby puts them above the comparison
370
+ operators, so an expression groups the way it reads:
371
+
372
+ ```ruby
373
+ Item.where { :price * :quantity > 1000 }
374
+ Item.select { sum(:price * :quantity).as(:total) }
375
+ ```
376
+
377
+ `.asc` and `.desc` take `.nulls_first` / `.nulls_last`. MySQL has no such
378
+ syntax, but Arel emulates it there, so the resulting order is the same
379
+ everywhere:
380
+
381
+ ```ruby
382
+ Author.order { :country.asc.nulls_last }
201
383
  ```
202
384
 
203
385
  ```ruby
@@ -216,7 +398,68 @@ Author.
216
398
  }
217
399
  ```
218
400
 
219
- See `examples/` for complete, runnable scripts.
401
+ ## Examples
402
+
403
+ `examples/` holds runnable scripts, each printing the SQL it builds and, where
404
+ the result is the point, the rows that come back. All but the last run against
405
+ an in-memory SQLite database and need no setup.
406
+
407
+ | | |
408
+ | --- | --- |
409
+ | `predicates.rb` | the `where` vocabulary: ranges, sets, NULL, text matching |
410
+ | `subqueries.rb` | `in?` with a relation, `exists?`, scalar subqueries |
411
+ | `expressions.rb` | arithmetic, aggregates, functions, `NULLS LAST` |
412
+ | `complex_joins.rb` | compound `ON` clauses, outer joins, a self join |
413
+ | `aggregations.rb` | `GROUP BY`, `HAVING` and aggregates across joins |
414
+ | `ctes.rb` | `with` and `with_recursive` |
415
+ | `postgresql.rb` | array columns, regular expressions, `ILIKE` (needs a server) |
416
+
417
+ ## Performance
418
+
419
+ `benchmark/query_building.rb` compares building the same queries through the
420
+ block DSL and through ActiveRecord's other argument styles. Only query
421
+ construction (through `to_sql`) is measured — every style produces the same
422
+ SQL, so execution costs the same regardless.
423
+
424
+ Queries built per second (ruby 4.1.0dev, ActiveRecord 8.1.3, one machine —
425
+ treat the ratios, not the absolute numbers, as the result):
426
+
427
+ | query | string | arel | block (this gem) | hash | relation and/or |
428
+ | --- | --- | --- | --- | --- | --- |
429
+ | simple equality | 42.5k | 42.0k | 37.7k | 31.5k | — |
430
+ | range (BETWEEN) | — | 34.6k | 32.7k | 24.8k | — |
431
+ | LIKE | 41.5k | 41.0k | 36.8k | — | — |
432
+ | compound AND/OR | 34.4k | 27.7k | 24.4k | — | 11.9k |
433
+
434
+ Allocated memory per built query:
435
+
436
+ | query | arel | block (this gem) | hash | string | relation and/or |
437
+ | --- | --- | --- | --- | --- | --- |
438
+ | simple equality | 2,600 B | 2,832 B | 3,328 B | 3,448 B | — |
439
+ | compound AND/OR | 3,208 B | 3,584 B | — | 4,680 B | 9,120 B |
440
+
441
+ In short: the block DSL is 6–13% slower than hand-written Arel (which it
442
+ compiles to), a little faster than hash conditions, and both faster and
443
+ leaner than `where(...).and(where(...).or(where(...)))` relation chains,
444
+ which pay for structural-compatibility checks and relation copies. The
445
+ `Proc#refined` call itself costs about 150 ns of the ~25 μs build — the
446
+ re-interpretation of the block is not where the time goes. Against a
447
+ database round trip of tens to hundreds of microseconds, none of these
448
+ differences are visible in an application.
449
+
450
+ One memory cost sits outside the per-query numbers above: to run a block
451
+ under the refinements, `Proc#refined` deep-copies its instruction sequence,
452
+ nested blocks included. The copy is made lazily on the refined proc's first
453
+ call and memoized per block and refinement list for the life of the process,
454
+ so it is paid once per `where { ... }` call site, not per query — the
455
+ benchmark measures the copy at the size of the original (568 bytes for the
456
+ simple-equality block, 888 bytes for the compound one), and a thousand
457
+ further calls from the same call site copy nothing. Steady state, an
458
+ application holds one extra copy of each distinct query block's bytecode:
459
+ a few hundred bytes per call site. "Per call site" assumes blocks compiled
460
+ once, as normal code is — building query blocks with a string `eval` mints
461
+ a fresh instruction sequence per pass, each earning a copy of its own, and
462
+ the memo keeps both alive for the life of the process.
220
463
 
221
464
  ## Running the tests
222
465
 
@@ -237,12 +480,15 @@ database is created on first use.
237
480
 
238
481
  The `pg` and `mysql2` gems are in the Gemfile's `db` group, since building them
239
482
  needs the client libraries installed. Skip them if SQLite is all you need,
240
- which is what CI does:
483
+ which is what CI's SQLite job does:
241
484
 
242
485
  ```sh
243
486
  bundle config set --local without db
244
487
  ```
245
488
 
489
+ CI runs all three, one job per adapter, with PostgreSQL and MySQL as service
490
+ containers.
491
+
246
492
  ## Releasing
247
493
 
248
494
  Pushing a `v*` tag runs `.github/workflows/push_gem.yml`, which builds the gem
@@ -12,7 +12,9 @@ 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.
17
+ gem.files = `git ls-files`.split($/).grep_v(%r{^sandbox/})
16
18
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
19
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
20
  gem.require_paths = ["lib"]