activerecord-refined 0.3.2 → 0.3.3
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/README.md +50 -13
- data/lib/active_record/refined/ast.rb +109 -5
- data/lib/active_record/refined.rb +4 -0
- data/lib/activerecord-refined/version.rb +1 -1
- data/test/test_block_syntax.rb +153 -0
- data/test/test_helper.rb +9 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f20876b4eb0b36ece84fc2f6a1c828461846b29bd829e279d31b22735cbf0c15
|
|
4
|
+
data.tar.gz: 4a717ffaf4991fa3a113ea4a6a619b16d431e8308e1869e22c988885022d450a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56734a22e7667270892abf1d6ab9fb0816a37043c5f1fe61a7367c40403f782af6dff7875b0f520ada5aef6ce89155f39919414a71d3abec279ccf3c538db578
|
|
7
|
+
data.tar.gz: f0aa350bbf6b80d1e36b156d2e997930195f2fcfe99c53ae449f3d0f44efe3870671c45bae52a7eac5f39c5f39adf129cbe5a1a1d3776b07eb8d29b1cfa470d2
|
data/README.md
CHANGED
|
@@ -78,6 +78,27 @@ Author.where { :country.in?(%w[JP US]) } # IN
|
|
|
78
78
|
Author.where { :country.null? } # IS NULL
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
`in?` also takes a relation as a subquery. Without an explicit select list the
|
|
82
|
+
subquery selects the relation's primary key, the same way ActiveRecord's own
|
|
83
|
+
`where(id: relation)` does:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
Author.where { :id.in?(Post.published.select(:author_id)) }
|
|
87
|
+
# "authors"."id" IN (SELECT "posts"."author_id" FROM "posts" WHERE ...)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`exists?` takes a relation and becomes `EXISTS (SELECT ...)`. Correlate the
|
|
91
|
+
subquery with the outer table through qualified columns — its `where` block
|
|
92
|
+
goes through the DSL like any other:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
Author.where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
|
|
96
|
+
# EXISTS (SELECT "posts".* FROM "posts" WHERE "posts"."author_id" = "authors"."id")
|
|
97
|
+
|
|
98
|
+
Author.where { !exists?(Post.where { :posts[:author_id] == :authors[:id] }) }
|
|
99
|
+
# NOT (EXISTS (...))
|
|
100
|
+
```
|
|
101
|
+
|
|
81
102
|
`like?` is case-sensitive `LIKE` on every adapter, including PostgreSQL, where
|
|
82
103
|
Arel would otherwise reach for `ILIKE`.
|
|
83
104
|
|
|
@@ -91,6 +112,25 @@ Author.where { :name.end_with?('son') } # LIKE '%son'
|
|
|
91
112
|
Author.where { :name.include?('test') } # LIKE '%test%'
|
|
92
113
|
```
|
|
93
114
|
|
|
115
|
+
Like their String namesakes, `start_with?` and `end_with?` take any number of
|
|
116
|
+
literals; matching any one of them is enough:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
Author.where { :name.start_with?('A', 'B') }
|
|
120
|
+
# (name LIKE 'A%' OR name LIKE 'B%')
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`member?` tests containment in a PostgreSQL array column. The two flavors of
|
|
124
|
+
"does it contain this?" split by name the way Ruby's own classes do: `include?`
|
|
125
|
+
is String's substring match, `member?` is Enumerable's element test, which
|
|
126
|
+
String does not have. Pass an array to require every element:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
Article.where { :tags.member?('ruby') } # "tags" @> '{ruby}'
|
|
130
|
+
Article.where { :tags.member?(%w[ruby rails]) } # "tags" @> '{ruby,rails}'
|
|
131
|
+
Article.where { :scores.member?(80) } # "scores" @> '{80}'
|
|
132
|
+
```
|
|
133
|
+
|
|
94
134
|
`=~` and `!~` match a regular expression: `REGEXP` and `NOT REGEXP` on MySQL,
|
|
95
135
|
`~` and `!~` on PostgreSQL. SQLite has no regexp operator of its own, so it
|
|
96
136
|
raises there.
|
|
@@ -115,6 +155,14 @@ Reservation.where { :period == (from...to) } # daterange = '[from,to)'
|
|
|
115
155
|
Article.where { :tags == %w[ruby rails] } # text[] = '{ruby,rails}'
|
|
116
156
|
```
|
|
117
157
|
|
|
158
|
+
For the same reason `== nil` raises `ArgumentError`: `= NULL` is never true in
|
|
159
|
+
SQL, so a NULL test has to be spelled as one. Use `null?`:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
Author.where { :country.null? } # country IS NULL
|
|
163
|
+
Author.where { !:country.null? } # NOT (country IS NULL)
|
|
164
|
+
```
|
|
165
|
+
|
|
118
166
|
Combine predicates with `&`, `|` and `!`. Ruby's operator precedence makes the
|
|
119
167
|
parentheses around each comparison necessary, though the `?` methods above need
|
|
120
168
|
none:
|
|
@@ -202,21 +250,10 @@ and publishes it through RubyGems.org's trusted publishing, so no API key is
|
|
|
202
250
|
stored anywhere.
|
|
203
251
|
|
|
204
252
|
```sh
|
|
205
|
-
|
|
206
|
-
git
|
|
207
|
-
git push origin v0.4.0
|
|
253
|
+
bump patch --tag # or bump {major,minor} etc.
|
|
254
|
+
git push --follow-tags
|
|
208
255
|
```
|
|
209
256
|
|
|
210
|
-
This needs a trusted publisher registered once at
|
|
211
|
-
<https://rubygems.org/gems/activerecord-refined/trusted_publishers>:
|
|
212
|
-
|
|
213
|
-
| Field | Value |
|
|
214
|
-
| --- | --- |
|
|
215
|
-
| Repository owner | `shugo` |
|
|
216
|
-
| Repository name | `activerecord-refined` |
|
|
217
|
-
| Workflow filename | `push_gem.yml` |
|
|
218
|
-
| Environment | `release` |
|
|
219
|
-
|
|
220
257
|
## Contributing
|
|
221
258
|
|
|
222
259
|
1. Fork it
|
|
@@ -5,11 +5,20 @@ module ActiveRecord
|
|
|
5
5
|
# expressions. Imported into the Symbol refinement with
|
|
6
6
|
# Refinement#import_methods, so every method must be defined with def.
|
|
7
7
|
module Predications
|
|
8
|
+
# == and != mean SQL = and <>, and = NULL is never true there, so nil
|
|
9
|
+
# is rejected rather than silently rewritten to IS NULL. null? builds
|
|
10
|
+
# its node directly and stays clear of this check.
|
|
8
11
|
def ==(other)
|
|
12
|
+
if other.nil?
|
|
13
|
+
raise ArgumentError, "== does not take nil; use null? instead"
|
|
14
|
+
end
|
|
9
15
|
Comparison.new(self, :==, other)
|
|
10
16
|
end
|
|
11
17
|
|
|
12
18
|
def !=(other)
|
|
19
|
+
if other.nil?
|
|
20
|
+
raise ArgumentError, "!= does not take nil; use !null? instead"
|
|
21
|
+
end
|
|
13
22
|
Comparison.new(self, :!=, other)
|
|
14
23
|
end
|
|
15
24
|
|
|
@@ -53,17 +62,27 @@ module ActiveRecord
|
|
|
53
62
|
Like.new(self, pattern)
|
|
54
63
|
end
|
|
55
64
|
|
|
56
|
-
def start_with?(
|
|
57
|
-
|
|
65
|
+
def start_with?(*prefixes)
|
|
66
|
+
if prefixes.empty?
|
|
67
|
+
raise ArgumentError, "start_with? needs at least one prefix"
|
|
68
|
+
end
|
|
69
|
+
Like.any(self, prefixes.map {|prefix| "#{Like.escape(prefix)}%" })
|
|
58
70
|
end
|
|
59
71
|
|
|
60
|
-
def end_with?(
|
|
61
|
-
|
|
72
|
+
def end_with?(*suffixes)
|
|
73
|
+
if suffixes.empty?
|
|
74
|
+
raise ArgumentError, "end_with? needs at least one suffix"
|
|
75
|
+
end
|
|
76
|
+
Like.any(self, suffixes.map {|suffix| "%#{Like.escape(suffix)}" })
|
|
62
77
|
end
|
|
63
78
|
|
|
64
79
|
def include?(substring)
|
|
65
80
|
Like.new(self, "%#{Like.escape(substring)}%", Like::ESCAPE)
|
|
66
81
|
end
|
|
82
|
+
|
|
83
|
+
def member?(element)
|
|
84
|
+
Member.new(self, element)
|
|
85
|
+
end
|
|
67
86
|
end
|
|
68
87
|
|
|
69
88
|
class Node
|
|
@@ -215,7 +234,8 @@ module ActiveRecord
|
|
|
215
234
|
end
|
|
216
235
|
end
|
|
217
236
|
|
|
218
|
-
# IN for a list of values, BETWEEN for a range
|
|
237
|
+
# IN for a list of values, BETWEEN for a range, IN (SELECT ...) for a
|
|
238
|
+
# relation.
|
|
219
239
|
class In < Predicate
|
|
220
240
|
attr_reader :operand, :values
|
|
221
241
|
|
|
@@ -228,9 +248,49 @@ module ActiveRecord
|
|
|
228
248
|
arel_operand = to_arel_operand(operand, table)
|
|
229
249
|
case values
|
|
230
250
|
when Range then arel_operand.between(values)
|
|
251
|
+
when ActiveRecord::Relation then arel_operand.in(subquery(values))
|
|
231
252
|
else arel_operand.in(values)
|
|
232
253
|
end
|
|
233
254
|
end
|
|
255
|
+
|
|
256
|
+
private
|
|
257
|
+
|
|
258
|
+
# The same treatment ActiveRecord's own RelationHandler gives a
|
|
259
|
+
# relation used as a value: without an explicit select list the
|
|
260
|
+
# subquery selects the model's primary key.
|
|
261
|
+
def subquery(relation)
|
|
262
|
+
if relation.eager_loading?
|
|
263
|
+
relation = relation.send(:apply_join_dependency)
|
|
264
|
+
end
|
|
265
|
+
if relation.select_values.empty?
|
|
266
|
+
model = relation.model
|
|
267
|
+
if model.composite_primary_key?
|
|
268
|
+
raise ArgumentError,
|
|
269
|
+
"Cannot map composite primary key #{model.primary_key} to IN"
|
|
270
|
+
end
|
|
271
|
+
relation = relation.select(relation.table[model.primary_key])
|
|
272
|
+
end
|
|
273
|
+
relation.arel
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# EXISTS (SELECT ...) for a relation. Correlate the subquery with the
|
|
278
|
+
# outer table through qualified columns. EXISTS only asks whether a row
|
|
279
|
+
# comes back, so unlike In there is no select list to fix up.
|
|
280
|
+
class Exists < Predicate
|
|
281
|
+
attr_reader :relation
|
|
282
|
+
|
|
283
|
+
def initialize(relation)
|
|
284
|
+
@relation = relation
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def to_arel(_table)
|
|
288
|
+
subquery = relation
|
|
289
|
+
if subquery.eager_loading?
|
|
290
|
+
subquery = subquery.send(:apply_join_dependency)
|
|
291
|
+
end
|
|
292
|
+
subquery.arel.exists
|
|
293
|
+
end
|
|
234
294
|
end
|
|
235
295
|
|
|
236
296
|
class Like < Predicate
|
|
@@ -243,6 +303,13 @@ module ActiveRecord
|
|
|
243
303
|
ActiveRecord::Base.sanitize_sql_like(string, ESCAPE)
|
|
244
304
|
end
|
|
245
305
|
|
|
306
|
+
# ORs one LIKE per pattern, for the shortcuts that accept several
|
|
307
|
+
# literals the way String#start_with? does.
|
|
308
|
+
def self.any(operand, patterns)
|
|
309
|
+
patterns.map {|pattern| new(operand, pattern, ESCAPE) }.
|
|
310
|
+
inject {|left, right| Or.new(left, right) }
|
|
311
|
+
end
|
|
312
|
+
|
|
246
313
|
attr_reader :operand, :pattern, :escape
|
|
247
314
|
|
|
248
315
|
def initialize(operand, pattern, escape = nil)
|
|
@@ -258,6 +325,43 @@ module ActiveRecord
|
|
|
258
325
|
end
|
|
259
326
|
end
|
|
260
327
|
|
|
328
|
+
# Containment in a PostgreSQL array column. The two flavors of "does it
|
|
329
|
+
# contain this?" split by name the way Ruby's own classes do: include? is
|
|
330
|
+
# String's substring match (LIKE), member? is Enumerable's element test,
|
|
331
|
+
# which String does not have. The elements are rendered as an array
|
|
332
|
+
# literal, which PostgreSQL coerces to the column's element type, so any
|
|
333
|
+
# expression works as the operand and no schema lookup is needed.
|
|
334
|
+
class Member < Predicate
|
|
335
|
+
attr_reader :operand, :elements
|
|
336
|
+
|
|
337
|
+
def initialize(operand, element)
|
|
338
|
+
@operand = operand
|
|
339
|
+
@elements = element.is_a?(::Array) ? element : [element]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def to_arel(table)
|
|
343
|
+
arel_operand = to_arel_operand(operand, table)
|
|
344
|
+
arel_operand.contains(Arel::Nodes.build_quoted(array_literal))
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
private
|
|
348
|
+
|
|
349
|
+
# PostgreSQL array input syntax: elements joined by commas inside
|
|
350
|
+
# braces, and an element is double-quoted whenever it is empty, spells
|
|
351
|
+
# NULL, or contains a character the parser treats specially.
|
|
352
|
+
def array_literal
|
|
353
|
+
encoded = elements.map do |value|
|
|
354
|
+
s = value.to_s
|
|
355
|
+
if s.empty? || s.casecmp?("null") || s.match?(/[\s{},"\\]/)
|
|
356
|
+
"\"#{s.gsub(/["\\]/) {|c| "\\#{c}" }}\""
|
|
357
|
+
else
|
|
358
|
+
s
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
"{#{encoded.join(',')}}"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
261
365
|
# Regular expression match: REGEXP on MySQL, ~ on PostgreSQL. SQLite has
|
|
262
366
|
# no regexp operator built in, so Arel raises NotImplementedError there.
|
|
263
367
|
class Match < Predicate
|
data/test/test_block_syntax.rb
CHANGED
|
@@ -81,6 +81,35 @@ class TestBlockSyntax < Minitest::Test
|
|
|
81
81
|
User.where { :name.include?('der') }.to_sql)
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Like their String namesakes, start_with? and end_with? take any number
|
|
85
|
+
# of literals; matching any one of them is enough.
|
|
86
|
+
def test_start_with_multiple
|
|
87
|
+
assert_sql(
|
|
88
|
+
/WHERE \("users"."name" LIKE 'ma%' ESCAPE '\\' OR "users"."name" LIKE 'no%' ESCAPE '\\'\)/,
|
|
89
|
+
User.where { :name.start_with?('ma', 'no') }.to_sql)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_end_with_multiple
|
|
93
|
+
assert_sql(
|
|
94
|
+
/WHERE \("users"."name" LIKE '%z' ESCAPE '\\' OR "users"."name" LIKE '%love' ESCAPE '\\'\)/,
|
|
95
|
+
User.where { :name.end_with?('z', 'love') }.to_sql)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The OR arrives grouped, so a following & applies to the whole list.
|
|
99
|
+
def test_start_with_multiple_combined
|
|
100
|
+
assert_sql(
|
|
101
|
+
/WHERE \("users"."name" LIKE 'ma%' ESCAPE '\\' OR "users"."name" LIKE 'no%' ESCAPE '\\'\) AND "users"."age" > 18/,
|
|
102
|
+
User.where { :name.start_with?('ma', 'no') & (:age > 18) }.to_sql)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def test_start_with_no_arguments
|
|
106
|
+
assert_raises(ArgumentError) { User.where { :name.start_with? } }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def test_end_with_no_arguments
|
|
110
|
+
assert_raises(ArgumentError) { User.where { :name.end_with? } }
|
|
111
|
+
end
|
|
112
|
+
|
|
84
113
|
def test_start_with_escapes_wildcards
|
|
85
114
|
assert_sql(/WHERE "users"."name" LIKE '100\\%\\_%' ESCAPE '\\'/,
|
|
86
115
|
User.where { :name.start_with?('100%_') }.to_sql)
|
|
@@ -91,6 +120,56 @@ class TestBlockSyntax < Minitest::Test
|
|
|
91
120
|
User.where { :name.include?('100%') }.to_sql)
|
|
92
121
|
end
|
|
93
122
|
|
|
123
|
+
def test_member
|
|
124
|
+
assert_sql(/WHERE "users"."tags" @> '\{ruby\}'/,
|
|
125
|
+
User.where { :tags.member?('ruby') }.to_sql)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def test_member_qualified
|
|
129
|
+
assert_sql(/WHERE "users"."tags" @> '\{ruby\}'/,
|
|
130
|
+
User.where { :users[:tags].member?('ruby') }.to_sql)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def test_member_negated
|
|
134
|
+
assert_sql(/WHERE NOT \("users"."tags" @> '\{ruby\}'\)/,
|
|
135
|
+
User.where { !:tags.member?('ruby') }.to_sql)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def test_member_multiple_elements
|
|
139
|
+
assert_sql(/WHERE "users"."tags" @> '\{ruby,rails\}'/,
|
|
140
|
+
User.where { :tags.member?(%w[ruby rails]) }.to_sql)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# MySQL additionally escapes the double quotes inside its string literal,
|
|
144
|
+
# so the exact spelling is only asserted where the operator is real.
|
|
145
|
+
def test_member_quotes_special_elements
|
|
146
|
+
skip_without_array_columns
|
|
147
|
+
assert_sql(/WHERE "users"."tags" @> '\{"with,comma"\}'/,
|
|
148
|
+
User.where { :tags.member?('with,comma') }.to_sql)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# include? is a substring match even on an array column; only member?
|
|
152
|
+
# means containment.
|
|
153
|
+
def test_include_is_like_even_on_array_columns
|
|
154
|
+
skip_without_array_columns
|
|
155
|
+
assert_sql(/WHERE "users"."tags" LIKE '%ruby%' ESCAPE '\\'/,
|
|
156
|
+
User.where { :tags.include?('ruby') }.to_sql)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Elements survive the trip through the array literal: % is an ordinary
|
|
160
|
+
# character there, a comma stays inside its element, and quotes and
|
|
161
|
+
# backslashes are escaped.
|
|
162
|
+
def test_member_matches_elements_literally
|
|
163
|
+
skip_without_array_columns
|
|
164
|
+
User.delete_all
|
|
165
|
+
User.create!(name: 'literal', tags: ['100%', 'with,comma', 'q"uote', 'back\\slash'])
|
|
166
|
+
User.create!(name: 'lookalike', tags: ['100200', 'with', 'comma'])
|
|
167
|
+
assert_equal(['literal'], User.where { :tags.member?('100%') }.pluck(:name))
|
|
168
|
+
assert_equal(['literal'], User.where { :tags.member?('with,comma') }.pluck(:name))
|
|
169
|
+
assert_equal(['literal'], User.where { :tags.member?('q"uote') }.pluck(:name))
|
|
170
|
+
assert_equal(['literal'], User.where { :tags.member?('back\\slash') }.pluck(:name))
|
|
171
|
+
end
|
|
172
|
+
|
|
94
173
|
def test_regexp
|
|
95
174
|
skip_without_regexp_support
|
|
96
175
|
assert_sql(/WHERE "users"."name" #{regexp_operator} '\^ma'/,
|
|
@@ -161,6 +240,16 @@ class TestBlockSyntax < Minitest::Test
|
|
|
161
240
|
User.where { :users[:name].null? }.to_sql)
|
|
162
241
|
end
|
|
163
242
|
|
|
243
|
+
def test_equal_nil_is_rejected
|
|
244
|
+
e = assert_raises(ArgumentError) { User.where { :name == nil } }
|
|
245
|
+
assert_match(/null\?/, e.message)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def test_not_equal_nil_is_rejected
|
|
249
|
+
e = assert_raises(ArgumentError) { User.where { :name != nil } }
|
|
250
|
+
assert_match(/null\?/, e.message)
|
|
251
|
+
end
|
|
252
|
+
|
|
164
253
|
def test_in
|
|
165
254
|
assert_sql(/WHERE "users"."age" IN \(1, 2, 3\)/,
|
|
166
255
|
User.where { :age.in?([1, 2, 3]) }.to_sql)
|
|
@@ -176,6 +265,70 @@ class TestBlockSyntax < Minitest::Test
|
|
|
176
265
|
User.where { !:age.in?([1, 2, 3]) }.to_sql)
|
|
177
266
|
end
|
|
178
267
|
|
|
268
|
+
def test_in_subquery
|
|
269
|
+
assert_sql(
|
|
270
|
+
/WHERE "authors"."id" IN \(SELECT "posts"."author_id" FROM "posts" WHERE "posts"."title" = 'pub'\)/,
|
|
271
|
+
Author.where { :id.in?(Post.where(title: 'pub').select(:author_id)) }.to_sql)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# A relation without an explicit select list selects its primary key, the
|
|
275
|
+
# same way ActiveRecord's own where(id: relation) does.
|
|
276
|
+
def test_in_subquery_selects_primary_key_by_default
|
|
277
|
+
assert_sql(/WHERE "authors"."id" IN \(SELECT "posts"."id" FROM "posts"\)/,
|
|
278
|
+
Author.where { :id.in?(Post.all) }.to_sql)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def test_not_in_subquery
|
|
282
|
+
assert_sql(/WHERE NOT \("authors"."id" IN \(SELECT "posts"."author_id" FROM "posts"\)\)/,
|
|
283
|
+
Author.where { !:id.in?(Post.select(:author_id)) }.to_sql)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The subquery correlates with the outer table through qualified columns,
|
|
287
|
+
# and its own where block goes through the DSL too.
|
|
288
|
+
def test_exists
|
|
289
|
+
assert_sql(
|
|
290
|
+
/WHERE EXISTS \(SELECT "posts"\.\* FROM "posts" WHERE "posts"."author_id" = "authors"."id"\)/,
|
|
291
|
+
Author.where { exists?(Post.where { :posts[:author_id] == :authors[:id] }) }.to_sql)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def test_not_exists
|
|
295
|
+
assert_sql(/WHERE NOT \(EXISTS \(SELECT "posts"\.\* FROM "posts"\)\)/,
|
|
296
|
+
Author.where { !exists?(Post.all) }.to_sql)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def test_exists_combined
|
|
300
|
+
assert_sql(
|
|
301
|
+
/WHERE "authors"."name" = 'matz' AND EXISTS \(SELECT "posts"\.\* FROM "posts" WHERE "posts"."title" = 'pub'\)/,
|
|
302
|
+
Author.where { (:name == 'matz') & exists?(Post.where(title: 'pub')) }.to_sql)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def test_exists_execution
|
|
306
|
+
Author.delete_all
|
|
307
|
+
Post.delete_all
|
|
308
|
+
with_post = Author.create!(name: 'with_post')
|
|
309
|
+
Author.create!(name: 'without')
|
|
310
|
+
Post.create!(title: 'pub', author_id: with_post.id)
|
|
311
|
+
correlated = -> { Post.where { :posts[:author_id] == :authors[:id] } }
|
|
312
|
+
assert_equal(['with_post'],
|
|
313
|
+
Author.where { exists?(correlated.call) }.pluck(:name))
|
|
314
|
+
assert_equal(['without'],
|
|
315
|
+
Author.where { !exists?(correlated.call) }.pluck(:name))
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def test_in_subquery_execution
|
|
319
|
+
Author.delete_all
|
|
320
|
+
Post.delete_all
|
|
321
|
+
published = Author.create!(name: 'published')
|
|
322
|
+
drafting = Author.create!(name: 'drafting')
|
|
323
|
+
Post.create!(title: 'pub', author_id: published.id)
|
|
324
|
+
Post.create!(title: 'draft', author_id: drafting.id)
|
|
325
|
+
subquery = -> { Post.where(title: 'pub').select(:author_id) }
|
|
326
|
+
assert_equal(['published'],
|
|
327
|
+
Author.where { :id.in?(subquery.call) }.pluck(:name))
|
|
328
|
+
assert_equal(['drafting'],
|
|
329
|
+
Author.where { !:id.in?(subquery.call) }.pluck(:name))
|
|
330
|
+
end
|
|
331
|
+
|
|
179
332
|
# == passes a Range or an Array through as a value rather than expanding it,
|
|
180
333
|
# so that it compares against a PostgreSQL range or array column. The SQL
|
|
181
334
|
# literal depends on the column type, so assert on the Arel node instead.
|
data/test/test_helper.rb
CHANGED
|
@@ -62,6 +62,10 @@ module SqlAssertions
|
|
|
62
62
|
skip "#{ADAPTER} has no regexp operator" unless REGEXP_OPERATORS.key?(ADAPTER)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
def skip_without_array_columns
|
|
66
|
+
skip "#{ADAPTER} has no array columns" unless ADAPTER == 'postgresql'
|
|
67
|
+
end
|
|
68
|
+
|
|
65
69
|
def regexp_operator
|
|
66
70
|
Regexp.escape(REGEXP_OPERATORS.fetch(ADAPTER).first)
|
|
67
71
|
end
|
|
@@ -105,7 +109,11 @@ class CreateAllTables < ActiveRecord::Migration[8.1]
|
|
|
105
109
|
drop_table(:users, if_exists: true)
|
|
106
110
|
drop_table(:authors, if_exists: true)
|
|
107
111
|
drop_table(:posts, if_exists: true)
|
|
108
|
-
create_table(:users)
|
|
112
|
+
create_table(:users) do |t|
|
|
113
|
+
t.string :name
|
|
114
|
+
t.integer :age
|
|
115
|
+
t.string :tags, array: true if ADAPTER == 'postgresql'
|
|
116
|
+
end
|
|
109
117
|
create_table(:authors) {|t| t.string :name}
|
|
110
118
|
create_table(:posts) {|t| t.string :title; t.integer :author_id}
|
|
111
119
|
end
|