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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3818aa9d45162578a7a0f01eae7b45f943e2a2b1708ec689cd7ba484af9011e
4
- data.tar.gz: 27674049c82ce8ba096f6a9416b603b25c97d326cb3ea78e557b43ffe76b25a0
3
+ metadata.gz: ba52b9c3529288eeedb0f5ca06a28284669f5a89027d2e372155108f0447ecc1
4
+ data.tar.gz: bb6d96021512a8891577781aa8e2abb14c2dc7cb8716423270ce0ccd074ed8c9
5
5
  SHA512:
6
- metadata.gz: 6a3d6b39152e68a21a21a14abd1092484eb335137a182db8068396ef7fa98ca17bd4bb152b0b028bc3ef2e1de17e8d257dcdb03a57cf093a650f387196e22ee9
7
- data.tar.gz: 5e44084905b8c8d22bdde49aad72ceab1b22b1be4e139e8f60237a134a05c258413503595af0f49409ac19923ed296ecbd3f7c075c64b20e6e61f8c2100e5dd3
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?('Matz') } # LOWER(name) = LOWER('Matz')
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 date_trunc exp floor format
281
- greatest least length ln log lower ltrim mod now nullif power rand
282
- replace round rtrim sqrt substr trim upper
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. Where an adapter has no equivalent — `date_trunc`
289
- outside PostgreSQL, `now` on SQLite the block raises `NotImplementedError`
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
- 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"]
@@ -28,10 +28,10 @@ T = User.arel_table
28
28
  # Each variant must generate equivalent SQL; sanity-print once.
29
29
  VARIANTS = {
30
30
  "simple equality" => {
31
- "hash" => -> { User.where(name: "matz").to_sql },
32
- "string" => -> { User.where("name = ?", "matz").to_sql },
33
- "arel" => -> { User.where(T[:name].eq("matz")).to_sql },
34
- "block" => -> { User.where { :name == "matz" }.to_sql },
31
+ "hash" => -> { User.where(name: "alice").to_sql },
32
+ "string" => -> { User.where("name = ?", "alice").to_sql },
33
+ "arel" => -> { User.where(T[:name].eq("alice")).to_sql },
34
+ "block" => -> { User.where { :name == "alice" }.to_sql },
35
35
  },
36
36
  "range (BETWEEN)" => {
37
37
  "hash" => -> { User.where(age: 20..40).to_sql },
@@ -41,13 +41,13 @@ VARIANTS = {
41
41
  "LIKE" => {
42
42
  "string" => -> { User.where("name LIKE ?", "ma%").to_sql },
43
43
  "arel" => -> { User.where(T[:name].matches("ma%", nil, true)).to_sql },
44
- "block" => -> { User.where { :name.like?("ma%") }.to_sql },
44
+ "block" => -> { User.where { :name.like?("al%") }.to_sql },
45
45
  },
46
46
  "compound AND/OR" => {
47
- "string" => -> { User.where("age >= ? AND (name = ? OR name = ?)", 18, "matz", "nobu").to_sql },
48
- "arel" => -> { User.where(T[:age].gteq(18).and(T[:name].eq("matz").or(T[:name].eq("nobu")))).to_sql },
49
- "relation" => -> { User.where(age: 18..).and(User.where(name: "matz").or(User.where(name: "nobu"))).to_sql },
50
- "block" => -> { User.where { (:age >= 18) & ((:name == "matz") | (:name == "nobu")) }.to_sql },
47
+ "string" => -> { User.where("age >= ? AND (name = ? OR name = ?)", 18, "alice", "bob").to_sql },
48
+ "arel" => -> { User.where(T[:age].gteq(18).and(T[:name].eq("alice").or(T[:name].eq("bob")))).to_sql },
49
+ "relation" => -> { User.where(age: 18..).and(User.where(name: "alice").or(User.where(name: "bob"))).to_sql },
50
+ "block" => -> { User.where { (:age >= 18) & ((:name == "alice") | (:name == "bob")) }.to_sql },
51
51
  },
52
52
  }
53
53
 
@@ -97,8 +97,8 @@ context = ActiveRecord::Refined::BlockContext.new
97
97
  syntax = ActiveRecord::Refined::BlockSyntax
98
98
 
99
99
  {
100
- "simple equality" => proc { :name == "matz" },
101
- "compound AND/OR" => proc { (:age >= 18) & ((:name == "matz") | (:name == "nobu")) },
100
+ "simple equality" => proc { :name == "alice" },
101
+ "compound AND/OR" => proc { (:age >= 18) & ((:name == "alice") | (:name == "bob")) },
102
102
  }.each do |label, blk|
103
103
  refined = blk.refined(syntax)
104
104
  context.instance_exec(&refined) # the copy is made here, on the first call
@@ -115,7 +115,7 @@ puts "1000 more calls from the same call site copied #{iseq_count.call - before}
115
115
 
116
116
  puts
117
117
  puts "=== where the block path spends its time ==="
118
- block = proc { :name == "matz" }
118
+ block = proc { :name == "alice" }
119
119
  refined_block = block.refined(ActiveRecord::Refined::BlockSyntax)
120
120
  context = ActiveRecord::Refined::BlockContext.new
121
121
  Benchmark.ips do |x|
@@ -16,11 +16,11 @@ Setup.new.up
16
16
  class Account < ActiveRecord::Base
17
17
  end
18
18
 
19
- Account.create!(login: 'matz', country: 'JP', age: 60)
20
- Account.create!(login: 'nobu', country: 'JP', age: 50)
21
- Account.create!(login: 'tenderlove', country: 'US', age: 45)
22
- Account.create!(login: '100%_pure', country: nil, age: 30)
23
- Account.create!(login: '1002000', country: 'US', age: 25)
19
+ Account.create!(login: 'alice', country: 'JP', age: 60)
20
+ Account.create!(login: 'bob', country: 'JP', age: 50)
21
+ Account.create!(login: 'carol', country: 'US', age: 45)
22
+ Account.create!(login: '100%_pure', country: nil, age: 30)
23
+ Account.create!(login: '1002000', country: 'US', age: 25)
24
24
 
25
25
  def show(title, relation, rows)
26
26
  puts "--- #{title} ---"
@@ -63,12 +63,12 @@ show('distinct_from? keeps NULLs, != drops them',
63
63
  # literals, so % and _ in them are escaped rather than matched as
64
64
  # wildcards. Note the last row matches only the literal-minded one.
65
65
  show('like? takes a pattern',
66
- Account.where { :login.like?('%love') },
67
- Account.where { :login.like?('%love') }.pluck(:login))
66
+ Account.where { :login.like?('%rol') },
67
+ Account.where { :login.like?('%rol') }.pluck(:login))
68
68
 
69
69
  show('start_with? takes any number of literals, like String#start_with?',
70
- Account.where { :login.start_with?('ma', 'no') },
71
- Account.where { :login.start_with?('ma', 'no') }.pluck(:login))
70
+ Account.where { :login.start_with?('al', 'bo') },
71
+ Account.where { :login.start_with?('al', 'bo') }.pluck(:login))
72
72
 
73
73
  # The % in the argument is escaped, so only the account whose login really
74
74
  # contains "100%" matches; the same pattern spelled with like? treats it as a
@@ -83,8 +83,8 @@ show('include? escapes wildcards; like? does not',
83
83
  # casecmp? folds both sides rather than trusting the collation, so it means
84
84
  # the same thing on every adapter.
85
85
  show('casecmp? is case-insensitive equality',
86
- Account.where { :login.casecmp?('MaTz') },
87
- Account.where { :login.casecmp?('MaTz') }.pluck(:login))
86
+ Account.where { :login.casecmp?('AlIcE') },
87
+ Account.where { :login.casecmp?('AlIcE') }.pluck(:login))
88
88
 
89
89
  # 4. Combining. & | ! build the tree; Ruby's precedence puts & and | above
90
90
  # the comparison operators, hence the parentheses around each comparison.
@@ -24,13 +24,13 @@ class Post < ActiveRecord::Base
24
24
  def self.published = where { :published == true }
25
25
  end
26
26
 
27
- matz = Author.create!(name: 'matz')
28
- nobu = Author.create!(name: 'nobu')
27
+ alice = Author.create!(name: 'alice')
28
+ bob = Author.create!(name: 'bob')
29
29
  quiet = Author.create!(name: 'quiet')
30
30
 
31
- Post.create!(title: 'refinements', author_id: matz.id, likes: 100, published: true)
32
- Post.create!(title: 'parser', author_id: matz.id, likes: 40, published: true)
33
- Post.create!(title: 'draft', author_id: nobu.id, likes: 5, published: false)
31
+ Post.create!(title: 'refinements', author_id: alice.id, likes: 100, published: true)
32
+ Post.create!(title: 'parser', author_id: alice.id, likes: 40, published: true)
33
+ Post.create!(title: 'draft', author_id: bob.id, likes: 5, published: false)
34
34
 
35
35
  def show(title, relation)
36
36
  puts "--- #{title} ---"
@@ -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 do |arg|
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: {}, ceil: {}, coalesce: {}, concat: {}, exp: {}, floor: {},
59
- length: {}, ln: {}, log: {}, lower: {}, ltrim: {}, mod: {},
60
- nullif: {}, power: {}, replace: {}, round: {}, rtrim: {}, sqrt: {},
61
- substr: {}, trim: {}, upper: {},
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) {|*args| AST::Function.new(function_name(name), args) }
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 = SCALAR_FUNCTIONS.fetch(name)
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,
@@ -1,5 +1,5 @@
1
1
  module Activerecord
2
2
  module Refined
3
- VERSION = '0.4.0'
3
+ VERSION = '0.5.0'
4
4
  end
5
5
  end
@@ -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" = 'matz'/, User.where { :name == 'matz' }.to_sql)
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" != 'nobu'/, User.where { :name != 'nobu' }.to_sql)
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" = 'matz' AND "users"."age" > 18/,
38
- User.where { (:name == 'matz') & (:age > 18) }.to_sql)
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\" = 'matz' OR \"users\".\"name\" = 'nobu'\)?/,
43
- User.where { (:name == 'matz') | (:name == 'nobu') }.to_sql)
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\" = 'matz'\)?/,
48
- User.where { !(:name == 'matz') }.to_sql)
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 == 'matz') & (:age > 18)) | !(:name == 'nobu') }.to_sql
53
- assert_sql(/"users"."name" = 'matz'/, sql)
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\('Matz'\)/,
74
- User.where { :name.casecmp?('Matz') }.to_sql)
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\('Matz'\)/,
79
- User.where { :users[:name].casecmp?('Matz') }.to_sql)
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: 'Matz')
90
- User.create!(name: 'nobu')
91
- assert_equal(['Matz'], User.where { :name.casecmp?('mAtZ') }.pluck(:name))
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 'ma%' ESCAPE '\\' OR "users"."name" LIKE 'no%' ESCAPE '\\'\)/,
119
- User.where { :name.start_with?('ma', 'no') }.to_sql)
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 'ma%' ESCAPE '\\' OR "users"."name" LIKE 'no%' ESCAPE '\\'\) AND "users"."age" > 18/,
132
- User.where { :name.start_with?('ma', 'no') & (:age > 18) }.to_sql)
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: 'matz')
353
+ User.create!(name: 'alice')
354
354
  User.create!(name: nil)
355
- assert_equal(['matz'], User.where { :name.not_distinct_from?('matz') }.pluck(:name))
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?('matz') }.pluck(:name))
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" = 'matz' AND EXISTS \(SELECT "posts"\.\* FROM "posts" WHERE "posts"."title" = 'pub'\)/,
425
- Author.where { (:name == 'matz') & exists?(Post.where(title: 'pub')) }.to_sql)
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" = 'matz'/,
481
- User.where { :users[:name] == 'matz' }.to_sql)
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: 'matz')
759
- User.create!(name: 'nobu')
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: 'matz', age: 60)
797
- assert_equal(['MATZ-x'], User.select { concat(upper(:name), '-x').as(:v) }.map(&:v))
798
- assert_equal([4], User.select { char_length(:name).as(:v) }.map(&:v))
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: 'matz')
830
- assert_equal(['matz!'], User.select { format('%s!', :name).as(:v) }.map(&:v))
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.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