activerecord_where_assoc 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +301 -0
- data/lib/active_record_where_assoc.rb +26 -0
- data/lib/active_record_where_assoc/active_record_compat.rb +63 -0
- data/lib/active_record_where_assoc/core_logic.rb +337 -0
- data/lib/active_record_where_assoc/exceptions.rb +6 -0
- data/lib/active_record_where_assoc/query_methods.rb +177 -0
- data/lib/active_record_where_assoc/querying.rb +11 -0
- data/lib/active_record_where_assoc/version.rb +5 -0
- data/lib/activerecord_where_assoc.rb +6 -0
- metadata +181 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 95ce5c0d5802b14a7e1427d949bd45fec4156841
|
4
|
+
data.tar.gz: 928080180b366f561b01cb26f7cf044187ac1c2b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d7e8dccc5e31da6f30a2355994b8ec611de26b015c3786524408f2fc6afb688e31381e71a8908f5df8682637c473a0a55a1d7f6769772389592cb706c316c3e1
|
7
|
+
data.tar.gz: a31a92215befe043a240074e0b358502e943cfb097b1409155193497b6f69ec02251dd9d53c7af3ecf723d7d398c543bff598ba15bd4216ae79ed89838192e30
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 Maxime Handfield Lapointe
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,301 @@
|
|
1
|
+
# ActiveRecord Where Assoc
|
2
|
+
|
3
|
+
[![Build Status](https://travis-ci.org/MaxLap/activerecord_where_assoc.svg?branch=master)](https://travis-ci.org/MaxLap/activerecord_where_assoc)
|
4
|
+
[![Coverage Status](https://coveralls.io/repos/github/MaxLap/activerecord_where_assoc/badge.svg)](https://coveralls.io/github/MaxLap/activerecord_where_assoc)
|
5
|
+
[![Code Climate](https://codeclimate.com/github/MaxLap/activerecord_where_assoc/badges/gpa.svg)](https://codeclimate.com/github/MaxLap/activerecord_where_assoc)
|
6
|
+
[![Issue Count](https://codeclimate.com/github/MaxLap/activerecord_where_assoc/badges/issue_count.svg)](https://codeclimate.com/github/MaxLap/activerecord_where_assoc)
|
7
|
+
|
8
|
+
This gem provides powerful methods to add conditions based on the associations of your records. (Using SQL's `EXISTS` operator)
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
# Find my_post's comments that were not made by an admin
|
12
|
+
my_post.comments.where_assoc_not_exists(:author, is_admin: true).where(...)
|
13
|
+
|
14
|
+
# Find posts that have comments by an admin
|
15
|
+
Post.where_assoc_exists([:comments, :author], &:admins).where(...)
|
16
|
+
|
17
|
+
# Find my_user's posts that have at least 5 non-spam comments
|
18
|
+
my_user.posts.where_assoc_count(5, :>=, :comments) { |comments| comments.where(spam: false) }.where(...)
|
19
|
+
```
|
20
|
+
|
21
|
+
These allow for powerful, chainable, clear and easy to reuse queries. (Great for scopes)
|
22
|
+
|
23
|
+
You also avoid many [problems with the alternative options](ALTERNATIVES_PROBLEMS.md).
|
24
|
+
|
25
|
+
Works with SQLite3, PostgreSQL and MySQL. [MySQL has one limitation](#mysql-doesnt-support-sub-limit). Untested with other DBMS.
|
26
|
+
|
27
|
+
## Feedback
|
28
|
+
|
29
|
+
This gem is very new. If you have any feedback, good or bad, do not hesitate to write it here: [General feedback](https://github.com/MaxLap/activerecord_where_assoc/issues/3). If you find any bug, please create a new issue.
|
30
|
+
|
31
|
+
* Failure stories, if you had difficulties that kept you from using the gem.
|
32
|
+
* Success stories, if you are using it and things are going great, I wanna hear this too.
|
33
|
+
* Suggestions to make the documentation easier to follow / more complete.
|
34
|
+
|
35
|
+
|
36
|
+
## 0.1.0
|
37
|
+
|
38
|
+
Since the gem is brand new, I'm releasing 0.1.0 as a public beta before bumping to 1.0.0 once I have some feedback.
|
39
|
+
|
40
|
+
## Installation
|
41
|
+
|
42
|
+
Add this line to your application's Gemfile:
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
gem 'activerecord_where_assoc'
|
46
|
+
```
|
47
|
+
|
48
|
+
And then execute:
|
49
|
+
|
50
|
+
$ bundle install
|
51
|
+
|
52
|
+
Or install it yourself as:
|
53
|
+
|
54
|
+
$ gem install activerecord_where_assoc
|
55
|
+
|
56
|
+
## Usage
|
57
|
+
|
58
|
+
### `#where_assoc_exists` & `#where_assoc_not_exists`
|
59
|
+
|
60
|
+
Returns a new relation, which is the result of filtering the current relation based on if a record for the specified association of the model exists (or not). Conditions that the associated model must match to count as existing can also be specified.
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
Post.where_assoc_exists(:comments, spam: true)
|
64
|
+
Post.where_assoc_not_exists(:comments, spam: true)
|
65
|
+
```
|
66
|
+
|
67
|
+
* 1st parameter: the association we are doing the condition on.
|
68
|
+
* 2nd parameter: (optional) the condition to apply on the association. It can be anything that `#where` can receive, so: Hash, String and Array (string with binds).
|
69
|
+
* 3rd parameter: [options (listed below)](#options) to alter some behaviors.
|
70
|
+
* block: adds more complex conditions by receiving a relation on the association. Can apply `#where`, `#where_assoc_*`, scopes, and other scoping methods.
|
71
|
+
The block either:
|
72
|
+
|
73
|
+
* receives no argument, in which case `self` is set to the relation, so you can do `{ where(id: 123) }`
|
74
|
+
* receives arguments, in which case the block is called with the relation as first parameter
|
75
|
+
|
76
|
+
The block should return the new relation to use or `nil` to do as if there were no blocks
|
77
|
+
It's common to use `where_assoc_*(..., &:scope_name)` to apply a single scope quickly
|
78
|
+
|
79
|
+
### `#where_assoc_count`
|
80
|
+
|
81
|
+
This is a generalization of `#where_assoc_exists` and `#where_assoc_not_exists`. It behave behaves the same way as them, but is more flexible as it allows you to be specific about how many matches there should be. To clarify, here are equivalent examples:
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
Post.where_assoc_exists(:comments, spam: true)
|
85
|
+
Post.where_assoc_count(1, :<=, :comments, spam: true)
|
86
|
+
|
87
|
+
Post.where_assoc_not_exists(:comments, spam: true)
|
88
|
+
Post.where_assoc_count(0, :==, :comments, spam: true)
|
89
|
+
```
|
90
|
+
|
91
|
+
* 1st parameter: a number or any string of SQL to embed in the query used for the leftoperand of the comparison.
|
92
|
+
* 2nd parameter: the operator to use: `:<`, `:<=`, `:==`, `:!=`, `:>=`, `:>`
|
93
|
+
* 3rd, 4th, 5th parameters are the same as the 1st, 2nd and 3rd parameters of `#where_assoc_exists`.
|
94
|
+
* block: same as `#where_assoc_exists`' block
|
95
|
+
|
96
|
+
The order of the parameters may seem confusing, but you will get used to it. To help remember the order of the parameters, remember that the goal is to do:
|
97
|
+
|
98
|
+
5 < (SELECT COUNT(*) FROM ...)
|
99
|
+
|
100
|
+
The parameters are in the same order as in that query: number, operator, association.
|
101
|
+
|
102
|
+
### Options
|
103
|
+
|
104
|
+
Each of the methods above can take an options argument. It is also possible to change the default value for the options.
|
105
|
+
|
106
|
+
* On a per-call basis:
|
107
|
+
```ruby
|
108
|
+
# Options are passed after the conditions argument
|
109
|
+
Posts.where_assoc_exists(:last_status, nil, ignore_limit: true)
|
110
|
+
Posts.where_assoc_count(1, :<, :last_status, nil, ignore_limit: true)
|
111
|
+
```
|
112
|
+
|
113
|
+
* As default for everywhere
|
114
|
+
```ruby
|
115
|
+
# Somewhere in your setup code, such as an initializer in Rails
|
116
|
+
ActiveRecordWhereAssoc.default_options[:ignore_limit] = true
|
117
|
+
```
|
118
|
+
|
119
|
+
Here is a list of the available options:
|
120
|
+
|
121
|
+
#### :ignore_limit
|
122
|
+
|
123
|
+
When this option is true, then `#limit` and `#offset` that are set either from default_scope or on associations are ignored. `#has_one` means `limit(1)`, so `#has_one` will behave like `#has_many` with this option.
|
124
|
+
|
125
|
+
Main reasons to use this:
|
126
|
+
* This is needed for MySQL to be able to do anything with `#has_one` associations because [MySQL doesn't support sub-limit](#mysql-doesnt-support-sub-limit).
|
127
|
+
* You have a `#has_one` association which you know can never have more than one record. Using `:ignore_limit`, you will use the simpler query of `#has_many`, which can be more efficient.
|
128
|
+
|
129
|
+
Why this isn't the default:
|
130
|
+
* From very few tests, the aliasing way seems to produce better plans.
|
131
|
+
* Using aliasing produces a shorter query.
|
132
|
+
|
133
|
+
#### :never_alias_limit
|
134
|
+
|
135
|
+
When this option is true, `#where_assoc_*` will not use `#from` to build relations that have `#limit` or `#offset` set on default_scope or on associations. Note, `#has_one` means `limit(1)`, so it will also use `#from` unless this option is activated.
|
136
|
+
|
137
|
+
Main reasons to use this:
|
138
|
+
* You have to use `#from` as condition for `#where_assoc_*` method (possibly because a scope needs it).
|
139
|
+
* This might result in a difference execution plan for the query since the query ends up being quite different.
|
140
|
+
|
141
|
+
## Supported Rails versions
|
142
|
+
|
143
|
+
Rails 4.1 to 5.2 are supported with Ruby 2.1 to 2.5.
|
144
|
+
|
145
|
+
## Advantages
|
146
|
+
|
147
|
+
These methods have many advantages over the alternative ways of achieving the similar results:
|
148
|
+
* Avoids the [problems with the alternative ways](ALTERNATIVES_PROBLEMS.md)
|
149
|
+
* Can be chained and nested with regular ActiveRecord methods (`where`, `merge`, `scope`, etc).
|
150
|
+
* Adds a single condition in the `WHERE` of the query instead of complex things like joins.
|
151
|
+
* So it's easy to have multiple conditions on the same association
|
152
|
+
* Handles `has_one` correctly: only testing the "first" record of the association that matches the default_scope and the scope on the association itself.
|
153
|
+
* Handles recursive associations (such as parent/children) seemlessly.
|
154
|
+
* Can be used to quickly generate a SQL query that you can edit/use manually.
|
155
|
+
|
156
|
+
## More examples
|
157
|
+
|
158
|
+
High level explanation of various ways of using the methods. Also take a look at [usage tips](#usage-tips)
|
159
|
+
|
160
|
+
```ruby
|
161
|
+
# Find my_post's comments that were not made by an admin
|
162
|
+
# Uses a Hash for the condition
|
163
|
+
my_post.comments.where_assoc_not_exists(:author, is_admin: true)
|
164
|
+
|
165
|
+
# Find my_user's posts that have comments by an admin
|
166
|
+
# Uses an array as shortcut to go to a nested related
|
167
|
+
# Uses the block shortcut to use a scope that exists on Author
|
168
|
+
my_user.posts.where_assoc_exists([:comments, :author], &:admins).where(...)
|
169
|
+
|
170
|
+
# Find my_user's posts that have at least 5 non-spam comments
|
171
|
+
# Uses a block with a parameter to do a condition
|
172
|
+
my_user.posts.where_assoc_count(5, :>=, :comments) { |s| s.where(spam: false) }
|
173
|
+
|
174
|
+
# Find my_user's posts that have at least 5 non-spam comments
|
175
|
+
# Uses a block without parameters to do a condition
|
176
|
+
my_user.posts.where_assoc_count(5, :>=, :comments) { where(spam: false) }
|
177
|
+
|
178
|
+
# Find my_user's posts that have comments by an honest admin
|
179
|
+
# Uses multiple associations.
|
180
|
+
# Uses a hash as 2nd parameter to do the conditions
|
181
|
+
my_user.posts.where_assoc_exists([:comments, :author], honest: true, is_admin: true)
|
182
|
+
|
183
|
+
# Find any post that has reached its maximum number of allowed comments
|
184
|
+
# Uses a string on the left side (first parameter) to refer to a column in the previous table.
|
185
|
+
Post.where_assoc_count("posts.max_comments_allowed", :==, :comments)
|
186
|
+
```
|
187
|
+
|
188
|
+
## Usage tips
|
189
|
+
|
190
|
+
### Nested associations
|
191
|
+
|
192
|
+
Sometimes, there isn't a single association that goes deep enough. In that situation, you can simply nest the scopes:
|
193
|
+
|
194
|
+
```ruby
|
195
|
+
# Find users that have a post that has a comment that was made by an admin.
|
196
|
+
# Using &:admins to use the admins scope (or any other class method of comments)
|
197
|
+
User.where_assoc_exists(:posts) { |posts|
|
198
|
+
posts.where_assoc_exists(:comments) { |comments|
|
199
|
+
comments.where_assoc_exists(:author, &:admins)
|
200
|
+
}
|
201
|
+
}
|
202
|
+
```
|
203
|
+
|
204
|
+
If you don't need special conditions on any of the intermediary associations, then you can an array as shortcut for multiple steps:
|
205
|
+
|
206
|
+
```ruby
|
207
|
+
# Same as above
|
208
|
+
User.where_assoc_exists([:posts, :comments, :author], &:admins)
|
209
|
+
```
|
210
|
+
|
211
|
+
This shortcut can be used for every `where_assoc_*` methods. The conditions and the block will only be applied to the last association of the chain.
|
212
|
+
|
213
|
+
|
214
|
+
### Beware of spreading conditions on multiple calls
|
215
|
+
|
216
|
+
The following have different meanings:
|
217
|
+
|
218
|
+
```ruby
|
219
|
+
my_user.posts.where_assoc_exists(:comments_authors, is_admin: true, honest: true)
|
220
|
+
|
221
|
+
my_user.posts.where_assoc_exists(:comments_authors, is_admin: true)
|
222
|
+
.where_assoc_exists(:comments_authors, honest: true)
|
223
|
+
```
|
224
|
+
|
225
|
+
The first is the posts of `my_user` that have a comment made by an honest admin. It requires a single comment to match every conditions.
|
226
|
+
|
227
|
+
The second is the posts of `my_user` that have a comment made by an admin and a comment made by someone honest. It could be the same comment (like the first query) but it could also be 2 different comments.
|
228
|
+
|
229
|
+
### Inter-table conditions
|
230
|
+
|
231
|
+
It's possible, with string conditions, to refer to all the tables that are used before the association, including the source model.
|
232
|
+
|
233
|
+
```ruby
|
234
|
+
# Find posts where the author also commented on the post.
|
235
|
+
Post.where_assoc_exists(:comments, "posts.author_id = comments.author_id")
|
236
|
+
```
|
237
|
+
|
238
|
+
Note that some database systems limit how far up you can refer to tables in nested queries. Meaning it's possible that the following query may get refused because of those limits:
|
239
|
+
|
240
|
+
```ruby
|
241
|
+
# it's hard to come up with a good example...
|
242
|
+
Post.where_assoc_exists([:comments, :author, :address], "addresses.country = posts.database_country")
|
243
|
+
```
|
244
|
+
|
245
|
+
Doing the same thing but with less associations between `address` and `posts` would not be an issue.
|
246
|
+
|
247
|
+
### The opposite of multiple nested EXISTS...
|
248
|
+
|
249
|
+
... is a single `NOT EXISTS` with then nested ones still using `EXISTS`.
|
250
|
+
|
251
|
+
All the methods always chain nested associations using an `EXISTS` when they have to go through multiple hoops. Only the outer-most, or first, association will have a `NOT EXISTS` when using `#where_assoc_not_exists` or a `COUNT` when using `#where_assoc_count`. This is the logical way of doing it.
|
252
|
+
|
253
|
+
### Using `#from` in scope
|
254
|
+
|
255
|
+
If you want to use a scope / condition which uses `#from`, then you need to use the [:never_alias_limit](#never_alias_limit) option to avoid `#where_assoc_*` being overwritten by your scope and getting a weird exception / wrong result.
|
256
|
+
|
257
|
+
## Known issues/limitations
|
258
|
+
|
259
|
+
### MySQL doesn't support sub-limit
|
260
|
+
On MySQL databases, it is not possible to use `has_one` associations and associations with a scope that apply either a limit or an offset.
|
261
|
+
|
262
|
+
I do not know of a way to do a SQL query that can deal with all the specifics of `has_one` for MySQL. If you have one, then please suggest it in an issue/pull request.
|
263
|
+
|
264
|
+
In order to work around this, you must use the [ignore_limit](#ignore_limit) option. The behavior is less correct, but better than being unable to use the gem.
|
265
|
+
|
266
|
+
### has_* :through vs limit/offset
|
267
|
+
For `has_many` and `has_one` with the `:through` option, `#limit` and `#offset` are ignored. Note that `#limit` and `#offset` of the `:source` and of the `:through` side are applied correctly.
|
268
|
+
|
269
|
+
This is the opposite of what `ActiveRecord` does when you fetch the result of such an association. `ActiveRecord` will ignore the limits of the part `:source` and of the `:through` and only use the one of the `has_* :through`.
|
270
|
+
|
271
|
+
It is pretty complicated to support `#limit` and `#offset` of the `has_* :through` and would require quite a bit of refactoring. PR welcome
|
272
|
+
|
273
|
+
Note that the support of `#limit` and `#offset` for the `:source` and `:through` parts is a feature. I consider `ActiveRecord` wrong for not handling them correctly.
|
274
|
+
|
275
|
+
## Development
|
276
|
+
|
277
|
+
After checking out the repo, run `bundle install` to install dependencies.
|
278
|
+
|
279
|
+
Run `rake test` to run the tests for the latest version of rails
|
280
|
+
|
281
|
+
Run `bin/console` for an interactive prompt that will allow you to experiment in the same environment as the tests.
|
282
|
+
|
283
|
+
Run `bin/fixcop` to fix a lot of common styling mistake from your changes and then display the remaining rubocop rules you break. Make sure to do this before committing and submitting PRs. Use common sense, sometimes it's okay to break a rule, add a [rubocop:disable comment](http://rubocop.readthedocs.io/en/latest/configuration/#disabling-cops-within-source-code) in that situation.
|
284
|
+
|
285
|
+
Run `bin/testall` to test all supported rails/ruby versions:
|
286
|
+
* It will tell you about missing ruby versions, which you can install if you want to test for them
|
287
|
+
* It will run `rake test` on each supported version or ruby/rails
|
288
|
+
* It automatically installs bundler if a ruby version doesn't have it
|
289
|
+
* It automatically runs `bundle install`
|
290
|
+
|
291
|
+
## Contributing
|
292
|
+
|
293
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/MaxLap/activerecord_where_assoc.
|
294
|
+
|
295
|
+
## License
|
296
|
+
|
297
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
298
|
+
|
299
|
+
## Acknowledgements
|
300
|
+
|
301
|
+
* [René van den Berg](https://github.com/ReneB) for some of the code of [activerecord-like](https://github.com/ReneB/activerecord-like) used for help with setting up the tests
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_where_assoc/version"
|
4
|
+
require "active_record"
|
5
|
+
|
6
|
+
module ActiveRecordWhereAssoc
|
7
|
+
# Default options for the gem. Meant to be modified in place by external code
|
8
|
+
def self.default_options
|
9
|
+
@default_options ||= {
|
10
|
+
ignore_limit: false,
|
11
|
+
never_alias_limit: false,
|
12
|
+
}
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
require "active_record_where_assoc/core_logic"
|
17
|
+
require "active_record_where_assoc/query_methods"
|
18
|
+
require "active_record_where_assoc/querying"
|
19
|
+
|
20
|
+
ActiveSupport.on_load(:active_record) do
|
21
|
+
ActiveRecord.eager_load!
|
22
|
+
|
23
|
+
# Need to use #send for the include to support Ruby 2.0
|
24
|
+
ActiveRecord::Relation.send(:include, ActiveRecordWhereAssoc::QueryMethods)
|
25
|
+
ActiveRecord::Base.extend(ActiveRecordWhereAssoc::Querying)
|
26
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordWhereAssoc
|
4
|
+
module ActiveRecordCompat
|
5
|
+
if ActiveRecord.gem_version >= Gem::Version.new("5.1")
|
6
|
+
def self.join_keys(reflection)
|
7
|
+
reflection.join_keys
|
8
|
+
end
|
9
|
+
elsif ActiveRecord.gem_version >= Gem::Version.new("4.2")
|
10
|
+
def self.join_keys(reflection)
|
11
|
+
reflection.join_keys(reflection.klass)
|
12
|
+
end
|
13
|
+
else
|
14
|
+
# 4.1 change that introduced JoinKeys:
|
15
|
+
# https://github.com/rails/rails/commit/5823e429981dc74f8f53187d2ab573823381bf28#diff-523caff658498027f61cae9d91c8503dL108
|
16
|
+
JoinKeys = Struct.new(:key, :foreign_key)
|
17
|
+
def self.join_keys(reflection)
|
18
|
+
if reflection.source_macro == :belongs_to
|
19
|
+
# The original code had to handle polymorphic here. But we don't support polymorphic belongs_to
|
20
|
+
# So the code would never reach here in the polymorphic case.
|
21
|
+
key = reflection.association_primary_key
|
22
|
+
foreign_key = reflection.foreign_key
|
23
|
+
else
|
24
|
+
key = reflection.foreign_key
|
25
|
+
foreign_key = reflection.active_record_primary_key
|
26
|
+
end
|
27
|
+
|
28
|
+
JoinKeys.new(key, foreign_key)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
if ActiveRecord.gem_version >= Gem::Version.new("5.0")
|
33
|
+
def self.chained_reflection_and_chained_constraints(reflection)
|
34
|
+
reflection.chain.map { |ref| [ref, ref.constraints] }.transpose
|
35
|
+
end
|
36
|
+
else
|
37
|
+
def self.chained_reflection_and_chained_constraints(reflection)
|
38
|
+
[reflection.chain, reflection.scope_chain]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
if ActiveRecord.gem_version >= Gem::Version.new("5.0")
|
43
|
+
def self.parent_reflection(reflection)
|
44
|
+
reflection.parent_reflection
|
45
|
+
end
|
46
|
+
else
|
47
|
+
def self.parent_reflection(reflection)
|
48
|
+
_parent_name, parent_refl = reflection.parent_reflection
|
49
|
+
parent_refl
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
if ActiveRecord.gem_version >= Gem::Version.new("4.2")
|
54
|
+
def self.normalize_association_name(association_name)
|
55
|
+
association_name.to_s
|
56
|
+
end
|
57
|
+
else
|
58
|
+
def self.normalize_association_name(association_name)
|
59
|
+
association_name.to_sym
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,337 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "active_record_compat"
|
4
|
+
require_relative "exceptions"
|
5
|
+
|
6
|
+
module ActiveRecordWhereAssoc
|
7
|
+
module CoreLogic
|
8
|
+
# Arel table used for aliasing when handling recursive associations (such as parent/children)
|
9
|
+
ALIAS_TABLE = Arel::Table.new("_ar_where_assoc_alias_")
|
10
|
+
|
11
|
+
# Block used when nesting associations for a where_assoc_[not_]exists
|
12
|
+
# Will apply the nested scope to the wrapping_scope with: where("EXISTS (SELECT... *nested_scope*)")
|
13
|
+
# exists_prefix: raw sql prefix to the EXISTS, ex: 'NOT '
|
14
|
+
NestWithExistsBlock = lambda do |wrapping_scope, nested_scope, exists_prefix = ""|
|
15
|
+
sql = "#{exists_prefix}EXISTS (#{nested_scope.select('0').to_sql})"
|
16
|
+
|
17
|
+
wrapping_scope.where(sql)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Block used when nesting associations for a where_assoc_count
|
21
|
+
# Will apply the nested scope to the wrapping_scope with: select("SUM(SELECT... *nested_scope*)")
|
22
|
+
NestWithSumBlock = lambda do |wrapping_scope, nested_scope|
|
23
|
+
# Need the double parentheses
|
24
|
+
sql = "SUM((#{nested_scope.to_sql}))"
|
25
|
+
|
26
|
+
wrapping_scope.unscope(:select).select(sql)
|
27
|
+
end
|
28
|
+
|
29
|
+
# List of available options, used for validation purposes.
|
30
|
+
VALID_OPTIONS_KEYS = ActiveRecordWhereAssoc.default_options.keys.freeze
|
31
|
+
|
32
|
+
def self.validate_options(options)
|
33
|
+
invalid_keys = options.keys - VALID_OPTIONS_KEYS
|
34
|
+
raise ArgumentError, "Invalid option keys received: #{invalid_keys.join(', ')}" unless invalid_keys.empty?
|
35
|
+
end
|
36
|
+
|
37
|
+
# Gets the value from the options or fallback to default
|
38
|
+
def self.option_value(options, key)
|
39
|
+
options.fetch(key) { ActiveRecordWhereAssoc.default_options[key] }
|
40
|
+
end
|
41
|
+
|
42
|
+
# Returns a new relation, which is the result of filtering base_relation
|
43
|
+
# based on if a record for the specified association of the model exists.
|
44
|
+
#
|
45
|
+
# See #where_assoc_exists in query_methods.rb for usage details.
|
46
|
+
def self.do_where_assoc_exists(base_relation, association_name, given_scope = nil, options = {}, &block)
|
47
|
+
nested_relation = relation_on_association(base_relation, association_name, given_scope, options, block, NestWithExistsBlock)
|
48
|
+
NestWithExistsBlock.call(base_relation, nested_relation)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Returns a new relation, which is the result of filtering base_relation
|
52
|
+
# based on if a record for the specified association of the model doesn't exist.
|
53
|
+
#
|
54
|
+
# See #where_assoc_exists in query_methods.rb for usage details.
|
55
|
+
def self.do_where_assoc_not_exists(base_relation, association_name, given_scope = nil, options = {}, &block)
|
56
|
+
nested_relation = relation_on_association(base_relation, association_name, given_scope, options, block, NestWithExistsBlock)
|
57
|
+
NestWithExistsBlock.call(base_relation, nested_relation, "NOT ")
|
58
|
+
end
|
59
|
+
|
60
|
+
# Returns a new relation, which is the result of filtering base_relation
|
61
|
+
# based on how many records for the specified association of the model exists.
|
62
|
+
#
|
63
|
+
# See #where_assoc_exists and #where_assoc_count in query_methods.rb for usage details.
|
64
|
+
def self.do_where_assoc_count(base_relation, left_operand, operator, association_name, given_scope = nil, options = {}, &block)
|
65
|
+
deepest_scope_mod = lambda do |deepest_scope|
|
66
|
+
deepest_scope = apply_proc_scope(deepest_scope, block) if block
|
67
|
+
|
68
|
+
deepest_scope.unscope(:select).select("COUNT(*)")
|
69
|
+
end
|
70
|
+
|
71
|
+
nested_relation = relation_on_association(base_relation, association_name, given_scope, options, deepest_scope_mod, NestWithSumBlock)
|
72
|
+
operator = case operator.to_s
|
73
|
+
when "=="
|
74
|
+
"="
|
75
|
+
when "!="
|
76
|
+
"<>"
|
77
|
+
else
|
78
|
+
operator
|
79
|
+
end
|
80
|
+
|
81
|
+
base_relation.where("(#{left_operand}) #{operator} COALESCE((#{nested_relation.to_sql}), 0)")
|
82
|
+
end
|
83
|
+
|
84
|
+
# Returns the receiver (with possible alterations) and a relation meant to be embed in the received.
|
85
|
+
# association_names_path: can be an array of association names or a single one
|
86
|
+
def self.relation_on_association(base_relation, association_names_path, given_scope = nil, options = {},
|
87
|
+
last_assoc_block = nil, nest_assocs_block = nil)
|
88
|
+
validate_options(options)
|
89
|
+
association_names_path = Array.wrap(association_names_path)
|
90
|
+
|
91
|
+
if association_names_path.size > 1
|
92
|
+
recursive_scope_block = lambda do |scope|
|
93
|
+
nested_scope = relation_on_association(scope, association_names_path[1..-1], given_scope, options, last_assoc_block, nest_assocs_block)
|
94
|
+
nest_assocs_block.call(scope, nested_scope)
|
95
|
+
end
|
96
|
+
|
97
|
+
relation_on_one_association(base_relation, association_names_path.first, nil, options, recursive_scope_block, nest_assocs_block)
|
98
|
+
else
|
99
|
+
relation_on_one_association(base_relation, association_names_path.first, given_scope, options, last_assoc_block, nest_assocs_block)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Returns the receiver (with possible alterations) and a relation meant to be embed in the received.
|
104
|
+
def self.relation_on_one_association(base_relation, association_name, given_scope = nil, options = {},
|
105
|
+
last_assoc_block = nil, nest_assocs_block = nil)
|
106
|
+
relation_klass = base_relation.klass
|
107
|
+
final_reflection = fetch_reflection(relation_klass, association_name)
|
108
|
+
|
109
|
+
nested_scope = nil
|
110
|
+
current_scope = nil
|
111
|
+
|
112
|
+
# Chain deals with through stuff
|
113
|
+
# We will start with the reflection that points on the final model, and slowly move back to the reflection
|
114
|
+
# that points on the model closest to self
|
115
|
+
# Each step, we get all of the scoping lambdas that were defined on associations that apply for
|
116
|
+
# the reflection's target
|
117
|
+
# Basically, we start from the deepest part of the query and wrap it up
|
118
|
+
reflection_chain, constaints_chain = ActiveRecordCompat.chained_reflection_and_chained_constraints(final_reflection)
|
119
|
+
skip_next = false
|
120
|
+
|
121
|
+
reflection_chain.each_with_index do |reflection, i|
|
122
|
+
if skip_next
|
123
|
+
skip_next = false
|
124
|
+
next
|
125
|
+
end
|
126
|
+
|
127
|
+
# the 2nd part of has_and_belongs_to_many is handled at the same time as the first.
|
128
|
+
skip_next = true if has_and_belongs_to_many?(reflection)
|
129
|
+
|
130
|
+
wrapper_scope, current_scope = initial_scope_from_reflection(reflection_chain[i..-1], constaints_chain[i])
|
131
|
+
|
132
|
+
current_scope = process_association_step_limits(current_scope, reflection, relation_klass, options)
|
133
|
+
|
134
|
+
if i.zero?
|
135
|
+
current_scope = current_scope.where(given_scope) if given_scope
|
136
|
+
current_scope = apply_proc_scope(current_scope, last_assoc_block) if last_assoc_block
|
137
|
+
end
|
138
|
+
|
139
|
+
# Those make no sense since we are only limiting the value that would match, using conditions
|
140
|
+
current_scope = current_scope.unscope(:limit, :order, :offset)
|
141
|
+
current_scope = nest_assocs_block.call(current_scope, nested_scope) if nested_scope
|
142
|
+
current_scope = nest_assocs_block.call(wrapper_scope, current_scope) if wrapper_scope
|
143
|
+
|
144
|
+
nested_scope = current_scope
|
145
|
+
end
|
146
|
+
|
147
|
+
current_scope
|
148
|
+
end
|
149
|
+
|
150
|
+
def self.fetch_reflection(relation_klass, association_name)
|
151
|
+
association_name = ActiveRecordCompat.normalize_association_name(association_name)
|
152
|
+
reflection = relation_klass._reflections[association_name]
|
153
|
+
|
154
|
+
if reflection.nil?
|
155
|
+
# Need to use build because this exception expects a record...
|
156
|
+
raise ActiveRecord::AssociationNotFoundError.new(relation_klass.new, association_name)
|
157
|
+
end
|
158
|
+
if reflection.macro == :belongs_to && reflection.options[:polymorphic]
|
159
|
+
# TODO: We might want an option to indicate that using pluck is ok?
|
160
|
+
raise NotImplementedError, "Can't deal with polymorphic belongs_to"
|
161
|
+
end
|
162
|
+
|
163
|
+
reflection
|
164
|
+
end
|
165
|
+
|
166
|
+
def self.initial_scope_from_reflection(reflection_chain, constraints)
|
167
|
+
reflection = reflection_chain.first
|
168
|
+
current_scope = reflection.klass.default_scoped
|
169
|
+
|
170
|
+
if has_and_belongs_to_many?(reflection)
|
171
|
+
# has_and_belongs_to_many, behind the scene has a secret model and uses a has_many through.
|
172
|
+
# This is the first of those two secret has_many through.
|
173
|
+
#
|
174
|
+
# In order to handle limit, offset, order correctly on has_and_belongs_to_man,
|
175
|
+
# we must do both this reflection and the next one at the same time.
|
176
|
+
# Think of it this way, if you have limit 3:
|
177
|
+
# Apply only on 1st step: You check that any of 2nd step for the first 3 of 1st step match
|
178
|
+
# Apply only on 2nd step: You check that any of the first 3 of second step match for any 1st step
|
179
|
+
# Apply over both (as we do): You check that only the first 3 of doing both step match,
|
180
|
+
|
181
|
+
# To create the join, simply using next_reflection.klass.default_scoped.joins(reflection.name)
|
182
|
+
# would be great, except we cannot add a given_scope afterward because we are on the wrong "base class",
|
183
|
+
# and we can't do #merge because of the LEW crap.
|
184
|
+
# So we must do the joins ourself!
|
185
|
+
_wrapper, sub_join_contraints = wrapper_and_join_constraints(reflection)
|
186
|
+
next_reflection = reflection_chain[1]
|
187
|
+
|
188
|
+
current_scope = current_scope.joins(<<-SQL)
|
189
|
+
INNER JOIN #{next_reflection.klass.quoted_table_name} ON #{sub_join_contraints.to_sql}
|
190
|
+
SQL
|
191
|
+
|
192
|
+
wrapper_scope, join_constaints = wrapper_and_join_constraints(next_reflection, habtm_other_reflection: reflection)
|
193
|
+
else
|
194
|
+
wrapper_scope, join_constaints = wrapper_and_join_constraints(reflection)
|
195
|
+
end
|
196
|
+
|
197
|
+
constraint_allowed_lim_off = constraint_allowed_lim_off_from(reflection)
|
198
|
+
|
199
|
+
constraints.each do |callable|
|
200
|
+
relation = reflection.klass.unscoped.instance_exec(&callable)
|
201
|
+
|
202
|
+
if callable != constraint_allowed_lim_off
|
203
|
+
# I just want to remove the current values without screwing things in the merge below
|
204
|
+
# so we cannot use #unscope
|
205
|
+
relation.limit_value = nil
|
206
|
+
relation.offset_value = nil
|
207
|
+
relation.order_values = []
|
208
|
+
end
|
209
|
+
|
210
|
+
# Need to use merge to replicate the Last Equality Wins behavior of associations
|
211
|
+
# https://github.com/rails/rails/issues/7365
|
212
|
+
# See also the test/tests/wa_last_equality_wins_test.rb for an explanation
|
213
|
+
current_scope = current_scope.merge(relation)
|
214
|
+
end
|
215
|
+
|
216
|
+
[wrapper_scope, current_scope.where(join_constaints)]
|
217
|
+
end
|
218
|
+
|
219
|
+
def self.constraint_allowed_lim_off_from(reflection)
|
220
|
+
if has_and_belongs_to_many?(reflection)
|
221
|
+
reflection.scope
|
222
|
+
else
|
223
|
+
# For :through associations, it's pretty hard/tricky to apply limit/offset/order of the
|
224
|
+
# whole has_* :through. For now, we only do the direct associations from one model to another
|
225
|
+
# that the :through uses and we ignore the limit from the scope of has_* :through.
|
226
|
+
#
|
227
|
+
# For :through associations, #actual_source_reflection returns final non-through
|
228
|
+
# reflection that is reached by following the :source.
|
229
|
+
# Otherwise, returns itself.
|
230
|
+
reflection.send(:actual_source_reflection).scope
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
def self.process_association_step_limits(current_scope, reflection, relation_klass, options)
|
235
|
+
return current_scope.unscope(:limit, :offset, :order) if reflection.macro == :belongs_to
|
236
|
+
|
237
|
+
current_scope = current_scope.limit(1) if reflection.macro == :has_one
|
238
|
+
|
239
|
+
current_scope = current_scope.unscope(:limit, :offset) if option_value(options, :ignore_limit)
|
240
|
+
|
241
|
+
# Order is useless without either limit or offset
|
242
|
+
current_scope = current_scope.unscope(:order) if !current_scope.limit_value && !current_scope.offset_value
|
243
|
+
|
244
|
+
return current_scope unless current_scope.limit_value || current_scope.offset_value
|
245
|
+
if %w(mysql mysql2).include?(relation_klass.connection.adapter_name.downcase)
|
246
|
+
msg = String.new
|
247
|
+
msg << "Associations and default_scopes with a limit or offset are not supported for MySQL (this includes has_many). "
|
248
|
+
msg << "Use ignore_limit: true to ignore both limit and offset, and treat has_one like has_many. "
|
249
|
+
msg << "See https://github.com/MaxLap/activerecord_where_assoc/tree/ignore_limits#mysql-doesnt-support-sub-limit for details."
|
250
|
+
raise MySQLDoesntSupportSubLimitError, msg
|
251
|
+
end
|
252
|
+
|
253
|
+
# We only check the records that would be returned by the associations if called on the model. If:
|
254
|
+
# * the association has a limit in its lambda
|
255
|
+
# * the default scope of the model has a limit
|
256
|
+
# * the association is a has_one
|
257
|
+
# Then not every records that match a naive join would be returned. So we first restrict the query to
|
258
|
+
# only the records that would be in the range of limit and offset.
|
259
|
+
#
|
260
|
+
# Note that if the #where_assoc_* block adds a limit or an offset, it has no effect. This is intended.
|
261
|
+
# An argument could be made for it to maybe make sense for #where_assoc_count, not sure why that would
|
262
|
+
# be useful.
|
263
|
+
|
264
|
+
if reflection.klass.table_name.include?(".") || option_value(options, :never_alias_limit)
|
265
|
+
# This works universally, but seems to sometimes have slower performances.. Need to test if there is an alternative way
|
266
|
+
# of expressing this...
|
267
|
+
# TODO: Investigate a way to improve performances, or maybe require a flag to do it this way?
|
268
|
+
# We use unscoped to avoid duplicating the conditions in the query, which is noise. (unless if it
|
269
|
+
# could helps the query planner of the DB, if someone can show it to be worth it, then this can be changed.)
|
270
|
+
|
271
|
+
reflection.klass.unscoped.where(reflection.klass.primary_key.to_sym => current_scope)
|
272
|
+
else
|
273
|
+
# This works as long as the table_name doesn't have a schema/database, since we need to use an alias
|
274
|
+
# with the table name to make scopes and everything else work as expected.
|
275
|
+
|
276
|
+
# We use unscoped to avoid duplicating the conditions in the query, which is noise. (unless if it
|
277
|
+
# could helps the query planner of the DB, if someone can show it to be worth it, then this can be changed.)
|
278
|
+
reflection.klass.unscoped.from("(#{current_scope.to_sql}) #{reflection.klass.table_name}")
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
# Apply a proc used as scope
|
283
|
+
# If it can't receive arguments, call the proc with self set to the relation
|
284
|
+
# If it can receive arguments, call the proc the relation passed as argument
|
285
|
+
def self.apply_proc_scope(relation, proc_scope)
|
286
|
+
if proc_scope.arity == 0
|
287
|
+
relation.instance_exec(&proc_scope) || relation
|
288
|
+
else
|
289
|
+
proc_scope.call(relation) || relation
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
def self.build_wrapper_scope_for_recursive_association(reflection)
|
294
|
+
table = reflection.klass.arel_table
|
295
|
+
primary_key = reflection.klass.primary_key
|
296
|
+
foreign_klass = reflection.send(:actual_source_reflection).active_record
|
297
|
+
|
298
|
+
wrapper_scope = foreign_klass.base_class.unscoped
|
299
|
+
wrapper_scope = wrapper_scope.from("#{table.name} #{ALIAS_TABLE.name}")
|
300
|
+
wrapper_scope = wrapper_scope.where(table[primary_key].eq(ALIAS_TABLE[primary_key]))
|
301
|
+
wrapper_scope
|
302
|
+
end
|
303
|
+
|
304
|
+
def self.wrapper_and_join_constraints(reflection, options = {})
|
305
|
+
join_keys = ActiveRecordCompat.join_keys(reflection)
|
306
|
+
|
307
|
+
key = join_keys.key
|
308
|
+
foreign_key = join_keys.foreign_key
|
309
|
+
|
310
|
+
table = reflection.klass.arel_table
|
311
|
+
foreign_klass = reflection.send(:actual_source_reflection).active_record
|
312
|
+
foreign_table = foreign_klass.arel_table
|
313
|
+
|
314
|
+
habtm_other_reflection = options[:habtm_other_reflection]
|
315
|
+
habtm_other_table = habtm_other_reflection.klass.arel_table if habtm_other_reflection
|
316
|
+
|
317
|
+
if (habtm_other_table || table).name == foreign_table.name
|
318
|
+
wrapper_scope = build_wrapper_scope_for_recursive_association(habtm_other_reflection || reflection)
|
319
|
+
foreign_table = ALIAS_TABLE
|
320
|
+
end
|
321
|
+
|
322
|
+
constraints = table[key].eq(foreign_table[foreign_key])
|
323
|
+
|
324
|
+
if reflection.type
|
325
|
+
# Handing of the polymorphic has_many/has_one's type column
|
326
|
+
constraints = constraints.and(table[reflection.type].eq(foreign_klass.base_class.name))
|
327
|
+
end
|
328
|
+
|
329
|
+
[wrapper_scope, constraints]
|
330
|
+
end
|
331
|
+
|
332
|
+
def self.has_and_belongs_to_many?(reflection) # rubocop:disable Naming/PredicateName
|
333
|
+
parent = ActiveRecordCompat.parent_reflection(reflection)
|
334
|
+
parent && parent.macro == :has_and_belongs_to_many
|
335
|
+
end
|
336
|
+
end
|
337
|
+
end
|
@@ -0,0 +1,177 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "active_record_compat"
|
4
|
+
require_relative "exceptions"
|
5
|
+
|
6
|
+
module ActiveRecordWhereAssoc
|
7
|
+
module QueryMethods
|
8
|
+
# Returns a new relation, which is the result of filtering the current relation
|
9
|
+
# based on if a record for the specified association of the model exists. Conditions
|
10
|
+
# the associated model must match to count as existing can also be specified.
|
11
|
+
#
|
12
|
+
# Here is a quick overview of the arguments received followed by a detailed explanation
|
13
|
+
# along with more examples. You may also consider viewing the gem's README. It contains
|
14
|
+
# known issues and some tips. The readme is packaged with the gem and viewable on github:
|
15
|
+
# https://github.com/MaxLap/activerecord_where_assoc
|
16
|
+
#
|
17
|
+
#
|
18
|
+
# As 1st argument, you must specify the association to check against. This can be
|
19
|
+
# any of the associations on the current relation's model.
|
20
|
+
#
|
21
|
+
# # Posts that have at least one comment
|
22
|
+
# Post.where_assoc_exists(:comments)
|
23
|
+
#
|
24
|
+
# As 2nd argument, you can add conditions that the records in the association must match
|
25
|
+
# to be considered as existing.
|
26
|
+
#
|
27
|
+
# The 3rd argument is for options that alter how the query is generated.
|
28
|
+
#
|
29
|
+
# If your conditions are too complex or too long to be placed in the 2nd argument,
|
30
|
+
# #where_assoc_* accepts a block in which you can do anything you want on the relation
|
31
|
+
# (any scoping method such as #where, #joins, nested #where_assoc_*, scopes of the model).
|
32
|
+
#
|
33
|
+
# === the association argument (1st argument)
|
34
|
+
#
|
35
|
+
# This is the association you want to check if records exists. If you want, you can pass
|
36
|
+
# an array of associations. They will be followed in order, just like a has_many :through
|
37
|
+
# would.
|
38
|
+
#
|
39
|
+
# # Posts with at least one comment
|
40
|
+
# Post.where_assoc_exists(:comments)
|
41
|
+
#
|
42
|
+
# # Posts for which there is at least one reply to a comment.
|
43
|
+
# Post.where_assoc_exists([:comments, :replies])
|
44
|
+
#
|
45
|
+
# Note that if you use conditions / blocks, they will only be applied to the last
|
46
|
+
# association of the array. If you want something else, you will need to use
|
47
|
+
# the block argument to nest multiple calls to #where_assoc_exists
|
48
|
+
#
|
49
|
+
# # Post.where_assoc_exists(:comments) { where_assoc_exists(:replies) }
|
50
|
+
#
|
51
|
+
# === the condition argument (2nd argument)
|
52
|
+
#
|
53
|
+
# This argument is additional conditions the association's records must fulfill to be
|
54
|
+
# considered as "existing". The argument is passed directly to #where.
|
55
|
+
#
|
56
|
+
# # Posts that have at least one comment considered as spam
|
57
|
+
# # Using a Hash
|
58
|
+
# Post.where_assoc_exists(:comments, spam_flag: true)
|
59
|
+
#
|
60
|
+
# # Using a String
|
61
|
+
# Post.where_assoc_exists(:comments, "spam_flag = true")
|
62
|
+
#
|
63
|
+
# # Using an Array (a string and its binds)
|
64
|
+
# Post.where_assoc_exists(:comments, ["spam_flag = ?", true])
|
65
|
+
#
|
66
|
+
# If the condition argument is blank, it is ignored (just like #where does).
|
67
|
+
#
|
68
|
+
# === the options argument (3rd argument)
|
69
|
+
#
|
70
|
+
# Some options are available to tweak how things queries are generated. In some case, this
|
71
|
+
# also changes the results of the query.
|
72
|
+
#
|
73
|
+
# ignore_limit: when true, #limit and #offset that are set either from default_scope or
|
74
|
+
# on associations are ignored. #has_one means #limit(1), so this makes
|
75
|
+
# #has_one be treated like #has_many.
|
76
|
+
#
|
77
|
+
# never_alias_limit: when true, #where_assoc_* will not use #from to build relations that
|
78
|
+
# have #limit or #offset set on default_scope or on associations.
|
79
|
+
# Note, #has_one means #limit(1), so it will also use #from unless this
|
80
|
+
# option is activated.
|
81
|
+
#
|
82
|
+
# === the block
|
83
|
+
#
|
84
|
+
# The block is used to add more complex conditions. The result behaves the same way
|
85
|
+
# as the 2nd argument's conditions, but lets you use any scoping methods, such as
|
86
|
+
# #where, #joins, # nested #where_assoc_* and scopes of the model. Note that using
|
87
|
+
# #joins might lead to unexpected results when using #where_assoc_count, since if
|
88
|
+
# the joins adds rows, it will change the resulting count.
|
89
|
+
#
|
90
|
+
# There are 2 ways of using the block for adding conditions to the association.
|
91
|
+
#
|
92
|
+
# * A block that receives one argument
|
93
|
+
# The block receives a relation on the target association and return a relation with added
|
94
|
+
# filters or may return nil to do nothing.
|
95
|
+
#
|
96
|
+
# # Using a where for the added condition
|
97
|
+
# Post.where_assoc_exists(:comments) { |comments| comments.where(spam_flag: true) }
|
98
|
+
#
|
99
|
+
# # Applying a scope of the relation
|
100
|
+
# Post.where_assoc_exists(:comments) { |comments| comments.spam_flagged }
|
101
|
+
#
|
102
|
+
# # Applying a scope of the relation, using the &:shortcut for procs
|
103
|
+
# Post.where_assoc_exists(:comments, &:spam_flagged)
|
104
|
+
#
|
105
|
+
#
|
106
|
+
# * A block that receives no argument
|
107
|
+
# Instead of receiving the relation as argument, the relation is used as the "self" of
|
108
|
+
# the block. Everything else is identical to the block with one argument.
|
109
|
+
#
|
110
|
+
# # Using a where for the added condition
|
111
|
+
# Post.where_assoc_exists(:comments) { where(spam_flag: true) }
|
112
|
+
#
|
113
|
+
# # Applying a scope of the relation
|
114
|
+
# Post.where_assoc_exists(:comments) { spam_flagged }
|
115
|
+
#
|
116
|
+
# The main reason to use a block with an argument instead of without is when you need
|
117
|
+
# to call methods on the self outside of the block, such as:
|
118
|
+
#
|
119
|
+
# Post.where_assoc_exists(:comments) { |comments| comments.where(id: self.something) }
|
120
|
+
#
|
121
|
+
def where_assoc_exists(association_name, given_scope = nil, options = {}, &block)
|
122
|
+
ActiveRecordWhereAssoc::CoreLogic.do_where_assoc_exists(self, association_name, given_scope, options, &block)
|
123
|
+
end
|
124
|
+
|
125
|
+
# Returns a new relation, which is the result of filtering the current relation
|
126
|
+
# based on if a record for the specified association of the model doesn't exist.
|
127
|
+
# Conditions the associated model must match to count as existing can also be specified.
|
128
|
+
#
|
129
|
+
# The parameters and everything is identical to #where_assoc_exists. The only
|
130
|
+
# difference is that a record is matched if no matching association record that
|
131
|
+
# fulfill the conditions are found.
|
132
|
+
def where_assoc_not_exists(association_name, given_scope = nil, options = {}, &block)
|
133
|
+
ActiveRecordWhereAssoc::CoreLogic.do_where_assoc_not_exists(self, association_name, given_scope, options, &block)
|
134
|
+
end
|
135
|
+
|
136
|
+
# Returns a new relation, which is the result of filtering the current relation
|
137
|
+
# based on how many records for the specified association of the model exists. Conditions
|
138
|
+
# the associated model must match can also be specified.
|
139
|
+
#
|
140
|
+
# #where_assoc_count is a generalization of #where_assoc_exists and #where_assoc_not_exists.
|
141
|
+
# It behave behaves the same way as them, but is more flexible as it allows you to be
|
142
|
+
# specific about how many matches there should be. To clarify, here are equivalent examples:
|
143
|
+
#
|
144
|
+
# Post.where_assoc_exists(:comments)
|
145
|
+
# Post.where_assoc_count(1, :<=, :comments)
|
146
|
+
#
|
147
|
+
# Post.where_assoc_not_exists(:comments)
|
148
|
+
# Post.where_assoc_count(0, :==, :comments)
|
149
|
+
#
|
150
|
+
# The usage is the same as with #where_assoc_exists, however, 2 arguments are inserted
|
151
|
+
# at the beginning.
|
152
|
+
#
|
153
|
+
# 1st argument: a number or any string of SQL to embed in the query used for the left
|
154
|
+
# operand of the comparison.
|
155
|
+
# 2nd argument: the operator to use: :<, :<=, :==, :!=, :>=, :>
|
156
|
+
# 3rd, 4th and 5th arguments: same as #where_assoc_exists' 1st, 2nd and 3rd arguments
|
157
|
+
# block: same as #where_assoc_exists' block
|
158
|
+
#
|
159
|
+
# The order of the parameters may seem confusing. But you will get used to it. To help
|
160
|
+
# remember the order of the parameters, remember that the goal is to do:
|
161
|
+
# 5 < (SELECT COUNT(*) FROM ...)
|
162
|
+
# So the parameters are in the same order as in that query: number, operator, association.
|
163
|
+
#
|
164
|
+
# To be clear, when you use multiple associations in an array, the count you will be
|
165
|
+
# comparing against is the total number of records of that last association.
|
166
|
+
#
|
167
|
+
# # The users that have received at least 5 comments total on all of their posts
|
168
|
+
# # So this can be one post that has 5 comments of 5 posts with 1 comments
|
169
|
+
# User.where_assoc_count(5, :<=, [:posts, :comments])
|
170
|
+
#
|
171
|
+
# # The users that have at least 5 posts with at least one comments
|
172
|
+
# User.where_assoc_count(5, :<=, :posts) { where_assoc_exists(:comments) }
|
173
|
+
def where_assoc_count(left_operand, operator, association_name, given_scope = nil, options = {}, &block)
|
174
|
+
ActiveRecordWhereAssoc::CoreLogic.do_where_assoc_count(self, left_operand, operator, association_name, given_scope, options, &block)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
metadata
ADDED
@@ -0,0 +1,181 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: activerecord_where_assoc
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Maxime Handfield Lapointe
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-04-14 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 4.1.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 4.1.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.15'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.15'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: minitest
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '5.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '5.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: pry
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rake
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '10.0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '10.0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: coveralls
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: rubocop
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - '='
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: 0.54.0
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - '='
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: 0.54.0
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: simplecov
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: sqlite3
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
description: 'Adds various #where_assoc_* methods to ActiveRecord to make it easy
|
140
|
+
to do correct conditions on the associations of the model being queried.'
|
141
|
+
email:
|
142
|
+
- maxhlap@gmail.com
|
143
|
+
executables: []
|
144
|
+
extensions: []
|
145
|
+
extra_rdoc_files: []
|
146
|
+
files:
|
147
|
+
- LICENSE.txt
|
148
|
+
- README.md
|
149
|
+
- lib/active_record_where_assoc.rb
|
150
|
+
- lib/active_record_where_assoc/active_record_compat.rb
|
151
|
+
- lib/active_record_where_assoc/core_logic.rb
|
152
|
+
- lib/active_record_where_assoc/exceptions.rb
|
153
|
+
- lib/active_record_where_assoc/query_methods.rb
|
154
|
+
- lib/active_record_where_assoc/querying.rb
|
155
|
+
- lib/active_record_where_assoc/version.rb
|
156
|
+
- lib/activerecord_where_assoc.rb
|
157
|
+
homepage: https://github.com/MaxLap/activerecord_where_assoc
|
158
|
+
licenses:
|
159
|
+
- MIT
|
160
|
+
metadata: {}
|
161
|
+
post_install_message:
|
162
|
+
rdoc_options: []
|
163
|
+
require_paths:
|
164
|
+
- lib
|
165
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
166
|
+
requirements:
|
167
|
+
- - ">="
|
168
|
+
- !ruby/object:Gem::Version
|
169
|
+
version: '0'
|
170
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
171
|
+
requirements:
|
172
|
+
- - ">="
|
173
|
+
- !ruby/object:Gem::Version
|
174
|
+
version: '0'
|
175
|
+
requirements: []
|
176
|
+
rubyforge_project:
|
177
|
+
rubygems_version: 2.6.11
|
178
|
+
signing_key:
|
179
|
+
specification_version: 4
|
180
|
+
summary: Make ActiveRecord do conditions on your associations
|
181
|
+
test_files: []
|