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 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordFollowAssoc
4
+ class PolymorphicBelongsToWithoutClasses < StandardError
5
+ end
6
+ 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,3 @@
1
+ module ActiveRecordFollowAssoc
2
+ VERSION = "0.1.0"
3
+ 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: []