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 +4 -4
- data/.github/workflows/sandbox.yml +158 -0
- data/.github/workflows/test.yml +56 -2
- data/LICENSE.txt +2 -1
- data/README.md +263 -17
- data/activerecord-refined.gemspec +3 -1
- data/benchmark/query_building.rb +129 -0
- data/examples/complex_joins.rb +14 -1
- data/examples/ctes.rb +82 -0
- data/examples/expressions.rb +87 -0
- data/examples/postgresql.rb +105 -0
- data/examples/predicates.rb +93 -0
- data/examples/subqueries.rb +67 -0
- data/lib/active_record/refined/ast.rb +320 -33
- data/lib/active_record/refined.rb +180 -20
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +597 -20
- data/test/test_helper.rb +12 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba52b9c3529288eeedb0f5ca06a28284669f5a89027d2e372155108f0447ecc1
|
|
4
|
+
data.tar.gz: bb6d96021512a8891577781aa8e2abb14c2dc7cb8716423270ce0ccd074ed8c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/.github/workflows/test.yml
CHANGED
|
@@ -7,10 +7,10 @@ on:
|
|
|
7
7
|
pull_request:
|
|
8
8
|
|
|
9
9
|
jobs:
|
|
10
|
-
|
|
10
|
+
sqlite:
|
|
11
11
|
runs-on: ubuntu-latest
|
|
12
12
|
env:
|
|
13
|
-
#
|
|
13
|
+
# Nothing here loads pg or mysql2, so skip building the extensions.
|
|
14
14
|
BUNDLE_WITHOUT: db
|
|
15
15
|
steps:
|
|
16
16
|
- uses: actions/checkout@v5
|
|
@@ -21,3 +21,57 @@ jobs:
|
|
|
21
21
|
ruby-version: head
|
|
22
22
|
bundler-cache: true
|
|
23
23
|
- run: bundle exec rake test
|
|
24
|
+
|
|
25
|
+
postgresql:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
env:
|
|
28
|
+
ADAPTER: postgresql
|
|
29
|
+
DB_USERNAME: postgres
|
|
30
|
+
DB_PASSWORD: postgres
|
|
31
|
+
services:
|
|
32
|
+
postgres:
|
|
33
|
+
image: postgres:16
|
|
34
|
+
env:
|
|
35
|
+
POSTGRES_PASSWORD: postgres
|
|
36
|
+
ports:
|
|
37
|
+
- 5432:5432
|
|
38
|
+
options: >-
|
|
39
|
+
--health-cmd pg_isready
|
|
40
|
+
--health-interval 10s
|
|
41
|
+
--health-timeout 5s
|
|
42
|
+
--health-retries 5
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v5
|
|
45
|
+
- uses: ruby/setup-ruby@v1
|
|
46
|
+
with:
|
|
47
|
+
ruby-version: head
|
|
48
|
+
bundler-cache: true
|
|
49
|
+
- run: bundle exec rake test
|
|
50
|
+
|
|
51
|
+
mysql:
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
env:
|
|
54
|
+
ADAPTER: mysql2
|
|
55
|
+
# The suite creates its own database, which MYSQL_USER would not be
|
|
56
|
+
# granted; root is the account that can.
|
|
57
|
+
DB_USERNAME: root
|
|
58
|
+
DB_PASSWORD: root
|
|
59
|
+
services:
|
|
60
|
+
mysql:
|
|
61
|
+
image: mysql:8
|
|
62
|
+
env:
|
|
63
|
+
MYSQL_ROOT_PASSWORD: root
|
|
64
|
+
ports:
|
|
65
|
+
- 3306:3306
|
|
66
|
+
options: >-
|
|
67
|
+
--health-cmd "mysqladmin ping -proot"
|
|
68
|
+
--health-interval 10s
|
|
69
|
+
--health-timeout 5s
|
|
70
|
+
--health-retries 5
|
|
71
|
+
steps:
|
|
72
|
+
- uses: actions/checkout@v5
|
|
73
|
+
- uses: ruby/setup-ruby@v1
|
|
74
|
+
with:
|
|
75
|
+
ruby-version: head
|
|
76
|
+
bundler-cache: true
|
|
77
|
+
- run: bundle exec rake test
|
data/LICENSE.txt
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
Copyright (c) 2012 Akira Matsuda
|
|
2
|
+
Copyright (c) 2026 Shugo Maeda
|
|
2
3
|
|
|
3
4
|
MIT License
|
|
4
5
|
|
|
@@ -19,4 +20,4 @@ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
|
19
20
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
21
|
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
22
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
-
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
23
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# ActiveRecord::Refined
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/activerecord-refined)
|
|
3
4
|
[](https://github.com/shugo/activerecord-refined/actions/workflows/test.yml)
|
|
4
5
|
|
|
5
6
|
Adding clean and powerful query syntax on ActiveRecord using refinements.
|
|
@@ -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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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') }
|
|
130
|
-
Article.where { :
|
|
131
|
-
Article.where { :
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
192
|
-
functions
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"]
|