activerecord-refined 0.5.0 → 0.5.1

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: ba52b9c3529288eeedb0f5ca06a28284669f5a89027d2e372155108f0447ecc1
4
- data.tar.gz: bb6d96021512a8891577781aa8e2abb14c2dc7cb8716423270ce0ccd074ed8c9
3
+ metadata.gz: 98f198f59d3bb4b6f4c51a16940d4e7ee44b890aa7e033a327a650b73cb3dfbd
4
+ data.tar.gz: 5fb7cdd8964b82d6bb14ffdfecf1400ea875f1c5bc12d7fa4645c431caf47d19
5
5
  SHA512:
6
- metadata.gz: db9e3ca29892b86f19af39e59e7a52ff987b187df8cb03249ab3456f5bc16ce7d6570b7309f77f28d9600e9aeb21f3c6874b27a63e86d0efeed13bc567f07d9a
7
- data.tar.gz: 3d65a461bab4a0c204702be0f532769c6fe7fafaa8106ea4e33994355b3fd3049ed2c5195f53460e9a9749007b5a133c7405ffdcd61485f3d07824e3fa686f9b
6
+ metadata.gz: 5ea7a29d21b31e3bf5f262a54eacb8e4e27590d9031f560d8de635da958cf91e6c5ef0872e77caf7b4e32907d2985f13fcd2ff8492ffbeda0edff1d8b7f658f4
7
+ data.tar.gz: 4949bb4ce9b0e4cd0f964e7f2759460a72707734a56d746cbf1221f80f9f7a87161c4a494c0226b880d7ad905e6c83933b29254c5ce3e59c41de415339557f56
@@ -129,7 +129,7 @@ jobs:
129
129
  sandbox/config.json
130
130
  ${{ steps.r2.outputs.uploaded != 'true' && 'sandbox/ruby.wasm' || '' }}
131
131
  sandbox/rb
132
- sandbox/vendor/browser.umd.js
132
+ sandbox/assets
133
133
  if-no-files-found: ignore
134
134
  retention-days: 7
135
135
 
data/README.md CHANGED
@@ -87,6 +87,24 @@ Author.where { :country.in?(%w[JP US]) } # IN
87
87
  Author.where { :country.null? } # IS NULL
88
88
  ```
89
89
 
90
+ `!` negates any of these. Where SQL has a negative of its own, so does the
91
+ block, which is the same rows written the way they would be written by hand:
92
+
93
+ ```ruby
94
+ Author.where { :country.not_null? } # IS NOT NULL
95
+ Author.where { :country.not_in?(%w[JP US]) } # NOT IN
96
+ Author.where { :age.not_between?(20, 40) } # not between 20 and 40
97
+ Author.where { :name.not_like?('A%') } # NOT LIKE
98
+ Author.where { :name.not_ilike?('a%') } # NOT ILIKE / NOT LIKE
99
+
100
+ Author.where { !:name.start_with?('A') } # NOT (name LIKE 'A%')
101
+ ```
102
+
103
+ Nothing turns on the choice: `NOT (country IS NULL)` and `country IS NOT NULL`
104
+ select the same rows, NULLs included. `not_between?` is the one whose SQL
105
+ looks unlike its name — Arel writes it as the two comparisons, `age < 20 OR
106
+ age > 40`, which is again the same rows.
107
+
90
108
  `in?` also takes a relation as a subquery. Without an explicit select list the
91
109
  subquery selects the relation's primary key, the same way ActiveRecord's own
92
110
  `where(id: relation)` does:
@@ -247,23 +265,53 @@ ActiveRecord's `with` and `with_recursive` need nothing from this gem: a CTE
247
265
  is joined by name like any other table, so its `ON` clause is a block, where
248
266
  Rails' own documentation reaches for a string join.
249
267
 
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:
268
+ `from_cte` takes the CTE's name and selects it under the model's own table
269
+ name, so the model's columns resolve:
252
270
 
253
271
  ```ruby
254
272
  Node.with_recursive(
255
273
  tree: [
256
- Node.where { :id == root.id },
257
- Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] },
274
+ Node.where { :id == root.id }.
275
+ select { [:id, :name, :parent_id, 0.as(:depth)] },
276
+ Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
277
+ select { [:nodes[:id], :nodes[:name], :nodes[:parent_id],
278
+ (:tree[:depth] + 1).as(:depth)] },
258
279
  ]
259
- ).from(:tree, as: :nodes)
280
+ ).from_cte(:tree)
260
281
  # WITH RECURSIVE "tree" AS (
261
- # SELECT "nodes".* FROM "nodes" WHERE "nodes"."id" = 1
282
+ # SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id", 0 AS depth
283
+ # FROM "nodes" WHERE "nodes"."id" = 1
262
284
  # UNION ALL
263
- # SELECT "nodes".* FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
285
+ # SELECT "nodes"."id", "nodes"."name", "nodes"."parent_id",
286
+ # ("tree"."depth" + 1) AS depth
287
+ # FROM "nodes" INNER JOIN "tree" ON "nodes"."parent_id" = "tree"."id"
264
288
  # ) SELECT "nodes".* FROM "tree" AS "nodes"
265
289
  ```
266
290
 
291
+ The anchor starts the count and the recursive member adds one, which is how
292
+ the shape of a tree comes out of a flat table. The `0` is a value rather than
293
+ SQL — see [`value`](#aggregates-functions-and-aliases) below for why a number
294
+ can say `.as` directly.
295
+
296
+ The alias on the last line is there for ActiveRecord's sake, not SQL's:
297
+ written by hand that line would be `SELECT * FROM tree`. ActiveRecord goes on qualifying
298
+ columns with the model's table name, so without the alias that name is not in
299
+ the query and anything qualifying a column fails:
300
+
301
+ ```ruby
302
+ Node.with_recursive(tree: [...]).from(:tree).where(name: 'root')
303
+ # PG::UndefinedTable: missing FROM-clause entry for table "nodes"
304
+ ```
305
+
306
+ Since the model's name is the only one that works, `from_cte` takes it from
307
+ the model rather than asking. `from(:tree, as: :nodes)` is the same thing
308
+ spelled out, and is what to reach for when the name wanted is not the model's.
309
+
310
+ What makes this worth spelling out is how selectively it breaks. `count`,
311
+ `order` and `select` never qualify, so they work without the alias on every
312
+ adapter; it is `where` and `find_by` that stop. A query can therefore look
313
+ right until the day a condition is added to it.
314
+
267
315
  A non-recursive CTE joins the same way:
268
316
 
269
317
  ```ruby
@@ -358,6 +406,29 @@ written into the SQL as given — so those two have to be plain names,
358
406
  optionally qualified by a schema in `fn`'s case. Anything else raises
359
407
  `ArgumentError` rather than reaching the query.
360
408
 
409
+ One place asks for a value to be said out loud: the top of a select list.
410
+ Everywhere else a bare literal is already a value — `where { :age > 18 }`,
411
+ `concat(:name, '-x')` — but ActiveRecord reads a string in `select` as SQL,
412
+ so `value` is how you ask for the other meaning. It carries the predications
413
+ and arithmetic with it, so a literal can be compared and combined like
414
+ anything else. Numbers have a shorthand, since nothing else could be meant by
415
+ one:
416
+
417
+ ```ruby
418
+ Node.select { [:id, value(0).as(:depth)] }
419
+ # SELECT "nodes"."id", 0 AS depth FROM "nodes"
420
+
421
+ Node.select { [:id, 0.as(:depth)] } # the same thing
422
+
423
+ Post.select { [:title, value('draft').as(:state)] }
424
+ # SELECT "posts"."title", 'draft' AS state FROM "posts"
425
+ ```
426
+
427
+ The shorthand is `Integer` and `Float` only. `String` keeps its two meanings —
428
+ SQL in a select list, a value everywhere else — and refining it would make the
429
+ same literal mean one thing or the other depending on whether it had been sent
430
+ a message.
431
+
361
432
  `fn` reaches functions without a method of their own. Its name is emitted as
362
433
  written, so a case-sensitive one can be spelled exactly:
363
434
 
@@ -13,8 +13,9 @@ Gem::Specification.new do |gem|
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
+ # package-lock.json have no business in anyone's bundle. CLAUDE.md is
17
+ # addressed to whoever is working on the repository, not to anyone using it.
18
+ gem.files = `git ls-files`.split($/).grep_v(%r{^sandbox/|^CLAUDE\.md$})
18
19
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
19
20
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
20
21
  gem.require_paths = ["lib"]
data/examples/ctes.rb CHANGED
@@ -31,23 +31,54 @@ Product.create!(name: 'apple', category_id: groceries.id, price: 2)
31
31
 
32
32
  # 1. Recursive CTE: every category below 'electronics', itself included.
33
33
  # The recursive member joins the CTE by name, so its ON clause is a block
34
- # rather than the string join Rails' own documentation reaches for. `from`
35
- # then selects the CTE under the model's table name, which is what lets
36
- # Category's own columns resolve against it.
34
+ # rather than the string join Rails' own documentation reaches for.
35
+ # `from_cte` then selects the CTE under the model's table name.
36
+ #
37
+ # That alias is ActiveRecord's requirement rather than SQL's: by hand the
38
+ # last line would be `SELECT * FROM tree`. ActiveRecord keeps qualifying
39
+ # columns with the model's table name, so without it `where` and `find_by`
40
+ # look for a table the query does not have. `count`, `order` and `select`
41
+ # never qualify and would work either way, which makes it easy to miss.
37
42
  subtree =
38
43
  Category.with_recursive(
39
44
  tree: [
40
45
  Category.where { :id == electronics.id },
41
46
  Category.joins(:tree) { :categories[:parent_id] == :tree[:id] },
42
47
  ]
43
- ).from(:tree, as: :categories)
48
+ ).from_cte(:tree)
44
49
 
45
50
  puts '--- 1. Recursive CTE walking a category tree ---'
46
51
  puts subtree.to_sql
47
52
  puts subtree.order { :name }.pluck(:name).inspect
48
53
  puts
49
54
 
50
- # 2. The same CTE as a subquery: products anywhere under 'electronics'.
55
+ # 2. One walk over every tree, carrying down where each row started and how
56
+ # far it has come. Which tree is wanted is then an ordinary `where`, asked
57
+ # afterwards, so the CTE is not rebuilt for each root. 0 is a value rather
58
+ # than SQL: at the top of a select list a bare string would be SQL, so
59
+ # numbers say `.as` directly and anything else says `value(...).as`.
60
+ forest =
61
+ Category.with_recursive(
62
+ tree: [
63
+ Category.where { :parent_id.null? }.
64
+ select { [:id, :name, :parent_id, :id.as(:root_id), 0.as(:depth)] },
65
+ Category.joins(:tree) { :categories[:parent_id] == :tree[:id] }.
66
+ select { [:categories[:id], :categories[:name], :categories[:parent_id],
67
+ :tree[:root_id], (:tree[:depth] + 1).as(:depth)] },
68
+ ]
69
+ ).from_cte(:tree).order { [:depth, :id] }
70
+
71
+ puts '--- 2. Recursive CTE carrying the root and the depth down ---'
72
+ puts forest.to_sql
73
+ puts forest.map {|c| [c.name, c.root_id, c.depth] }.inspect
74
+
75
+ # The alias from_cte puts on the CTE is what lets this `where` qualify
76
+ # root_id; without it the column would be looked for in a table the query no
77
+ # longer has.
78
+ puts forest.where { :root_id == electronics.id }.map {|c| [c.name, c.depth] }.inspect
79
+ puts
80
+
81
+ # 3. The same CTE as a subquery: products anywhere under 'electronics'.
51
82
  # The outer query joins the CTE by name like any other table.
52
83
  products_below =
53
84
  Product.with_recursive(
@@ -57,12 +88,12 @@ products_below =
57
88
  ]
58
89
  ).joins(:tree) { :tree[:id] == :products[:category_id] }
59
90
 
60
- puts '--- 2. Recursive CTE joined from the outer query ---'
91
+ puts '--- 3. Recursive CTE joined from the outer query ---'
61
92
  puts products_below.to_sql
62
93
  puts products_below.order { :name }.pluck(:name).inspect
63
94
  puts
64
95
 
65
- # 3. A plain CTE, named once and used twice: categories that hold something
96
+ # 4. A plain CTE, named once and used twice: categories that hold something
66
97
  # expensive, and the count of products in each.
67
98
  expensive =
68
99
  Category.with(pricey: Product.where { :price >= 100 }).
@@ -76,7 +107,7 @@ expensive =
76
107
  ]
77
108
  }
78
109
 
79
- puts '--- 3. Plain CTE joined and aggregated ---'
110
+ puts '--- 4. Plain CTE joined and aggregated ---'
80
111
  puts expensive.to_sql
81
112
  puts expensive.map {|c| [c.category, c.pricey_count, c.top_price] }.inspect
82
113
  puts
@@ -63,26 +63,50 @@ module ActiveRecord
63
63
  Match.new(self, pattern, negated: true)
64
64
  end
65
65
 
66
+ # `!` negates any predicate, so these are here for the four that SQL
67
+ # spells for itself: IS NOT NULL rather than NOT (... IS NULL), and
68
+ # likewise NOT IN and NOT LIKE. They mean the same thing either way,
69
+ # including when the column is NULL; what they save is the reading.
66
70
  def null?
67
71
  Comparison.new(self, :==, nil)
68
72
  end
69
73
 
74
+ def not_null?
75
+ Comparison.new(self, :!=, nil)
76
+ end
77
+
70
78
  def in?(values)
71
79
  In.new(self, values)
72
80
  end
73
81
 
82
+ def not_in?(values)
83
+ In.new(self, values, negated: true)
84
+ end
85
+
74
86
  def between?(min, max)
75
87
  In.new(self, min..max)
76
88
  end
77
89
 
90
+ def not_between?(min, max)
91
+ In.new(self, min..max, negated: true)
92
+ end
93
+
78
94
  def like?(pattern)
79
95
  Like.new(self, pattern)
80
96
  end
81
97
 
98
+ def not_like?(pattern)
99
+ Like.new(self, pattern, negated: true)
100
+ end
101
+
82
102
  def ilike?(pattern)
83
103
  Like.new(self, pattern, nil, case_sensitive: false)
84
104
  end
85
105
 
106
+ def not_ilike?(pattern)
107
+ Like.new(self, pattern, nil, case_sensitive: false, negated: true)
108
+ end
109
+
86
110
  # Case-insensitive equality, folded on both sides rather than left to
87
111
  # the collation, so it means the same thing on every adapter.
88
112
  def casecmp?(value)
@@ -246,6 +270,29 @@ module ActiveRecord
246
270
  end
247
271
  end
248
272
 
273
+ # A literal standing where an expression would: `select { value(0).as(:depth) }`.
274
+ #
275
+ # Values reach the SQL quoted wherever they appear as an operand, but the
276
+ # top of a select list is ActiveRecord's, and a bare string there is SQL
277
+ # rather than a string. Saying `value` is how you ask for the other
278
+ # meaning, and it carries the predications with it, so a literal can be
279
+ # compared and combined like anything else.
280
+ class Value < Node
281
+ include Predications
282
+ include Arithmetics
283
+ include Aggregations
284
+
285
+ attr_reader :value
286
+
287
+ def initialize(value)
288
+ @value = value
289
+ end
290
+
291
+ def to_arel(_table)
292
+ Arel::Nodes.build_quoted(value)
293
+ end
294
+ end
295
+
249
296
  class Column < Node
250
297
  include Predications
251
298
  include Arithmetics
@@ -482,19 +529,21 @@ module ActiveRecord
482
529
  # IN for a list of values, BETWEEN for a range, IN (SELECT ...) for a
483
530
  # relation.
484
531
  class In < Predicate
485
- attr_reader :operand, :values
532
+ attr_reader :operand, :values, :negated
486
533
 
487
- def initialize(operand, values)
534
+ def initialize(operand, values, negated: false)
488
535
  @operand = operand
489
536
  @values = values
537
+ @negated = negated
490
538
  end
491
539
 
492
540
  def to_arel(table)
493
541
  arel_operand = to_arel_operand(operand, table)
494
- case values
495
- when Range then arel_operand.between(values)
496
- when ActiveRecord::Relation then arel_operand.in(subquery(values))
497
- else arel_operand.in(values)
542
+ if values.is_a?(Range)
543
+ arel_operand.public_send(negated ? :not_between : :between, values)
544
+ else
545
+ arg = values.is_a?(ActiveRecord::Relation) ? subquery(values) : values
546
+ arel_operand.public_send(negated ? :not_in : :in, arg)
498
547
  end
499
548
  end
500
549
 
@@ -555,19 +604,23 @@ module ActiveRecord
555
604
  inject {|left, right| Or.new(left, right) }
556
605
  end
557
606
 
558
- attr_reader :operand, :pattern, :escape, :case_sensitive
607
+ attr_reader :operand, :pattern, :escape, :case_sensitive, :negated
559
608
 
560
- def initialize(operand, pattern, escape = nil, case_sensitive: true)
609
+ def initialize(operand, pattern, escape = nil, case_sensitive: true,
610
+ negated: false)
561
611
  @operand = operand
562
612
  @pattern = pattern
563
613
  @escape = escape
564
614
  @case_sensitive = case_sensitive
615
+ @negated = negated
565
616
  end
566
617
 
567
618
  def to_arel(table)
568
619
  # Arel matches case-insensitively unless told otherwise, which is
569
620
  # what picks ILIKE over LIKE on PostgreSQL.
570
- to_arel_operand(operand, table).matches(pattern, escape, case_sensitive)
621
+ to_arel_operand(operand, table).
622
+ public_send(negated ? :does_not_match : :matches,
623
+ pattern, escape, case_sensitive)
571
624
  end
572
625
  end
573
626
 
@@ -22,6 +22,18 @@ module ActiveRecord
22
22
  AST::Column.new(self, column_name)
23
23
  end
24
24
  end
25
+
26
+ # Shorthand for `value(0).as(:depth)` and the like. Numbers only: a
27
+ # string in a select list already means SQL rather than a string, so
28
+ # giving String this would make the same literal mean two things
29
+ # depending on whether it had been sent a message.
30
+ [Integer, Float].each do |klass|
31
+ refine klass do
32
+ def as(alias_name)
33
+ AST::As.new(AST::Value.new(self), alias_name)
34
+ end
35
+ end
36
+ end
25
37
  end
26
38
 
27
39
  class BlockContext
@@ -164,6 +176,17 @@ module ActiveRecord
164
176
  AST::Exists.new(relation)
165
177
  end
166
178
 
179
+ # A literal where an expression is expected, quoted like any other value:
180
+ #
181
+ # select { [:id, value(0).as(:depth)] }
182
+ #
183
+ # Needed because the top of a select list is ActiveRecord's, and a bare
184
+ # string there is SQL rather than a string. Numbers have a shorthand --
185
+ # `0.as(:depth)` -- since nothing else could be meant by one.
186
+ def value(literal)
187
+ AST::Value.new(literal)
188
+ end
189
+
167
190
  private
168
191
 
169
192
  def function_name(name, functions)
@@ -230,9 +253,8 @@ module ActiveRecord
230
253
  end
231
254
 
232
255
  # A symbol names a table, which ActiveRecord's own from only takes as a
233
- # string. With `as` it is selected under another name, which is how a
234
- # CTE stands in for the model's own table:
235
- # with_recursive(tree: [...]).from(:tree, as: :nodes)
256
+ # string. With `as` it is selected under another name; when that name
257
+ # is the model's own, from_cte says the same thing without repeating it.
236
258
  def from(value, subquery_name = nil, as: nil)
237
259
  unless value.is_a?(Symbol)
238
260
  if as
@@ -245,6 +267,18 @@ module ActiveRecord
245
267
  super(arel_table, subquery_name)
246
268
  end
247
269
 
270
+ # Selects a CTE in place of the model's own table. The alias is not a
271
+ # choice -- ActiveRecord keeps qualifying columns with the table name,
272
+ # so the model's is the only name that works -- which is why it is
273
+ # taken from the model rather than asked for:
274
+ # with_recursive(tree: [...]).from_cte(:tree)
275
+ def from_cte(name)
276
+ unless name.is_a?(Symbol)
277
+ raise ArgumentError, "from_cte takes the CTE's name as a symbol"
278
+ end
279
+ from(name, as: klass.table_name)
280
+ end
281
+
248
282
  # `as` names the table within the query, which is what makes a self
249
283
  # join expressible: joins(:employees, as: :managers) { ... }.
250
284
  def joins(*args, as: nil, &block)
@@ -1,5 +1,5 @@
1
1
  module Activerecord
2
2
  module Refined
3
- VERSION = '0.5.0'
3
+ VERSION = '0.5.1'
4
4
  end
5
5
  end
@@ -5,3 +5,7 @@ require 'active_record/refined/ast'
5
5
  require 'active_record/refined'
6
6
 
7
7
  ActiveRecord::QueryMethods.prepend ActiveRecord::Refined::QueryMethods
8
+
9
+ # The methods above are ActiveRecord's own, so a model already forwards them
10
+ # to its relation. from_cte is new, and has to be added to that list itself.
11
+ ActiveRecord::Base.singleton_class.delegate :from_cte, to: :all
@@ -91,11 +91,22 @@ class TestBlockSyntax < Minitest::Test
91
91
  assert_equal(['Alice'], User.where { :name.casecmp?('aLiCe') }.pluck(:name))
92
92
  end
93
93
 
94
- def test_not_like
94
+ def test_bang_negates_like
95
95
  assert_sql(/WHERE NOT \("users"."name" LIKE 'tender%'\)/,
96
96
  User.where { !:name.like?('tender%') }.to_sql)
97
97
  end
98
98
 
99
+ def test_not_like
100
+ assert_sql(/WHERE "users"."name" NOT LIKE 'tender%'/,
101
+ User.where { :name.not_like?('tender%') }.to_sql)
102
+ end
103
+
104
+ def test_not_ilike
105
+ expected = ADAPTER == 'postgresql' ? 'ILIKE' : 'LIKE'
106
+ assert_sql(/WHERE "users"."name" NOT #{expected} 'tender%'/,
107
+ User.where { :name.not_ilike?('tender%') }.to_sql)
108
+ end
109
+
99
110
  def test_start_with
100
111
  assert_sql(/WHERE "users"."name" LIKE 'tender%' ESCAPE '\\'/,
101
112
  User.where { :name.start_with?('tender') }.to_sql)
@@ -298,11 +309,23 @@ class TestBlockSyntax < Minitest::Test
298
309
  User.where { :age.in?(18...65) }.to_sql)
299
310
  end
300
311
 
301
- def test_not_between
312
+ def test_bang_negates_between
302
313
  assert_sql(/WHERE NOT \("users"."age" BETWEEN 18 AND 65\)/,
303
314
  User.where { !:age.between?(18, 65) }.to_sql)
304
315
  end
305
316
 
317
+ # Arel spells the negation as the two comparisons rather than NOT BETWEEN,
318
+ # which is the same set of rows, NULLs included.
319
+ def test_not_between
320
+ assert_sql(/WHERE \("users"."age" < 18 OR "users"."age" > 65\)/,
321
+ User.where { :age.not_between?(18, 65) }.to_sql)
322
+ end
323
+
324
+ def test_not_in_range
325
+ assert_sql(/WHERE \("users"."age" < 18 OR "users"."age" > 65\)/,
326
+ User.where { :age.not_in?(18..65) }.to_sql)
327
+ end
328
+
306
329
  def test_is_null
307
330
  assert_sql(/WHERE "users"."name" IS NULL/,
308
331
  User.where { :name.null? }.to_sql)
@@ -313,6 +336,36 @@ class TestBlockSyntax < Minitest::Test
313
336
  User.where { :users[:name].null? }.to_sql)
314
337
  end
315
338
 
339
+ def test_is_not_null
340
+ assert_sql(/WHERE "users"."name" IS NOT NULL/,
341
+ User.where { :name.not_null? }.to_sql)
342
+ end
343
+
344
+ def test_is_not_null_qualified
345
+ assert_sql(/WHERE "users"."name" IS NOT NULL/,
346
+ User.where { :users[:name].not_null? }.to_sql)
347
+ end
348
+
349
+ # The claim these methods rest on: the direct spelling is the same rows as
350
+ # negating the positive one, which is where a NULL would show a difference
351
+ # if there were one.
352
+ def test_the_negations_match_what_bang_selects
353
+ User.delete_all
354
+ User.create!(name: 'alice', age: 60)
355
+ User.create!(name: 'bob', age: 20)
356
+ User.create!(name: nil, age: 40)
357
+ [
358
+ [-> { :name.not_null? }, -> { !:name.null? }],
359
+ [-> { :age.not_in?([20, 30]) }, -> { !:age.in?([20, 30]) }],
360
+ [-> { :age.not_between?(20, 30) }, -> { !:age.between?(20, 30) }],
361
+ [-> { :name.not_like?('a%') }, -> { !:name.like?('a%') }],
362
+ ].each do |direct, negated|
363
+ assert_equal(User.where(&negated).pluck(:id).sort,
364
+ User.where(&direct).pluck(:id).sort,
365
+ "#{direct.source_location} did not match the ! form")
366
+ end
367
+ end
368
+
316
369
  def test_equal_nil_is_rejected
317
370
  e = assert_raises(ArgumentError) { User.where { :name == nil } }
318
371
  assert_match(/null\?/, e.message)
@@ -333,11 +386,21 @@ class TestBlockSyntax < Minitest::Test
333
386
  User.where { :users[:age].in?([1, 2, 3]) }.to_sql)
334
387
  end
335
388
 
336
- def test_not_in
389
+ def test_bang_negates_in
337
390
  assert_sql(/WHERE NOT \("users"."age" IN \(1, 2, 3\)\)/,
338
391
  User.where { !:age.in?([1, 2, 3]) }.to_sql)
339
392
  end
340
393
 
394
+ def test_not_in
395
+ assert_sql(/WHERE "users"."age" NOT IN \(1, 2, 3\)/,
396
+ User.where { :age.not_in?([1, 2, 3]) }.to_sql)
397
+ end
398
+
399
+ def test_not_in_qualified
400
+ assert_sql(/WHERE "users"."age" NOT IN \(1, 2, 3\)/,
401
+ User.where { :users[:age].not_in?([1, 2, 3]) }.to_sql)
402
+ end
403
+
341
404
  # Spelled IS [NOT] DISTINCT FROM on PostgreSQL, IS / IS NOT on SQLite and
342
405
  # <=> on MySQL, so only the resulting rows are portable.
343
406
  def test_not_distinct_from_execution
@@ -547,6 +610,36 @@ class TestBlockSyntax < Minitest::Test
547
610
  assert_raises(ArgumentError) { Node.from('tree', as: :nodes) }
548
611
  end
549
612
 
613
+ def test_from_cte_takes_the_alias_from_the_model
614
+ assert_sql(/FROM "tree" (?:AS )?"nodes"/, Node.from_cte(:tree).to_sql)
615
+ assert_equal(Node.from(:tree, as: :nodes).to_sql, Node.from_cte(:tree).to_sql)
616
+ end
617
+
618
+ def test_from_cte_needs_a_symbol
619
+ assert_raises(ArgumentError) { Node.from_cte('tree') }
620
+ end
621
+
622
+ # The alias is what lets a where find its column, which is the whole reason
623
+ # from_cte exists; without it the SQL names a table the query does not have.
624
+ def test_from_cte_leaves_where_able_to_qualify
625
+ Node.delete_all
626
+ root = Node.create!(name: 'root')
627
+ Node.create!(name: 'child', parent_id: root.id)
628
+ other = Node.create!(name: 'other root')
629
+ Node.create!(name: 'other child', parent_id: other.id)
630
+ forest = Node.with_recursive(
631
+ tree: [
632
+ Node.where { :parent_id.null? }.
633
+ select { [:id, :name, :parent_id, :id.as(:root_id)] },
634
+ Node.joins(:tree) { :nodes[:parent_id] == :tree[:id] }.
635
+ select { [:nodes[:id], :nodes[:name], :nodes[:parent_id],
636
+ :tree[:root_id]] },
637
+ ]
638
+ ).from_cte(:tree)
639
+ assert_equal(%w[child root],
640
+ forest.where { :root_id == root.id }.pluck(:name).sort)
641
+ end
642
+
550
643
  # A CTE is joined by name like any other table, so the recursive member's
551
644
  # ON clause is a block rather than the string join Rails' own docs use.
552
645
  def test_recursive_cte
@@ -1179,4 +1272,71 @@ class TestBlockSyntax < Minitest::Test
1179
1272
  assert_sql(/WHERE "users"."name" = 'Ruby' AND "users"."age" = 19/,
1180
1273
  User.where(name: 'Ruby', age: 19).to_sql)
1181
1274
  end
1275
+
1276
+ def test_value_in_a_select_list
1277
+ assert_sql(/SELECT "users"."name", 0 AS depth/,
1278
+ User.select { [:name, value(0).as(:depth)] }.to_sql)
1279
+ end
1280
+
1281
+ # Each adapter escapes the apostrophe its own way, so what is asserted is
1282
+ # that the string stays a value rather than reaching the SQL as written.
1283
+ def test_value_is_quoted
1284
+ User.delete_all
1285
+ User.create!(name: 'alice')
1286
+ payload = "it's a value"
1287
+ assert_sql(/SELECT 'draft' AS state/,
1288
+ User.select { value('draft').as(:state) }.to_sql)
1289
+ assert_equal([payload],
1290
+ User.select { value(payload).as(:note) }.map(&:note))
1291
+ end
1292
+
1293
+ def test_a_bare_string_is_still_sql
1294
+ assert_sql(/SELECT "users"."name", 1 \+ 1 AS two/,
1295
+ User.select { [:name, '1 + 1 AS two'] }.to_sql)
1296
+ end
1297
+
1298
+ def test_value_takes_the_predications
1299
+ assert_sql(/WHERE 1 = "users"."age"/, User.where { value(1) == :users[:age] }.to_sql)
1300
+ assert_sql(/WHERE 1 IS NULL/, User.where { value(1).null? }.to_sql)
1301
+ end
1302
+
1303
+ def test_value_takes_the_arithmetics
1304
+ assert_sql(/SELECT \(1 \+ "users"."age"\) AS next_year/,
1305
+ User.select { (value(1) + :age).as(:next_year) }.to_sql)
1306
+ end
1307
+
1308
+ def test_value_as_a_function_argument
1309
+ assert_sql(/SELECT COALESCE\("users"."age", 0\)/,
1310
+ User.select { coalesce(:age, value(0)) }.to_sql)
1311
+ end
1312
+
1313
+ def test_integer_shorthand_for_value
1314
+ assert_sql(/SELECT "users"."name", 0 AS depth/,
1315
+ User.select { [:name, 0.as(:depth)] }.to_sql)
1316
+ end
1317
+
1318
+ def test_float_shorthand_for_value
1319
+ assert_sql(/SELECT 1\.5 AS rate/, User.select { 1.5.as(:rate) }.to_sql)
1320
+ end
1321
+
1322
+ def test_numeric_shorthand_has_no_orderings
1323
+ assert_raises(NoMethodError) { User.order { 1.asc } }
1324
+ assert_raises(NoMethodError) { User.order { 1.desc } }
1325
+ end
1326
+
1327
+ def test_value_alias_rejects_an_injected_name
1328
+ assert_raises(ArgumentError) { User.select { value(0).as(INJECTION.to_sym) } }
1329
+ assert_raises(ArgumentError) { User.select { 0.as(INJECTION.to_sym) } }
1330
+ end
1331
+
1332
+ def test_a_value_selected_reaches_the_row
1333
+ User.delete_all
1334
+ User.create!(name: 'alice', age: 60)
1335
+ assert_equal([['alice', 0]],
1336
+ User.select { [:name, 0.as(:depth)] }.map {|u| [u.name, u.depth] })
1337
+ end
1338
+
1339
+ def test_numeric_shorthand_is_confined_to_the_block
1340
+ assert_raises(NoMethodError) { 0.as(:depth) }
1341
+ end
1182
1342
  end
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.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shugo Maeda