activerecord-refined 0.4.0 → 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/README.md +50 -6
- data/activerecord-refined.gemspec +3 -1
- data/benchmark/query_building.rb +12 -12
- data/examples/predicates.rb +11 -11
- data/examples/subqueries.rb +5 -5
- data/lib/active_record/refined/ast.rb +82 -6
- data/lib/active_record/refined.rb +72 -7
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +186 -35
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 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/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:
|
|
@@ -118,7 +126,7 @@ collation, so it means the same thing everywhere:
|
|
|
118
126
|
|
|
119
127
|
```ruby
|
|
120
128
|
Author.where { :name.ilike?('ma%') } # ILIKE 'ma%' / LIKE 'ma%'
|
|
121
|
-
Author.where { :name.casecmp?('
|
|
129
|
+
Author.where { :name.casecmp?('Alice') } # LOWER(name) = LOWER('Alice')
|
|
122
130
|
```
|
|
123
131
|
|
|
124
132
|
`not_distinct_from?` and `distinct_from?` compare with NULL treated as a
|
|
@@ -277,18 +285,54 @@ so a misspelling is a `NoMethodError` where you wrote it, and a name Ruby also
|
|
|
277
285
|
answers to — `rand` — means the SQL one inside a block:
|
|
278
286
|
|
|
279
287
|
```
|
|
280
|
-
abs ceil char_length coalesce concat
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
283
294
|
```
|
|
284
295
|
|
|
285
296
|
Most are spelled the same everywhere. Where they are not, the method names one
|
|
286
297
|
meaning and each adapter gets its own spelling: `char_length`, `greatest` and
|
|
287
298
|
`least` become `LENGTH`, `MAX` and `MIN` on SQLite, and `rand` is `RAND` on
|
|
288
|
-
MySQL and `RANDOM` elsewhere
|
|
289
|
-
|
|
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`
|
|
290
304
|
rather than leaving the database to reject the SQL.
|
|
291
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
|
+
|
|
292
336
|
`format` is printf formatting, and raises on MySQL, where a function of the
|
|
293
337
|
same name does something else entirely: it puts separators in a number, and
|
|
294
338
|
reads a printf template as the number zero rather than complaining. `fn` still
|
|
@@ -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"]
|
data/benchmark/query_building.rb
CHANGED
|
@@ -28,10 +28,10 @@ T = User.arel_table
|
|
|
28
28
|
# Each variant must generate equivalent SQL; sanity-print once.
|
|
29
29
|
VARIANTS = {
|
|
30
30
|
"simple equality" => {
|
|
31
|
-
"hash" => -> { User.where(name: "
|
|
32
|
-
"string" => -> { User.where("name = ?", "
|
|
33
|
-
"arel" => -> { User.where(T[:name].eq("
|
|
34
|
-
"block" => -> { User.where { :name == "
|
|
31
|
+
"hash" => -> { User.where(name: "alice").to_sql },
|
|
32
|
+
"string" => -> { User.where("name = ?", "alice").to_sql },
|
|
33
|
+
"arel" => -> { User.where(T[:name].eq("alice")).to_sql },
|
|
34
|
+
"block" => -> { User.where { :name == "alice" }.to_sql },
|
|
35
35
|
},
|
|
36
36
|
"range (BETWEEN)" => {
|
|
37
37
|
"hash" => -> { User.where(age: 20..40).to_sql },
|
|
@@ -41,13 +41,13 @@ VARIANTS = {
|
|
|
41
41
|
"LIKE" => {
|
|
42
42
|
"string" => -> { User.where("name LIKE ?", "ma%").to_sql },
|
|
43
43
|
"arel" => -> { User.where(T[:name].matches("ma%", nil, true)).to_sql },
|
|
44
|
-
"block" => -> { User.where { :name.like?("
|
|
44
|
+
"block" => -> { User.where { :name.like?("al%") }.to_sql },
|
|
45
45
|
},
|
|
46
46
|
"compound AND/OR" => {
|
|
47
|
-
"string" => -> { User.where("age >= ? AND (name = ? OR name = ?)", 18, "
|
|
48
|
-
"arel" => -> { User.where(T[:age].gteq(18).and(T[:name].eq("
|
|
49
|
-
"relation" => -> { User.where(age: 18..).and(User.where(name: "
|
|
50
|
-
"block" => -> { User.where { (:age >= 18) & ((:name == "
|
|
47
|
+
"string" => -> { User.where("age >= ? AND (name = ? OR name = ?)", 18, "alice", "bob").to_sql },
|
|
48
|
+
"arel" => -> { User.where(T[:age].gteq(18).and(T[:name].eq("alice").or(T[:name].eq("bob")))).to_sql },
|
|
49
|
+
"relation" => -> { User.where(age: 18..).and(User.where(name: "alice").or(User.where(name: "bob"))).to_sql },
|
|
50
|
+
"block" => -> { User.where { (:age >= 18) & ((:name == "alice") | (:name == "bob")) }.to_sql },
|
|
51
51
|
},
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -97,8 +97,8 @@ context = ActiveRecord::Refined::BlockContext.new
|
|
|
97
97
|
syntax = ActiveRecord::Refined::BlockSyntax
|
|
98
98
|
|
|
99
99
|
{
|
|
100
|
-
"simple equality" => proc { :name == "
|
|
101
|
-
"compound AND/OR" => proc { (:age >= 18) & ((:name == "
|
|
100
|
+
"simple equality" => proc { :name == "alice" },
|
|
101
|
+
"compound AND/OR" => proc { (:age >= 18) & ((:name == "alice") | (:name == "bob")) },
|
|
102
102
|
}.each do |label, blk|
|
|
103
103
|
refined = blk.refined(syntax)
|
|
104
104
|
context.instance_exec(&refined) # the copy is made here, on the first call
|
|
@@ -115,7 +115,7 @@ puts "1000 more calls from the same call site copied #{iseq_count.call - before}
|
|
|
115
115
|
|
|
116
116
|
puts
|
|
117
117
|
puts "=== where the block path spends its time ==="
|
|
118
|
-
block = proc { :name == "
|
|
118
|
+
block = proc { :name == "alice" }
|
|
119
119
|
refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
|
|
120
120
|
context = ActiveRecord::Refined::BlockContext.new
|
|
121
121
|
Benchmark.ips do |x|
|
data/examples/predicates.rb
CHANGED
|
@@ -16,11 +16,11 @@ Setup.new.up
|
|
|
16
16
|
class Account < ActiveRecord::Base
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
Account.create!(login: '
|
|
20
|
-
Account.create!(login: '
|
|
21
|
-
Account.create!(login: '
|
|
22
|
-
Account.create!(login: '100%_pure',
|
|
23
|
-
Account.create!(login: '1002000',
|
|
19
|
+
Account.create!(login: 'alice', country: 'JP', age: 60)
|
|
20
|
+
Account.create!(login: 'bob', country: 'JP', age: 50)
|
|
21
|
+
Account.create!(login: 'carol', country: 'US', age: 45)
|
|
22
|
+
Account.create!(login: '100%_pure', country: nil, age: 30)
|
|
23
|
+
Account.create!(login: '1002000', country: 'US', age: 25)
|
|
24
24
|
|
|
25
25
|
def show(title, relation, rows)
|
|
26
26
|
puts "--- #{title} ---"
|
|
@@ -63,12 +63,12 @@ show('distinct_from? keeps NULLs, != drops them',
|
|
|
63
63
|
# literals, so % and _ in them are escaped rather than matched as
|
|
64
64
|
# wildcards. Note the last row matches only the literal-minded one.
|
|
65
65
|
show('like? takes a pattern',
|
|
66
|
-
Account.where { :login.like?('%
|
|
67
|
-
Account.where { :login.like?('%
|
|
66
|
+
Account.where { :login.like?('%rol') },
|
|
67
|
+
Account.where { :login.like?('%rol') }.pluck(:login))
|
|
68
68
|
|
|
69
69
|
show('start_with? takes any number of literals, like String#start_with?',
|
|
70
|
-
Account.where { :login.start_with?('
|
|
71
|
-
Account.where { :login.start_with?('
|
|
70
|
+
Account.where { :login.start_with?('al', 'bo') },
|
|
71
|
+
Account.where { :login.start_with?('al', 'bo') }.pluck(:login))
|
|
72
72
|
|
|
73
73
|
# The % in the argument is escaped, so only the account whose login really
|
|
74
74
|
# contains "100%" matches; the same pattern spelled with like? treats it as a
|
|
@@ -83,8 +83,8 @@ show('include? escapes wildcards; like? does not',
|
|
|
83
83
|
# casecmp? folds both sides rather than trusting the collation, so it means
|
|
84
84
|
# the same thing on every adapter.
|
|
85
85
|
show('casecmp? is case-insensitive equality',
|
|
86
|
-
Account.where { :login.casecmp?('
|
|
87
|
-
Account.where { :login.casecmp?('
|
|
86
|
+
Account.where { :login.casecmp?('AlIcE') },
|
|
87
|
+
Account.where { :login.casecmp?('AlIcE') }.pluck(:login))
|
|
88
88
|
|
|
89
89
|
# 4. Combining. & | ! build the tree; Ruby's precedence puts & and | above
|
|
90
90
|
# the comparison operators, hence the parentheses around each comparison.
|
data/examples/subqueries.rb
CHANGED
|
@@ -24,13 +24,13 @@ class Post < ActiveRecord::Base
|
|
|
24
24
|
def self.published = where { :published == true }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
alice = Author.create!(name: 'alice')
|
|
28
|
+
bob = Author.create!(name: 'bob')
|
|
29
29
|
quiet = Author.create!(name: 'quiet')
|
|
30
30
|
|
|
31
|
-
Post.create!(title: 'refinements', author_id:
|
|
32
|
-
Post.create!(title: 'parser', author_id:
|
|
33
|
-
Post.create!(title: 'draft', author_id:
|
|
31
|
+
Post.create!(title: 'refinements', author_id: alice.id, likes: 100, published: true)
|
|
32
|
+
Post.create!(title: 'parser', author_id: alice.id, likes: 40, published: true)
|
|
33
|
+
Post.create!(title: 'draft', author_id: bob.id, likes: 5, published: false)
|
|
34
34
|
|
|
35
35
|
def show(title, relation)
|
|
36
36
|
puts "--- #{title} ---"
|
|
@@ -9,6 +9,9 @@ module ActiveRecord
|
|
|
9
9
|
NAME = /[[:alpha:]_][[:alnum:]_$]*/
|
|
10
10
|
ALIAS_NAME = /\A#{NAME}\z/
|
|
11
11
|
FUNCTION_NAME = /\A#{NAME}(\.#{NAME})?\z/
|
|
12
|
+
# A SQL type as cast writes it: words, at most parenthesized with
|
|
13
|
+
# lengths -- double precision, decimal(10,2).
|
|
14
|
+
TYPE_NAME = /\A[[:alpha:]_][[:alnum:]_ ]*(\(\d+(, ?\d+)?\))?\z/
|
|
12
15
|
|
|
13
16
|
def self.check_name(name, pattern, what)
|
|
14
17
|
return name if pattern.match?(name.to_s)
|
|
@@ -218,6 +221,15 @@ module ActiveRecord
|
|
|
218
221
|
else operand
|
|
219
222
|
end
|
|
220
223
|
end
|
|
224
|
+
|
|
225
|
+
# Resolves a function argument: a column or an expression as above,
|
|
226
|
+
# anything else a value to be quoted.
|
|
227
|
+
def to_arel_argument(arg, table)
|
|
228
|
+
case arg
|
|
229
|
+
when Node, Symbol then to_arel_operand(arg, table)
|
|
230
|
+
else Arel::Nodes.build_quoted(arg)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
221
233
|
end
|
|
222
234
|
|
|
223
235
|
class Predicate < Node
|
|
@@ -351,16 +363,80 @@ module ActiveRecord
|
|
|
351
363
|
end
|
|
352
364
|
|
|
353
365
|
def to_arel(table)
|
|
354
|
-
arel_args = args.map
|
|
355
|
-
case arg
|
|
356
|
-
when Node, Symbol then to_arel_operand(arg, table)
|
|
357
|
-
else Arel::Nodes.build_quoted(arg)
|
|
358
|
-
end
|
|
359
|
-
end
|
|
366
|
+
arel_args = args.map {|arg| to_arel_argument(arg, table) }
|
|
360
367
|
Arel::Nodes::NamedFunction.new(name, arel_args)
|
|
361
368
|
end
|
|
362
369
|
end
|
|
363
370
|
|
|
371
|
+
# EXTRACT(field FROM expr). The field is grammar rather than a value --
|
|
372
|
+
# a keyword the adapter reads bare -- so it has to be a plain name,
|
|
373
|
+
# which Arel upcases on the way out.
|
|
374
|
+
class Extract < Node
|
|
375
|
+
include Predications
|
|
376
|
+
include Arithmetics
|
|
377
|
+
|
|
378
|
+
attr_reader :field, :operand
|
|
379
|
+
|
|
380
|
+
def initialize(field, operand)
|
|
381
|
+
@field = AST.check_name(field, ALIAS_NAME, "extract field")
|
|
382
|
+
@operand = operand
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def to_arel(table)
|
|
386
|
+
Arel::Nodes::Extract.new(to_arel_argument(operand, table), field.to_s)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# CAST(expr AS type). The type is grammar too, written into the SQL as
|
|
391
|
+
# given -- it is the adapter's own name for the type, and whether it
|
|
392
|
+
# exists is the database's to say -- so it has to look like one:
|
|
393
|
+
# a plain name, at most parenthesized with lengths.
|
|
394
|
+
class Cast < Node
|
|
395
|
+
include Predications
|
|
396
|
+
include Arithmetics
|
|
397
|
+
|
|
398
|
+
attr_reader :operand, :sql_type
|
|
399
|
+
|
|
400
|
+
def initialize(operand, sql_type)
|
|
401
|
+
@operand = operand
|
|
402
|
+
@sql_type = AST.check_name(sql_type, TYPE_NAME, "SQL type")
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def to_arel(table)
|
|
406
|
+
Arel::Nodes::NamedFunction.new(
|
|
407
|
+
"CAST",
|
|
408
|
+
[Arel::Nodes::As.new(to_arel_argument(operand, table),
|
|
409
|
+
Arel::Nodes::SqlLiteral.new(sql_type.to_s))])
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# CURRENT_TIMESTAMP and its relatives, what the SQL grammar calls a
|
|
414
|
+
# datetime value function. The grammar has them bare, and PostgreSQL
|
|
415
|
+
# and SQLite reject them written as calls, so unlike Function the name
|
|
416
|
+
# is emitted without parentheses. A precision is the one thing that
|
|
417
|
+
# does go into parentheses, and it is written into the SQL as given, so
|
|
418
|
+
# only an Integer is accepted.
|
|
419
|
+
class DatetimeValueFunction < Node
|
|
420
|
+
include Predications
|
|
421
|
+
include Arithmetics
|
|
422
|
+
|
|
423
|
+
attr_reader :name, :precision
|
|
424
|
+
|
|
425
|
+
def initialize(name, precision = nil)
|
|
426
|
+
unless precision.nil? || precision.is_a?(Integer)
|
|
427
|
+
raise ArgumentError,
|
|
428
|
+
"#{precision.inspect} is not an Integer precision"
|
|
429
|
+
end
|
|
430
|
+
@name = name
|
|
431
|
+
@precision = precision
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def to_arel(_table)
|
|
435
|
+
Arel::Nodes::SqlLiteral.new(
|
|
436
|
+
precision ? "#{name}(#{precision})" : name)
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
364
440
|
# A plain SQL comparison. The value is passed through as it is, so a Range
|
|
365
441
|
# or an Array compares against a PostgreSQL range or array column, the way
|
|
366
442
|
# ActiveRecord's own force_equality? types do.
|
|
@@ -55,13 +55,20 @@ module ActiveRecord
|
|
|
55
55
|
# Availability was checked by calling each one; the SQLite figures
|
|
56
56
|
# assume the math functions its build usually enables.
|
|
57
57
|
SCALAR_FUNCTIONS = {
|
|
58
|
-
abs: {},
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
abs: {}, acos: {}, asin: {}, atan: {}, atan2: {}, ceil: {},
|
|
59
|
+
coalesce: {}, concat: {}, cos: {}, degrees: {}, exp: {}, floor: {},
|
|
60
|
+
length: {}, ln: {}, log: {}, log10: {}, lower: {}, ltrim: {},
|
|
61
|
+
mod: {}, nullif: {}, pi: {}, power: {}, radians: {}, replace: {},
|
|
62
|
+
round: {}, rtrim: {}, sign: {}, sin: {}, sqrt: {}, substr: {},
|
|
63
|
+
tan: {}, trim: {}, upper: {},
|
|
62
64
|
char_length: {sqlite: 'LENGTH'},
|
|
63
65
|
greatest: {sqlite: 'MAX'},
|
|
64
66
|
least: {sqlite: 'MIN'},
|
|
67
|
+
# PostgreSQL spells log2(x) as log(2, x), which no renaming carries.
|
|
68
|
+
log2: {postgresql: nil},
|
|
69
|
+
# MySQL's TRUNCATE insists on the second argument, where the others
|
|
70
|
+
# default it to zero; SQLite's trunc takes only the one.
|
|
71
|
+
trunc: {mysql: 'TRUNCATE'},
|
|
65
72
|
now: {sqlite: nil},
|
|
66
73
|
date_trunc: {sqlite: nil, mysql: nil},
|
|
67
74
|
# Named for Kernel#rand, which it also takes back: a block calling
|
|
@@ -83,7 +90,65 @@ module ActiveRecord
|
|
|
83
90
|
}.freeze
|
|
84
91
|
|
|
85
92
|
SCALAR_FUNCTIONS.each_key do |name|
|
|
86
|
-
define_method(name)
|
|
93
|
+
define_method(name) do |*args|
|
|
94
|
+
AST::Function.new(function_name(name, SCALAR_FUNCTIONS), args)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The datetime value functions, as the SQL grammar calls them. These
|
|
99
|
+
# the grammar has bare -- PostgreSQL and SQLite reject them written with
|
|
100
|
+
# parentheses -- and the one thing that does go into parentheses is an
|
|
101
|
+
# optional precision, current_timestamp(3), which current_date never
|
|
102
|
+
# takes and SQLite never accepts. The table reads like
|
|
103
|
+
# SCALAR_FUNCTIONS; current_timestamp is the portable spelling of what
|
|
104
|
+
# now means, reaching SQLite where now does not.
|
|
105
|
+
DATETIME_VALUE_FUNCTIONS = {
|
|
106
|
+
current_date: {},
|
|
107
|
+
current_time: {},
|
|
108
|
+
current_timestamp: {},
|
|
109
|
+
localtime: {sqlite: nil},
|
|
110
|
+
localtimestamp: {sqlite: nil},
|
|
111
|
+
}.freeze
|
|
112
|
+
|
|
113
|
+
def current_date
|
|
114
|
+
AST::DatetimeValueFunction.new(
|
|
115
|
+
function_name(:current_date, DATETIME_VALUE_FUNCTIONS))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
(DATETIME_VALUE_FUNCTIONS.keys - [:current_date]).each do |name|
|
|
119
|
+
define_method(name) do |precision = nil|
|
|
120
|
+
# Built first so that a precision of the wrong type is an
|
|
121
|
+
# ArgumentError on every adapter, before SQLite gets to say it takes
|
|
122
|
+
# none at all.
|
|
123
|
+
node = AST::DatetimeValueFunction.new(
|
|
124
|
+
function_name(name, DATETIME_VALUE_FUNCTIONS), precision)
|
|
125
|
+
if precision && adapter_family == :sqlite
|
|
126
|
+
raise NotImplementedError,
|
|
127
|
+
"#{name} takes no precision on #{@model.connection_db_config.adapter}"
|
|
128
|
+
end
|
|
129
|
+
node
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# EXTRACT(field FROM expr). The field is a keyword, not a value, so it
|
|
134
|
+
# has to be a plain name; the node checks it. SQLite spells all of
|
|
135
|
+
# this as strftime formats, which no renaming carries, so it raises
|
|
136
|
+
# there -- after the node is built, so that a bad field is an
|
|
137
|
+
# ArgumentError on every adapter.
|
|
138
|
+
def extract(field, expr)
|
|
139
|
+
node = AST::Extract.new(field, expr)
|
|
140
|
+
if adapter_family == :sqlite
|
|
141
|
+
raise NotImplementedError,
|
|
142
|
+
"extract has no equivalent on #{@model.connection_db_config.adapter}"
|
|
143
|
+
end
|
|
144
|
+
node
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# CAST(expr AS type). The type is the adapter's own name for it,
|
|
148
|
+
# checked for shape by the node; whether it exists is the database's to
|
|
149
|
+
# say.
|
|
150
|
+
def cast(expr, type)
|
|
151
|
+
AST::Cast.new(expr, type)
|
|
87
152
|
end
|
|
88
153
|
|
|
89
154
|
# Escape hatch for functions without a method of their own. The name is
|
|
@@ -101,8 +166,8 @@ module ActiveRecord
|
|
|
101
166
|
|
|
102
167
|
private
|
|
103
168
|
|
|
104
|
-
def function_name(name)
|
|
105
|
-
spellings =
|
|
169
|
+
def function_name(name, functions)
|
|
170
|
+
spellings = functions.fetch(name)
|
|
106
171
|
return name.to_s.upcase unless spellings.key?(adapter_family)
|
|
107
172
|
spellings.fetch(adapter_family) ||
|
|
108
173
|
raise(NotImplementedError,
|
data/test/test_block_syntax.rb
CHANGED
|
@@ -2,11 +2,11 @@ require_relative 'test_helper'
|
|
|
2
2
|
|
|
3
3
|
class TestBlockSyntax < Minitest::Test
|
|
4
4
|
def test_equal
|
|
5
|
-
assert_sql(/WHERE "users"."name" = '
|
|
5
|
+
assert_sql(/WHERE "users"."name" = 'alice'/, User.where { :name == 'alice' }.to_sql)
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def test_not_equal
|
|
9
|
-
assert_sql(/WHERE "users"."name" != '
|
|
9
|
+
assert_sql(/WHERE "users"."name" != 'bob'/, User.where { :name != 'bob' }.to_sql)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def test_greater_than
|
|
@@ -34,23 +34,23 @@ class TestBlockSyntax < Minitest::Test
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def test_and
|
|
37
|
-
assert_sql(/WHERE "users"."name" = '
|
|
38
|
-
User.where { (:name == '
|
|
37
|
+
assert_sql(/WHERE "users"."name" = 'alice' AND "users"."age" > 18/,
|
|
38
|
+
User.where { (:name == 'alice') & (:age > 18) }.to_sql)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def test_or
|
|
42
|
-
assert_sql(/WHERE \(?\"users\".\"name\" = '
|
|
43
|
-
User.where { (:name == '
|
|
42
|
+
assert_sql(/WHERE \(?\"users\".\"name\" = 'alice' OR \"users\".\"name\" = 'bob'\)?/,
|
|
43
|
+
User.where { (:name == 'alice') | (:name == 'bob') }.to_sql)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def test_not
|
|
47
|
-
assert_sql(/WHERE NOT \(?\"users\".\"name\" = '
|
|
48
|
-
User.where { !(:name == '
|
|
47
|
+
assert_sql(/WHERE NOT \(?\"users\".\"name\" = 'alice'\)?/,
|
|
48
|
+
User.where { !(:name == 'alice') }.to_sql)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def test_complex_combination
|
|
52
|
-
sql = User.where { ((:name == '
|
|
53
|
-
assert_sql(/"users"."name" = '
|
|
52
|
+
sql = User.where { ((:name == 'alice') & (:age > 18)) | !(:name == 'bob') }.to_sql
|
|
53
|
+
assert_sql(/"users"."name" = 'alice'/, sql)
|
|
54
54
|
assert_sql(/"users"."age" > 18/, sql)
|
|
55
55
|
assert_sql(/NOT/, sql)
|
|
56
56
|
assert_sql(/OR/, sql)
|
|
@@ -70,13 +70,13 @@ class TestBlockSyntax < Minitest::Test
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def test_casecmp
|
|
73
|
-
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('
|
|
74
|
-
User.where { :name.casecmp?('
|
|
73
|
+
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('Alice'\)/,
|
|
74
|
+
User.where { :name.casecmp?('Alice') }.to_sql)
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def test_casecmp_qualified
|
|
78
|
-
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('
|
|
79
|
-
User.where { :users[:name].casecmp?('
|
|
78
|
+
assert_sql(/WHERE LOWER\("users"."name"\) = LOWER\('Alice'\)/,
|
|
79
|
+
User.where { :users[:name].casecmp?('Alice') }.to_sql)
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def test_casecmp_nil_is_rejected
|
|
@@ -86,9 +86,9 @@ class TestBlockSyntax < Minitest::Test
|
|
|
86
86
|
|
|
87
87
|
def test_casecmp_execution
|
|
88
88
|
User.delete_all
|
|
89
|
-
User.create!(name: '
|
|
90
|
-
User.create!(name: '
|
|
91
|
-
assert_equal(['
|
|
89
|
+
User.create!(name: 'Alice')
|
|
90
|
+
User.create!(name: 'bob')
|
|
91
|
+
assert_equal(['Alice'], User.where { :name.casecmp?('aLiCe') }.pluck(:name))
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
def test_not_like
|
|
@@ -115,8 +115,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
115
115
|
# of literals; matching any one of them is enough.
|
|
116
116
|
def test_start_with_multiple
|
|
117
117
|
assert_sql(
|
|
118
|
-
/WHERE \("users"."name" LIKE '
|
|
119
|
-
User.where { :name.start_with?('
|
|
118
|
+
/WHERE \("users"."name" LIKE 'al%' ESCAPE '\\' OR "users"."name" LIKE 'bo%' ESCAPE '\\'\)/,
|
|
119
|
+
User.where { :name.start_with?('al', 'bo') }.to_sql)
|
|
120
120
|
end
|
|
121
121
|
|
|
122
122
|
def test_end_with_multiple
|
|
@@ -128,8 +128,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
128
128
|
# The OR arrives grouped, so a following & applies to the whole list.
|
|
129
129
|
def test_start_with_multiple_combined
|
|
130
130
|
assert_sql(
|
|
131
|
-
/WHERE \("users"."name" LIKE '
|
|
132
|
-
User.where { :name.start_with?('
|
|
131
|
+
/WHERE \("users"."name" LIKE 'al%' ESCAPE '\\' OR "users"."name" LIKE 'bo%' ESCAPE '\\'\) AND "users"."age" > 18/,
|
|
132
|
+
User.where { :name.start_with?('al', 'bo') & (:age > 18) }.to_sql)
|
|
133
133
|
end
|
|
134
134
|
|
|
135
135
|
def test_start_with_no_arguments
|
|
@@ -350,11 +350,11 @@ class TestBlockSyntax < Minitest::Test
|
|
|
350
350
|
|
|
351
351
|
def test_not_distinct_from_a_value_execution
|
|
352
352
|
User.delete_all
|
|
353
|
-
User.create!(name: '
|
|
353
|
+
User.create!(name: 'alice')
|
|
354
354
|
User.create!(name: nil)
|
|
355
|
-
assert_equal(['
|
|
355
|
+
assert_equal(['alice'], User.where { :name.not_distinct_from?('alice') }.pluck(:name))
|
|
356
356
|
# Unlike !=, this keeps the NULL row.
|
|
357
|
-
assert_equal([nil], User.where { :name.distinct_from?('
|
|
357
|
+
assert_equal([nil], User.where { :name.distinct_from?('alice') }.pluck(:name))
|
|
358
358
|
end
|
|
359
359
|
|
|
360
360
|
def test_distinct_from_postgresql_syntax
|
|
@@ -421,8 +421,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
421
421
|
|
|
422
422
|
def test_exists_combined
|
|
423
423
|
assert_sql(
|
|
424
|
-
/WHERE "authors"."name" = '
|
|
425
|
-
Author.where { (:name == '
|
|
424
|
+
/WHERE "authors"."name" = 'alice' AND EXISTS \(SELECT "posts"\.\* FROM "posts" WHERE "posts"."title" = 'pub'\)/,
|
|
425
|
+
Author.where { (:name == 'alice') & exists?(Post.where(title: 'pub')) }.to_sql)
|
|
426
426
|
end
|
|
427
427
|
|
|
428
428
|
def test_exists_execution
|
|
@@ -477,8 +477,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
477
477
|
end
|
|
478
478
|
|
|
479
479
|
def test_qualified_column
|
|
480
|
-
assert_sql(/WHERE "users"."name" = '
|
|
481
|
-
User.where { :users[:name] == '
|
|
480
|
+
assert_sql(/WHERE "users"."name" = 'alice'/,
|
|
481
|
+
User.where { :users[:name] == 'alice' }.to_sql)
|
|
482
482
|
end
|
|
483
483
|
|
|
484
484
|
def test_column_to_column_comparison
|
|
@@ -755,8 +755,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
755
755
|
# rather than opening the condition up.
|
|
756
756
|
def test_values_are_quoted
|
|
757
757
|
User.delete_all
|
|
758
|
-
User.create!(name: '
|
|
759
|
-
User.create!(name: '
|
|
758
|
+
User.create!(name: 'alice')
|
|
759
|
+
User.create!(name: 'bob')
|
|
760
760
|
payload = "x' OR 1=1 --"
|
|
761
761
|
assert_empty(User.where { :name == payload }.pluck(:name))
|
|
762
762
|
assert_empty(User.where { :name.like?(payload) }.pluck(:name))
|
|
@@ -793,9 +793,9 @@ class TestBlockSyntax < Minitest::Test
|
|
|
793
793
|
|
|
794
794
|
def test_scalar_functions_run
|
|
795
795
|
User.delete_all
|
|
796
|
-
User.create!(name: '
|
|
797
|
-
assert_equal(['
|
|
798
|
-
assert_equal([
|
|
796
|
+
User.create!(name: 'alice', age: 60)
|
|
797
|
+
assert_equal(['ALICE-x'], User.select { concat(upper(:name), '-x').as(:v) }.map(&:v))
|
|
798
|
+
assert_equal([5], User.select { char_length(:name).as(:v) }.map(&:v))
|
|
799
799
|
assert_equal([60], User.select { greatest(:age, 18).as(:v) }.map(&:v))
|
|
800
800
|
end
|
|
801
801
|
|
|
@@ -826,8 +826,8 @@ class TestBlockSyntax < Minitest::Test
|
|
|
826
826
|
assert_raises(NotImplementedError) { User.select { format('%s!', :name) } }
|
|
827
827
|
else
|
|
828
828
|
User.delete_all
|
|
829
|
-
User.create!(name: '
|
|
830
|
-
assert_equal(['
|
|
829
|
+
User.create!(name: 'alice')
|
|
830
|
+
assert_equal(['alice!'], User.select { format('%s!', :name).as(:v) }.map(&:v))
|
|
831
831
|
end
|
|
832
832
|
end
|
|
833
833
|
|
|
@@ -845,6 +845,157 @@ class TestBlockSyntax < Minitest::Test
|
|
|
845
845
|
end
|
|
846
846
|
end
|
|
847
847
|
|
|
848
|
+
# CURRENT_TIMESTAMP and its relatives are grammar rather than calls, so
|
|
849
|
+
# they come out without the parentheses PostgreSQL and SQLite reject.
|
|
850
|
+
def test_datetime_value_functions_are_emitted_bare
|
|
851
|
+
assert_sql(/SELECT CURRENT_TIMESTAMP FROM/,
|
|
852
|
+
User.select { current_timestamp }.to_sql)
|
|
853
|
+
assert_sql(/SELECT CURRENT_DATE FROM/, User.select { current_date }.to_sql)
|
|
854
|
+
assert_sql(/SELECT CURRENT_TIME FROM/, User.select { current_time }.to_sql)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def test_datetime_value_function_in_comparison_and_alias
|
|
858
|
+
assert_sql(/WHERE "users"."name" < CURRENT_TIMESTAMP/,
|
|
859
|
+
User.where { :name < current_timestamp }.to_sql)
|
|
860
|
+
assert_sql(/SELECT CURRENT_TIMESTAMP AS ts FROM/,
|
|
861
|
+
User.select { current_timestamp.as(:ts) }.to_sql)
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
def test_current_timestamp_runs
|
|
865
|
+
User.delete_all
|
|
866
|
+
User.create!(name: 'alice')
|
|
867
|
+
refute_nil(User.select { current_timestamp.as(:v) }.sole.v)
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
# The one thing that does go into the parentheses is a precision, which
|
|
871
|
+
# current_date never takes and SQLite never accepts.
|
|
872
|
+
def test_datetime_value_function_with_precision
|
|
873
|
+
if ADAPTER == 'sqlite3'
|
|
874
|
+
e = assert_raises(NotImplementedError) { User.select { current_timestamp(3) } }
|
|
875
|
+
assert_match(/precision/, e.message)
|
|
876
|
+
else
|
|
877
|
+
assert_sql(/SELECT CURRENT_TIMESTAMP\(3\) FROM/,
|
|
878
|
+
User.select { current_timestamp(3) }.to_sql)
|
|
879
|
+
assert_sql(/SELECT CURRENT_TIME\(0\) FROM/,
|
|
880
|
+
User.select { current_time(0) }.to_sql)
|
|
881
|
+
User.delete_all
|
|
882
|
+
User.create!(name: 'alice')
|
|
883
|
+
refute_nil(User.select { current_timestamp(0).as(:v) }.sole.v)
|
|
884
|
+
end
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
def test_current_date_takes_no_precision
|
|
888
|
+
assert_raises(ArgumentError) { User.select { current_date(0) } }
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# The precision is written into the SQL as given, so only an Integer is
|
|
892
|
+
# accepted there.
|
|
893
|
+
def test_precision_must_be_an_integer
|
|
894
|
+
assert_raises(ArgumentError) do
|
|
895
|
+
User.select { current_timestamp(:'3); DROP TABLE users --') }
|
|
896
|
+
end
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def test_localtime_is_unsupported_on_sqlite
|
|
900
|
+
if ADAPTER == 'sqlite3'
|
|
901
|
+
assert_raises(NotImplementedError) { User.select { localtime } }
|
|
902
|
+
assert_raises(NotImplementedError) { User.select { localtimestamp } }
|
|
903
|
+
else
|
|
904
|
+
assert_sql(/SELECT LOCALTIME FROM/, User.select { localtime }.to_sql)
|
|
905
|
+
assert_sql(/SELECT LOCALTIMESTAMP FROM/,
|
|
906
|
+
User.select { localtimestamp }.to_sql)
|
|
907
|
+
end
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
def test_math_functions
|
|
911
|
+
assert_sql(/SELECT SIGN\("users"."age"\)/, User.select { sign(:age) }.to_sql)
|
|
912
|
+
assert_sql(/SELECT ATAN2\("users"."age", 2\)/,
|
|
913
|
+
User.select { atan2(:age, 2) }.to_sql)
|
|
914
|
+
assert_sql(/SELECT PI\(\)/, User.select { pi }.to_sql)
|
|
915
|
+
assert_sql(/SELECT DEGREES\(RADIANS\("users"."age"\)\)/,
|
|
916
|
+
User.select { degrees(radians(:age)) }.to_sql)
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
def test_math_functions_run
|
|
920
|
+
User.delete_all
|
|
921
|
+
User.create!(name: 'alice', age: 60)
|
|
922
|
+
assert_equal(1, User.select { sign(:age).as(:v) }.sole.v.to_i)
|
|
923
|
+
assert_equal(60,
|
|
924
|
+
User.select { round(degrees(radians(:age))).as(:v) }.sole.v.to_i)
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
# PostgreSQL spells log2(x) as log(2, x), which no renaming carries.
|
|
928
|
+
def test_log2_is_unsupported_on_postgresql
|
|
929
|
+
if ADAPTER == 'postgresql'
|
|
930
|
+
assert_raises(NotImplementedError) { User.select { log2(:age) } }
|
|
931
|
+
else
|
|
932
|
+
assert_sql(/SELECT LOG2\("users"."age"\)/, User.select { log2(:age) }.to_sql)
|
|
933
|
+
end
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
# MySQL spells trunc TRUNCATE, and insists on the second argument the
|
|
937
|
+
# others default to zero.
|
|
938
|
+
def test_trunc
|
|
939
|
+
expected = ADAPTER == 'mysql2' ? 'TRUNCATE' : 'TRUNC'
|
|
940
|
+
assert_sql(/SELECT #{expected}\("users"."age", 0\)/,
|
|
941
|
+
User.select { trunc(:age, 0) }.to_sql)
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# EXTRACT(field FROM expr): the field is a keyword rather than a value.
|
|
945
|
+
# SQLite spells all of this as strftime formats, which no renaming
|
|
946
|
+
# carries.
|
|
947
|
+
def test_extract
|
|
948
|
+
if ADAPTER == 'sqlite3'
|
|
949
|
+
e = assert_raises(NotImplementedError) { User.select { extract(:year, :name) } }
|
|
950
|
+
assert_match(/extract/, e.message)
|
|
951
|
+
else
|
|
952
|
+
assert_sql(/SELECT EXTRACT\(YEAR FROM "users"."name"\)/,
|
|
953
|
+
User.select { extract(:year, :name) }.to_sql)
|
|
954
|
+
assert_sql(/WHERE EXTRACT\(YEAR FROM "users"."name"\) = 2026/,
|
|
955
|
+
User.where { extract(:year, :name) == 2026 }.to_sql)
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# A bad field is an ArgumentError on every adapter, before SQLite gets to
|
|
960
|
+
# say it has no extract at all.
|
|
961
|
+
def test_extract_rejects_an_injected_field
|
|
962
|
+
assert_raises(ArgumentError) { User.select { extract(INJECTION.to_sym, :name) } }
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def test_extract_runs
|
|
966
|
+
skip "#{ADAPTER} has no extract" if ADAPTER == 'sqlite3'
|
|
967
|
+
User.delete_all
|
|
968
|
+
User.create!(name: 'alice')
|
|
969
|
+
assert_equal(2026,
|
|
970
|
+
User.select { extract(:year, cast('2026-01-05', :date)).as(:v) }.sole.v.to_i)
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def test_cast
|
|
974
|
+
assert_sql(/SELECT CAST\("users"."age" AS text\)/,
|
|
975
|
+
User.select { cast(:age, :text) }.to_sql)
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
def test_cast_runs
|
|
979
|
+
User.delete_all
|
|
980
|
+
User.create!(name: 'alice')
|
|
981
|
+
assert_equal(12.5,
|
|
982
|
+
User.select { cast('12.5', 'decimal(10,2)').as(:v) }.sole.v.to_f)
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
# The type is written into the SQL as given, so it has to look like one:
|
|
986
|
+
# a plain name, at most parenthesized with lengths. The adapters' own
|
|
987
|
+
# spellings with a space in them pass too.
|
|
988
|
+
def test_cast_type_names
|
|
989
|
+
assert_sql(/AS double precision\)/,
|
|
990
|
+
User.select { cast(:age, 'double precision') }.to_sql)
|
|
991
|
+
assert_sql(/AS decimal\(10,2\)\)/,
|
|
992
|
+
User.select { cast(:age, 'decimal(10,2)') }.to_sql)
|
|
993
|
+
assert_raises(ArgumentError) { User.select { cast(:age, INJECTION.to_sym) } }
|
|
994
|
+
assert_raises(ArgumentError) do
|
|
995
|
+
User.select { cast(:age, 'integer); DROP TABLE users --') }
|
|
996
|
+
end
|
|
997
|
+
end
|
|
998
|
+
|
|
848
999
|
# A name with no method of its own is still a NoMethodError, not a
|
|
849
1000
|
# function call the database has to reject.
|
|
850
1001
|
def test_unknown_function_is_a_no_method_error
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord-refined
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shugo Maeda
|
|
@@ -81,6 +81,7 @@ extensions: []
|
|
|
81
81
|
extra_rdoc_files: []
|
|
82
82
|
files:
|
|
83
83
|
- .github/workflows/push_gem.yml
|
|
84
|
+
- .github/workflows/sandbox.yml
|
|
84
85
|
- .github/workflows/test.yml
|
|
85
86
|
- .gitignore
|
|
86
87
|
- Gemfile
|