activerecord_follow_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 +152 -0
- data/lib/active_record_follow_assoc/active_record_compat.rb +95 -0
- data/lib/active_record_follow_assoc/core_logic.rb +239 -0
- data/lib/active_record_follow_assoc/exceptions.rb +6 -0
- data/lib/active_record_follow_assoc/query_methods.rb +77 -0
- data/lib/active_record_follow_assoc/version.rb +3 -0
- data/lib/active_record_follow_assoc.rb +6 -0
- data/lib/activerecord_follow_assoc.rb +29 -0
- metadata +196 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 4b8caccb83e94c7443d397e3821ff1e82bd7b97e70eaf4069a35199cec86e4d5
|
4
|
+
data.tar.gz: 974c2775d84c5d1863288bab1d25844c46e3a068502d56885ff0e8b061f61bd4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6a24b54072e35365c17cc80a6b112fabab2e535b42f2d6063065fafd1defbd02cb619638d06bfbddb7ac080a74b6476d033d075075fcbeee96fbe7c85f755608
|
7
|
+
data.tar.gz: 27a15ff6481ec90f3dba7d71b1f8b13e82bec4a56918735edf2676f1b6f7fef971372336f881e83a1422cfb79e88a358f36501080ad26395e0635d9ea0685b1b
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2021 Maxime 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,152 @@
|
|
1
|
+
This gem is still a work in progress. It hasn't been released yet.
|
2
|
+
|
3
|
+
# ActiveRecord Follow Assoc
|
4
|
+
|
5
|
+
![Test supported versions](https://github.com/MaxLap/activerecord_follow_assoc/workflows/Test%20supported%20versions/badge.svg)
|
6
|
+
|
7
|
+
Let's say that, in your Rails app, you want to get all of the comments to the recent posts the
|
8
|
+
current user made.
|
9
|
+
|
10
|
+
Think of how you would do it.
|
11
|
+
|
12
|
+
Here's how this gem allows you to do it:
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
current_user.posts.recent.follow_assoc(:comments)
|
16
|
+
```
|
17
|
+
|
18
|
+
The `follow_assoc` method, added by this gem allows you to query the specified association
|
19
|
+
of the records that the current query would return.
|
20
|
+
|
21
|
+
Here is a more complete [introduction to this gem](INTRODUCTION.md).
|
22
|
+
|
23
|
+
Benefits of `follow_assoc`:
|
24
|
+
* Works the same way for all kinds of association `belongs_to`, `has_many`, `has_one`, `has_and_belongs_to_many`
|
25
|
+
* You can use `where`, `order` and other such methods on the result
|
26
|
+
* By nesting SQL queries, the only records that need to be loaded are the final ones, so the above example
|
27
|
+
wouldn't have loaded any `Post` from the database. This usually leads to faster code.
|
28
|
+
* You avoid many [problems with the alternative options](ALTERNATIVES_PROBLEMS.md).
|
29
|
+
|
30
|
+
## Why / when do you need this?
|
31
|
+
|
32
|
+
As applications grow, you can end up with quite complex data model and even more complex business rules. You may end up
|
33
|
+
needing to fetch records that are deep in your associations.
|
34
|
+
|
35
|
+
As a simple example, let's say you have a helper which receives sections of a blog and must return the recent comments
|
36
|
+
in those sections.
|
37
|
+
```ruby
|
38
|
+
def recent_comments_within(sections)
|
39
|
+
sections.follow_assoc(:posts, :comments).recent
|
40
|
+
end
|
41
|
+
```
|
42
|
+
|
43
|
+
Note that this won't work if `sections` is an `Array`. `follow_assoc` is available in the same places as `where`. See [Usage](#Usage) for details.
|
44
|
+
|
45
|
+
Doing this without follow_assoc can be verbose, error-prone and less efficient depending on the approach taken.
|
46
|
+
|
47
|
+
## Installation
|
48
|
+
|
49
|
+
**This is not released yet. This won't work.**
|
50
|
+
Rails 4.1 to 6.1 are supported with Ruby 2.1 to 3.0. Tested against SQLite3, PostgreSQL and MySQL. The gem
|
51
|
+
only depends on the `activerecord` gem.
|
52
|
+
|
53
|
+
Add this line to your application's Gemfile:
|
54
|
+
|
55
|
+
```ruby
|
56
|
+
gem 'activerecord_follow_assoc'
|
57
|
+
```
|
58
|
+
|
59
|
+
And then execute:
|
60
|
+
|
61
|
+
$ bundle install
|
62
|
+
|
63
|
+
Or install it yourself with:
|
64
|
+
|
65
|
+
$ gem install activerecord_follow_assoc
|
66
|
+
|
67
|
+
## Usage
|
68
|
+
|
69
|
+
Starting from a query or a model, you call `follow_assoc` with an association's name. It returns another query that:
|
70
|
+
|
71
|
+
* searches in the association's model
|
72
|
+
* has a `where` to only return the records that are associated with the records that the initial query would have returned.
|
73
|
+
|
74
|
+
So `my_comments.follow_assoc(:posts)` gives you a query on `Post` which only returns the posts that are
|
75
|
+
associated to the records of `my_comments`.
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
# Getting the spam comments to posts by a specific author
|
79
|
+
spam_comments = author.posts.follow_assoc(:comments).spam
|
80
|
+
```
|
81
|
+
|
82
|
+
As a shortcut, you can also give multiple association to `follow_assoc`. Doing so is equivalent to consecutive calls to it.
|
83
|
+
```ruby
|
84
|
+
# Getting the spam comments to posts in some sections
|
85
|
+
spam_comments_in_section = my_sections.follow_assoc(:posts, :comments).spam
|
86
|
+
# Equivalent to
|
87
|
+
spam_comments_in_section = my_sections.follow_assoc(:posts).follow_assoc(:comments).spam
|
88
|
+
```
|
89
|
+
|
90
|
+
The `follow_assoc` method is only available on models and queries (also often called relation or scope). You cannot use
|
91
|
+
it on an `Array` of record. If you need to use `follow_assoc` in that situation, then you must make a query yourself:
|
92
|
+
```ruby
|
93
|
+
sections_query = Section.where(id: my_sections)
|
94
|
+
# Then you can use `follow_assoc`
|
95
|
+
spam_comments_in_section = sections_query.follow_assoc(:posts, :comments).spam
|
96
|
+
```
|
97
|
+
|
98
|
+
Detailed doc is [here](https://maxlap.dev/activerecord_follow_assoc/ActiveRecordFollowAssoc/QueryMethods.html).
|
99
|
+
|
100
|
+
## Known issues
|
101
|
+
|
102
|
+
**No support for recursive has_one**
|
103
|
+
|
104
|
+
The SQL to handle recursive has_one while isolating the different layers of conditions is a mess and I worry about
|
105
|
+
the resulting performance. So for now, this will raise an exception. You can use the `ignore_limit: true` option
|
106
|
+
to treat the has_one as a has_many.
|
107
|
+
|
108
|
+
**MySQL doesn't support sub-limit**
|
109
|
+
|
110
|
+
On MySQL databases, it is not possible to use has_one associations.
|
111
|
+
|
112
|
+
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.
|
113
|
+
|
114
|
+
In order to work around this, you must use the `ignore_limit: true` option, which means that the `has_one` will be treated
|
115
|
+
like a `has_many`.
|
116
|
+
|
117
|
+
## Another recommended gem
|
118
|
+
|
119
|
+
If you feel a need for this gem's feature, you may also be interested in another gem I made: [activerecord_where_assoc](https://github.com/MaxLap/activerecord_where_assoc).
|
120
|
+
|
121
|
+
It allows you to make conditions based on your associations (without changing the kind of objects returned). For simple cases, it's possible that both can build the query your need, but each can handle different situations. Here is an example:
|
122
|
+
|
123
|
+
```ruby
|
124
|
+
# Find every posts that have comments by an admin
|
125
|
+
Post.where_assoc_exists([:comments, :author], &:admins)
|
126
|
+
```
|
127
|
+
|
128
|
+
This could be done with `follow_assoc`: `User.admins.follow_assoc(:comments, :post)`. But if you wanted conditions on
|
129
|
+
a second association, then `follow_assoc` wouldn't work. It all depends on the context where you need to do the query
|
130
|
+
and what starting point you have.
|
131
|
+
|
132
|
+
## Development
|
133
|
+
|
134
|
+
After checking out the repo, run `bundle install` to install dependencies. Then, run `rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
135
|
+
|
136
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
137
|
+
|
138
|
+
## Contributing
|
139
|
+
|
140
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/MaxLap/activerecord_follow_assoc.
|
141
|
+
|
142
|
+
|
143
|
+
## License
|
144
|
+
|
145
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
146
|
+
|
147
|
+
## Code of Conduct
|
148
|
+
|
149
|
+
Everyone interacting in the ActiveRecordFollowAssoc project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/MaxLap/activerecord_follow_assoc/blob/master/CODE_OF_CONDUCT.md).
|
150
|
+
|
151
|
+
|
152
|
+
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveRecordFollowAssoc
|
4
|
+
module ActiveRecordCompat
|
5
|
+
if ActiveRecord.gem_version >= Gem::Version.new("6.1.0.rc1")
|
6
|
+
JoinKeys = Struct.new(:key, :foreign_key)
|
7
|
+
def self.join_keys(reflection, poly_belongs_to_klass)
|
8
|
+
if poly_belongs_to_klass
|
9
|
+
JoinKeys.new(reflection.join_primary_key(poly_belongs_to_klass), reflection.join_foreign_key)
|
10
|
+
else
|
11
|
+
JoinKeys.new(reflection.join_primary_key, reflection.join_foreign_key)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
elsif ActiveRecord.gem_version >= Gem::Version.new("5.1")
|
16
|
+
def self.join_keys(reflection, poly_belongs_to_klass)
|
17
|
+
if poly_belongs_to_klass
|
18
|
+
reflection.get_join_keys(poly_belongs_to_klass)
|
19
|
+
else
|
20
|
+
reflection.join_keys
|
21
|
+
end
|
22
|
+
end
|
23
|
+
elsif ActiveRecord.gem_version >= Gem::Version.new("4.2")
|
24
|
+
def self.join_keys(reflection, poly_belongs_to_klass)
|
25
|
+
reflection.join_keys(poly_belongs_to_klass || reflection.klass)
|
26
|
+
end
|
27
|
+
else
|
28
|
+
# 4.1 change that introduced JoinKeys:
|
29
|
+
# https://github.com/rails/rails/commit/5823e429981dc74f8f53187d2ab573823381bf28#diff-523caff658498027f61cae9d91c8503dL108
|
30
|
+
JoinKeys = Struct.new(:key, :foreign_key)
|
31
|
+
def self.join_keys(reflection, poly_belongs_to_klass)
|
32
|
+
if reflection.source_macro == :belongs_to
|
33
|
+
key = reflection.association_primary_key(poly_belongs_to_klass)
|
34
|
+
foreign_key = reflection.foreign_key
|
35
|
+
else
|
36
|
+
key = reflection.foreign_key
|
37
|
+
foreign_key = reflection.active_record_primary_key
|
38
|
+
end
|
39
|
+
|
40
|
+
JoinKeys.new(key, foreign_key)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
if ActiveRecord.gem_version >= Gem::Version.new("5.0")
|
45
|
+
def self.chained_reflection_and_chained_constraints(reflection)
|
46
|
+
pairs = reflection.chain.map do |ref|
|
47
|
+
# PolymorphicReflection is a super weird thing. Like a partial reflection, I don't get it.
|
48
|
+
# Seems like just bypassing it works for our needs.
|
49
|
+
# When doing a has_many through that has a polymorphic source and a source_type, this ends up
|
50
|
+
# part of the chain instead of the regular HasManyReflection that one would expect.
|
51
|
+
ref = ref.instance_variable_get(:@reflection) if ref.is_a?(ActiveRecord::Reflection::PolymorphicReflection)
|
52
|
+
|
53
|
+
[ref, ref.constraints]
|
54
|
+
end
|
55
|
+
|
56
|
+
pairs.transpose
|
57
|
+
end
|
58
|
+
else
|
59
|
+
def self.chained_reflection_and_chained_constraints(reflection)
|
60
|
+
[reflection.chain, reflection.scope_chain]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
if ActiveRecord.gem_version >= Gem::Version.new("5.0")
|
65
|
+
def self.parent_reflection(reflection)
|
66
|
+
reflection.parent_reflection
|
67
|
+
end
|
68
|
+
else
|
69
|
+
def self.parent_reflection(reflection)
|
70
|
+
_parent_name, parent_refl = reflection.parent_reflection
|
71
|
+
parent_refl
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
if ActiveRecord.gem_version >= Gem::Version.new("4.2")
|
76
|
+
def self.normalize_association_name(association_name)
|
77
|
+
association_name.to_s
|
78
|
+
end
|
79
|
+
else
|
80
|
+
def self.normalize_association_name(association_name)
|
81
|
+
association_name.to_sym
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
if ActiveRecord.gem_version >= Gem::Version.new("5.0")
|
86
|
+
def self.through_reflection?(reflection)
|
87
|
+
reflection.through_reflection?
|
88
|
+
end
|
89
|
+
else
|
90
|
+
def self.through_reflection?(reflection)
|
91
|
+
reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,239 @@
|
|
1
|
+
require_relative "active_record_compat"
|
2
|
+
|
3
|
+
module ActiveRecordFollowAssoc
|
4
|
+
module CoreLogic
|
5
|
+
# Arel table used for aliasing when handling recursive associations (such as parent/children)
|
6
|
+
ALIAS_TABLE = Arel::Table.new("_ar_follow_assoc_alias_")
|
7
|
+
|
8
|
+
# Returns the SQL for checking if any of the received relation exists.
|
9
|
+
# Uses a OR if there are multiple relations.
|
10
|
+
# => "EXISTS (SELECT... *relation1*) OR EXISTS (SELECT... *relation2*)"
|
11
|
+
def self.sql_for_any_exists(relations)
|
12
|
+
relations = [relations] unless relations.is_a?(Array)
|
13
|
+
relations = relations.reject { |rel| rel.is_a?(ActiveRecord::NullRelation) }
|
14
|
+
sqls = relations.map { |rel| "EXISTS (#{rel.select('1').to_sql})" }
|
15
|
+
if sqls.size > 1
|
16
|
+
"(#{sqls.join(" OR ")})" # Parens needed when embedding the sql in a `where`, because the OR could make things wrong
|
17
|
+
elsif sqls.size == 1
|
18
|
+
sqls.first
|
19
|
+
else
|
20
|
+
"0=1"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
|
25
|
+
def self.follow_assoc(relation, association_names, options_for_last_assoc = {})
|
26
|
+
association_names[0...-1].each do |association_name|
|
27
|
+
relation = follow_one_assoc(relation, association_name)
|
28
|
+
end
|
29
|
+
follow_one_assoc(relation, association_names.last, options_for_last_assoc)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.follow_one_assoc(relation, association_name, options = {})
|
33
|
+
reflection = fetch_reflection(relation, association_name)
|
34
|
+
|
35
|
+
if reflection.scope && reflection.scope.arity != 0
|
36
|
+
raise ArgumentError, <<-MSG.squish
|
37
|
+
The association scope '#{name}' is instance dependent (the scope
|
38
|
+
block takes an argument). Following instance dependent scopes is
|
39
|
+
not supported.
|
40
|
+
MSG
|
41
|
+
end
|
42
|
+
|
43
|
+
reflection_chain, constraints_chain = ActiveRecordFollowAssoc::ActiveRecordCompat.chained_reflection_and_chained_constraints(reflection)
|
44
|
+
|
45
|
+
# Chained stuff is in reverse order, we want it in forward order
|
46
|
+
reflection_chain = reflection_chain.reverse
|
47
|
+
constraints_chain = constraints_chain.reverse
|
48
|
+
|
49
|
+
reflection_chain.each_with_index do |sub_reflection, i|
|
50
|
+
klass = class_for_reflection(sub_reflection, options[:poly_belongs_to])
|
51
|
+
alias_scope, join_constraints = wrapper_and_join_constraints(sub_reflection, options[:poly_belongs_to])
|
52
|
+
|
53
|
+
constraints_relation = resolve_constraints(sub_reflection, klass, constraints_chain[i])
|
54
|
+
constraints_relation = constraints_relation.unscope(:limit, :offset, :order) if option_value(options, :ignore_limit)
|
55
|
+
|
56
|
+
if constraints_relation.limit_value
|
57
|
+
if alias_scope
|
58
|
+
raise "#{sub_reflection.name} is a recursive has_one, this is not supported by follow_assoc."
|
59
|
+
end
|
60
|
+
sub_relation = constraints_relation.where(join_constraints).unscope(:select).select(klass.primary_key)
|
61
|
+
|
62
|
+
relation = relation.joins(sub_reflection.name)
|
63
|
+
.unscope(:select)
|
64
|
+
.select("#{klass.quoted_table_name}.*")
|
65
|
+
.where("#{klass.quoted_table_name}.#{klass.quoted_primary_key} IN (#{sub_relation.to_sql})")
|
66
|
+
|
67
|
+
relation = klass.unscoped.from("(#{relation.to_sql}) #{klass.quoted_table_name}")
|
68
|
+
else
|
69
|
+
if alias_scope
|
70
|
+
relation = alias_scope.where(sql_for_any_exists(relation.where(join_constraints)))
|
71
|
+
join_constraints = nil
|
72
|
+
end
|
73
|
+
|
74
|
+
relation = klass.unscoped.where(sql_for_any_exists(relation.where(join_constraints)))
|
75
|
+
relation = relation.merge(constraints_relation)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
relation
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.fetch_reflection(relation_klass, association_name)
|
83
|
+
association_name = ActiveRecordCompat.normalize_association_name(association_name)
|
84
|
+
reflection = relation_klass._reflections[association_name]
|
85
|
+
|
86
|
+
if reflection.nil?
|
87
|
+
# Need a fake record because this exception expects a record...
|
88
|
+
raise ActiveRecord::AssociationNotFoundError.new(relation_klass.new, association_name)
|
89
|
+
end
|
90
|
+
|
91
|
+
reflection
|
92
|
+
end
|
93
|
+
|
94
|
+
def self.wrapper_and_join_constraints(reflection, poly_belongs_to_klass = nil)
|
95
|
+
join_keys = ActiveRecordCompat.join_keys(reflection, poly_belongs_to_klass)
|
96
|
+
|
97
|
+
key = join_keys.key
|
98
|
+
foreign_key = join_keys.foreign_key
|
99
|
+
|
100
|
+
table = (poly_belongs_to_klass || reflection.klass).arel_table
|
101
|
+
foreign_klass = reflection.send(:actual_source_reflection).active_record
|
102
|
+
foreign_table = foreign_klass.arel_table
|
103
|
+
|
104
|
+
if table.name == foreign_table.name
|
105
|
+
alias_scope = build_alias_scope_for_recursive_association(reflection, poly_belongs_to_klass)
|
106
|
+
table = ALIAS_TABLE
|
107
|
+
end
|
108
|
+
|
109
|
+
constraints = table[key].eq(foreign_table[foreign_key])
|
110
|
+
|
111
|
+
if reflection.type
|
112
|
+
# Handling of the polymorphic has_many/has_one's type column
|
113
|
+
constraints = constraints.and(table[reflection.type].eq(foreign_klass.base_class.name))
|
114
|
+
end
|
115
|
+
|
116
|
+
if poly_belongs_to_klass
|
117
|
+
constraints = constraints.and(foreign_table[reflection.foreign_type].eq(poly_belongs_to_klass.base_class.name))
|
118
|
+
end
|
119
|
+
|
120
|
+
[alias_scope, constraints]
|
121
|
+
end
|
122
|
+
|
123
|
+
def self.resolve_constraints(reflection, klass, constraints)
|
124
|
+
relation = klass.default_scoped
|
125
|
+
assoc_scope_allowed_lim_off = assoc_scope_to_keep_lim_off_from(reflection)
|
126
|
+
|
127
|
+
constraints.each do |callable|
|
128
|
+
assoc_constraint_relation = klass.unscoped.instance_exec(nil, &callable)
|
129
|
+
|
130
|
+
if callable != assoc_scope_allowed_lim_off
|
131
|
+
# I just want to remove the current values without screwing things in the merge below
|
132
|
+
# so we cannot use #unscope
|
133
|
+
assoc_constraint_relation.limit_value = nil
|
134
|
+
assoc_constraint_relation.offset_value = nil
|
135
|
+
assoc_constraint_relation.order_values = []
|
136
|
+
end
|
137
|
+
|
138
|
+
# Need to use merge to replicate the Last Equality Wins behavior of associations
|
139
|
+
# https://github.com/rails/rails/issues/7365
|
140
|
+
relation = relation.merge(assoc_constraint_relation)
|
141
|
+
end
|
142
|
+
|
143
|
+
relation = relation.limit(1) if reflection.macro == :has_one
|
144
|
+
|
145
|
+
if user_defined_actual_source_reflection(reflection).macro == :belongs_to
|
146
|
+
relation = relation.unscope(:limit, :offset, :order)
|
147
|
+
end
|
148
|
+
relation
|
149
|
+
end
|
150
|
+
|
151
|
+
def self.build_alias_scope_for_recursive_association(reflection, poly_belongs_to_klass)
|
152
|
+
klass = poly_belongs_to_klass || reflection.klass
|
153
|
+
table = klass.arel_table
|
154
|
+
primary_key = klass.primary_key
|
155
|
+
foreign_klass = reflection.send(:actual_source_reflection).active_record
|
156
|
+
|
157
|
+
alias_scope = foreign_klass.base_class.unscoped
|
158
|
+
alias_scope = alias_scope.from("#{table.name} #{ALIAS_TABLE.name}")
|
159
|
+
alias_scope = alias_scope.where(table[primary_key].eq(ALIAS_TABLE[primary_key]))
|
160
|
+
alias_scope
|
161
|
+
end
|
162
|
+
|
163
|
+
def self.class_for_reflection(reflection, on_poly_belongs_to)
|
164
|
+
actual_source_reflection = user_defined_actual_source_reflection(reflection)
|
165
|
+
|
166
|
+
if poly_belongs_to?(actual_source_reflection)
|
167
|
+
if reflection.options[:source_type]
|
168
|
+
[reflection.options[:source_type].safe_constantize].compact
|
169
|
+
else
|
170
|
+
if on_poly_belongs_to.nil?
|
171
|
+
msg = String.new
|
172
|
+
if actual_source_reflection == reflection
|
173
|
+
msg << "Association #{reflection.name.inspect} is a polymorphic belongs_to. "
|
174
|
+
else
|
175
|
+
msg << "Association #{reflection.name.inspect} is a :through relation that uses a polymorphic belongs_to"
|
176
|
+
msg << "#{actual_source_reflection.name.inspect} as source without without a source_type. "
|
177
|
+
end
|
178
|
+
msg << "This is not supported by ActiveRecord when doing joins, but it is by FollowAssoc. However, "
|
179
|
+
msg << "you must pass the :poly_belongs_to option to specify what to do in this case.\n"
|
180
|
+
msg << "See the :poly_belongs_to option at https://maxlap.dev/activerecord_follow_assoc/ActiveRecordFollowAssoc/QueryMethods.html"
|
181
|
+
raise ActiveRecordFollowAssoc::PolymorphicBelongsToWithoutClasses, msg
|
182
|
+
elsif on_poly_belongs_to.is_a?(Class) && on_poly_belongs_to < ActiveRecord::Base
|
183
|
+
on_poly_belongs_to
|
184
|
+
else
|
185
|
+
raise ArgumentError, "Received a bad value for :poly_belongs_to: #{on_poly_belongs_to.inspect}"
|
186
|
+
end
|
187
|
+
end
|
188
|
+
else
|
189
|
+
reflection.klass
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
# Returns the deepest user-defined reflection using source_reflection.
|
194
|
+
# This is different from #send(:actual_source_reflection) because it stops on
|
195
|
+
# has_and_belongs_to_many associations, where as actual_source_reflection would continue
|
196
|
+
# down to the belongs_to that is used internally.
|
197
|
+
def self.user_defined_actual_source_reflection(reflection)
|
198
|
+
loop do
|
199
|
+
return reflection if reflection == reflection.source_reflection
|
200
|
+
return reflection if has_and_belongs_to_many?(reflection)
|
201
|
+
reflection = reflection.source_reflection
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
def self.assoc_scope_to_keep_lim_off_from(reflection)
|
206
|
+
# For :through associations, it's pretty hard/tricky to apply limit/offset/order of the
|
207
|
+
# whole has_* :through. For now, we only apply those of the direct associations from one model
|
208
|
+
# to another that the :through uses and we ignore the limit/offset/order from the scope of has_* :through.
|
209
|
+
#
|
210
|
+
# The exception is for has_and_belongs_to_many, which behind the scene, use a has_many :through.
|
211
|
+
# For those, since we know there is no limits on the internal has_many and the belongs_to,
|
212
|
+
# we can do a special case and handle their limit. This way, we can treat them the same way we treat
|
213
|
+
# the other macros, we only apply the limit/offset/order of the deepest user-define association.
|
214
|
+
user_defined_actual_source_reflection(reflection).scope
|
215
|
+
end
|
216
|
+
|
217
|
+
# Gets the value from the options or fallback to default
|
218
|
+
def self.option_value(options, key)
|
219
|
+
options.fetch(key) { ActiveRecordFollowAssoc.default_options[key] }
|
220
|
+
end
|
221
|
+
|
222
|
+
def self.poly_belongs_to?(reflection)
|
223
|
+
reflection.macro == :belongs_to && reflection.options[:polymorphic]
|
224
|
+
end
|
225
|
+
|
226
|
+
# Return true if #user_defined_actual_source_reflection is a has_and_belongs_to_many
|
227
|
+
def self.actually_has_and_belongs_to_many?(reflection)
|
228
|
+
has_and_belongs_to_many?(user_defined_actual_source_reflection(reflection))
|
229
|
+
end
|
230
|
+
|
231
|
+
# Because we work using Model._reflections, we don't actually get the :has_and_belongs_to_many.
|
232
|
+
# Instead, we get a has_many :through, which is was ActiveRecord created behind the scene.
|
233
|
+
# This code detects that a :through is actually a has_and_belongs_to_many.
|
234
|
+
def self.has_and_belongs_to_many?(reflection) # rubocop:disable Naming/PredicateName
|
235
|
+
parent = ActiveRecordCompat.parent_reflection(reflection)
|
236
|
+
parent && parent.macro == :has_and_belongs_to_many
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# See QueryMethods
|
2
|
+
module ActiveRecordFollowAssoc
|
3
|
+
|
4
|
+
end
|
5
|
+
|
6
|
+
module ActiveRecordFollowAssoc::QueryMethods
|
7
|
+
# Query the specified association of the records that the current query would return.
|
8
|
+
#
|
9
|
+
# Returns a new relation (also known as a query) which:
|
10
|
+
# * targets the association's model.
|
11
|
+
# So +Post.follow_assoc(:comments)+ will return comments.
|
12
|
+
# * only returns the records that are associated with those that the receiver would return.
|
13
|
+
# So +Post.where(published: true).follow_assoc(:comments)+ only returns the comments of
|
14
|
+
# published posts.
|
15
|
+
#
|
16
|
+
# You could say this is a way of doing a +#flat_map+ of the association on the result
|
17
|
+
# of the current relation, but without loading the records of the first relation and
|
18
|
+
# without having to worry about eager loading.
|
19
|
+
#
|
20
|
+
# Examples (with equivalent +#flat_map+)
|
21
|
+
#
|
22
|
+
# # Comments of published posts
|
23
|
+
# Post.where(published: true).follow_assoc(:comments)
|
24
|
+
# # Somewhat equivalent to. (Need to use preload to avoid the N+1 query problem)
|
25
|
+
# Post.where(published: true).preload(:comments).flat_map(:comments)
|
26
|
+
#
|
27
|
+
# The main differences between the +#flat_map+ and +#follow_assoc+ approaches:
|
28
|
+
# * +#follow_assoc+ returns a relation (or query or scope, however you call it), so you can
|
29
|
+
# use other scoping methods, such as +#where+, +#limit+, +#order+.
|
30
|
+
# * +#flat_map+ returns an Array, so you cannot use other scoping methods.
|
31
|
+
# * +#flat_map+ must be used with eager loading. Forgetting to do so makes N+1 query likely.
|
32
|
+
# * +#follow_assoc+ only loads the final matched records.
|
33
|
+
# * +#flat_map+ loads every associations on the way, this is wasteful when you don't need them.
|
34
|
+
#
|
35
|
+
# [association_names]
|
36
|
+
# The first argument(s) are the associations that you want to follow. They are the names of
|
37
|
+
# your +#belongs_to+, +#has_many+, +#has_one+, +#has_and_belongs_to_many+.
|
38
|
+
#
|
39
|
+
# If you pass in more than one, they will be followed in order.
|
40
|
+
# Ex: +Post.follow_assoc(:comments, :author)+ gives you the authors of the comments of the posts.
|
41
|
+
#
|
42
|
+
# [options]
|
43
|
+
# Following are the options that can be passed as last argument.
|
44
|
+
#
|
45
|
+
# If you are passing multiple association_names, the options only affect the last association.
|
46
|
+
#
|
47
|
+
# [option :ignore_limit]
|
48
|
+
# When true, +#has_one+ will be treated like a +#has_many+.
|
49
|
+
#
|
50
|
+
# Main reasons to use ignore_limit: true
|
51
|
+
# * Needed for MySQL to be able to do anything with +#has_one+ associations because MySQL
|
52
|
+
# doesn't support sub-limit. <br>
|
53
|
+
# See {MySQL doesn't support limit}[https://github.com/MaxLap/activerecord_follow_assoc#mysql-doesnt-support-sub-limit] <br>
|
54
|
+
# Note, this does mean the +#has_one+ will be treated as if it was a +#has_many+ for MySQL too.
|
55
|
+
# * You have a +#has_one+ association which you know can never have more than one record and are
|
56
|
+
# dealing with a heavy/slow query. The query used to deal with +#has_many+ is less complex, and
|
57
|
+
# may prove faster.
|
58
|
+
# * For this one special case, you want to check the other records that match your has_one
|
59
|
+
#
|
60
|
+
# [option :poly_belongs_to]
|
61
|
+
# If the last association of association_names is a polymorphic belongs_to, then by default,
|
62
|
+
# +#follow_assoc+ will raise an exception. This is because there are many unrelated models
|
63
|
+
# that could be the one referred to by the records, but an ActiveRecord relation can only
|
64
|
+
# target a single Model.
|
65
|
+
#
|
66
|
+
# For this reason, you must choose which Model to "look into" when following a polymorphic
|
67
|
+
# belongs_to. This is what the :poly_belongs_to option does.
|
68
|
+
#
|
69
|
+
# For example, you can't just go from "Picture" and follow_assoc the polymorphic belongs_to
|
70
|
+
# association "imageable". But if what you are looking for is only the employees, then this works:
|
71
|
+
# employee_scope = pictures_scope.follow_assoc(:imageable, poly_belongs_to: Employee)
|
72
|
+
#
|
73
|
+
def follow_assoc(*association_names)
|
74
|
+
options = association_names.extract_options!
|
75
|
+
ActiveRecordFollowAssoc::CoreLogic.follow_assoc(self, association_names, options)
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,6 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Just in case of typo in the require. Call the right one automatically
|
4
|
+
# The gem name is activerecord_follow_assoc, so it's what gets required automatically
|
5
|
+
# But to fit with usual naming, since we write ActiveRecord, it means for namespacing, we use active_record_follow_assoc
|
6
|
+
require_relative "activerecord_follow_assoc"
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_follow_assoc/version"
|
4
|
+
require "active_record"
|
5
|
+
|
6
|
+
module ActiveRecordFollowAssoc
|
7
|
+
def self.default_options
|
8
|
+
@default_options ||= {
|
9
|
+
ignore_limit: false,
|
10
|
+
}
|
11
|
+
end
|
12
|
+
|
13
|
+
require_relative "active_record_follow_assoc/exceptions"
|
14
|
+
require_relative "active_record_follow_assoc/core_logic"
|
15
|
+
require_relative "active_record_follow_assoc/query_methods"
|
16
|
+
|
17
|
+
module ClassDelegates
|
18
|
+
# Delegating the methods in QueryMethods from ActiveRecord::Base to :all. Same thing ActiveRecord does for #where.
|
19
|
+
new_query_methods = QueryMethods.public_instance_methods
|
20
|
+
delegate(*new_query_methods, to: :all)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
ActiveSupport.on_load(:active_record) do
|
25
|
+
ActiveRecord.eager_load!
|
26
|
+
|
27
|
+
ActiveRecord::Relation.include(ActiveRecordFollowAssoc::QueryMethods)
|
28
|
+
ActiveRecord::Base.extend(ActiveRecordFollowAssoc::ClassDelegates)
|
29
|
+
end
|
metadata
ADDED
@@ -0,0 +1,196 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: activerecord_follow_assoc
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Maxime Lapointe
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-09-28 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: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '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: deep-cover
|
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: niceql
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: 0.1.23
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: 0.1.23
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: sqlite3
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0'
|
153
|
+
description: 'In ActiveRecord, allows you to query the association of the records
|
154
|
+
that your current query would return. If you need the comments of some posts: `Post.where(...).follow_assoc(:comments)`.
|
155
|
+
You can then chain `where` on the comments.'
|
156
|
+
email:
|
157
|
+
- hunter_spawn@hotmail.com
|
158
|
+
executables: []
|
159
|
+
extensions: []
|
160
|
+
extra_rdoc_files: []
|
161
|
+
files:
|
162
|
+
- LICENSE.txt
|
163
|
+
- README.md
|
164
|
+
- lib/active_record_follow_assoc.rb
|
165
|
+
- lib/active_record_follow_assoc/active_record_compat.rb
|
166
|
+
- lib/active_record_follow_assoc/core_logic.rb
|
167
|
+
- lib/active_record_follow_assoc/exceptions.rb
|
168
|
+
- lib/active_record_follow_assoc/query_methods.rb
|
169
|
+
- lib/active_record_follow_assoc/version.rb
|
170
|
+
- lib/activerecord_follow_assoc.rb
|
171
|
+
homepage: https://github.com/MaxLap/activerecord_follow_assoc
|
172
|
+
licenses:
|
173
|
+
- MIT
|
174
|
+
metadata:
|
175
|
+
homepage_uri: https://github.com/MaxLap/activerecord_follow_assoc
|
176
|
+
source_code_uri: https://github.com/MaxLap/activerecord_follow_assoc
|
177
|
+
post_install_message:
|
178
|
+
rdoc_options: []
|
179
|
+
require_paths:
|
180
|
+
- lib
|
181
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
182
|
+
requirements:
|
183
|
+
- - ">="
|
184
|
+
- !ruby/object:Gem::Version
|
185
|
+
version: 2.1.0
|
186
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
187
|
+
requirements:
|
188
|
+
- - ">="
|
189
|
+
- !ruby/object:Gem::Version
|
190
|
+
version: '0'
|
191
|
+
requirements: []
|
192
|
+
rubygems_version: 3.0.3
|
193
|
+
signing_key:
|
194
|
+
specification_version: 4
|
195
|
+
summary: Follow associations within your ActiveRecord queries
|
196
|
+
test_files: []
|