activerecord_where_assoc 1.1.0 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f470695cdbb7503ff9def8cbe5caca7f229fe2fa133227e78f44a6c6da6156bb
4
- data.tar.gz: bcf122af66d2338d345f3e151e060e44a5365b0976bf790e7c066cd9ff05912f
3
+ metadata.gz: fd0fae20ed7a2ae8fac19fda0df7f70b9b6a842a6519684c66c571dbf2f57732
4
+ data.tar.gz: f570a564494a52517dde7aad62c79b571ab3d1e49d651f32ac2bb86b301fa4f6
5
5
  SHA512:
6
- metadata.gz: 666127fc2b91da590c71a8f8e95a612ad03597e5e0ee599239752ed83645e93c67de6fcedfbe70a4a9d23f2cf884ff0c6fc091036d752e40f3fffa186ef1e65f
7
- data.tar.gz: 582bae5889078d7b259d20ca1e38cd061ccfced8d96e58b2323dfbccdb7ac896fdac1c37626b7d73da17490d350d63949ba73297fd20fc1b3e49fba4cea4870c
6
+ metadata.gz: 240832561dd8eb5cc93ff3ebc239a0aedf8f9f79520a6404ffe6ac8f9d1b2cacd5f0057d85066ed889b62f16b5e85125acb8b943c91fa72ab0450a366d7d7569
7
+ data.tar.gz: 060f842df0bd69b72ac0e6f0a64ef222b347047d693046abe83a9bfdd6797109e202bcccc32f75a3e7bc4349f1b5a50c94982102571a699b113e65331ebca459
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Unreleased
2
2
 
3
+ # 1.1.3 - 2022-08-16
4
+
5
+ * Add support for associations defined on abstract models
6
+
7
+ # 1.1.2 - 2020-12-24
8
+
9
+ * Add compatiblity for Rails 6.1
10
+
11
+ # 1.1.1 - 2020-04-13
12
+
13
+ * Fix handling for ActiveRecord's NullRelation (MyModel.none) in block and association's conditions.
14
+
3
15
  # 1.1.0 - 2020-02-24
4
16
 
5
17
  * Added methods which return the SQL used by this gem: `assoc_exists_sql`, `assoc_not_exists_sql`, `compare_assoc_count_sql`, `only_assoc_count_sql`
data/EXAMPLES.md CHANGED
@@ -1,3 +1,4 @@
1
+ SELECT "users".* FROM "users"
1
2
  Here are some example usages of the gem, along with the generated SQL.
2
3
 
3
4
  Each of those methods can be chained with scoping methods, so they can be used on `Post`, `my_user.posts`, `Post.where('hello')` or inside a scope. Note that for the `*_sql` variants, those should preferably be used on classes only, because otherwise, it could be confusing for a reader.
@@ -107,13 +108,13 @@ User.where_assoc_exists(:posts).or(User.where_assoc_exists(:comments))
107
108
  ```
108
109
  ```sql
109
110
  SELECT "users".* FROM "users"
110
- WHERE ((EXISTS (
111
+ WHERE (EXISTS (
111
112
  SELECT 1 FROM "posts"
112
113
  WHERE "posts"."author_id" = "users"."id"
113
- )) OR (EXISTS (
114
+ ) OR EXISTS (
114
115
  SELECT 1 FROM "comments"
115
116
  WHERE "comments"."author_id" = "users"."id"
116
- )))
117
+ ))
117
118
  ```
118
119
 
119
120
  ---
data/README.md CHANGED
@@ -1,19 +1,17 @@
1
1
  # ActiveRecord Where Assoc
2
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)
3
+ ![Test supported versions](https://github.com/MaxLap/activerecord_where_assoc/workflows/Test%20supported%20versions/badge.svg)
5
4
  [![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
5
 
8
6
  This gem makes it easy to do conditions based on the associations of your records in ActiveRecord (Rails). (Using SQL's `EXISTS` operator)
9
7
 
10
8
  ```ruby
11
9
  # Find my_post's comments that were not made by an admin
12
10
  my_post.comments.where_assoc_not_exists(:author, is_admin: true).where(...)
13
-
11
+
14
12
  # Find every posts that have comments by an admin
15
13
  Post.where_assoc_exists([:comments, :author], &:admins).where(...)
16
-
14
+
17
15
  # Find my_user's posts that have at least 5 non-spam comments (not_spam is a scope on comments)
18
16
  my_user.posts.where_assoc_count(5, :>=, :comments) { |comments| comments.not_spam }.where(...)
19
17
  ```
@@ -39,9 +37,8 @@ These methods have many advantages over the alternative ways of achieving the si
39
37
 
40
38
  ## Installation
41
39
 
42
- Rails 4.1 to 6.0 are supported with Ruby 2.1 to 2.7.
43
- Tested against SQLite3, PostgreSQL and MySQL.
44
- The gem only depends on the `activerecord` gem.
40
+ Rails 4.1 to 7.0 are supported with Ruby 2.1 to 3.1. Tested against SQLite3, PostgreSQL and MySQL. The gem
41
+ only depends on the `activerecord` gem.
45
42
 
46
43
  Add this line to your application's Gemfile:
47
44
 
@@ -53,7 +50,7 @@ And then execute:
53
50
 
54
51
  $ bundle install
55
52
 
56
- Or install it yourself as:
53
+ Or install it yourself with:
57
54
 
58
55
  $ gem install activerecord_where_assoc
59
56
 
@@ -83,8 +80,8 @@ where_assoc_not_exists(association_name, conditions, options, &block)
83
80
  where_assoc_count(left_operand, operator, association_name, conditions, options, &block)
84
81
  ```
85
82
 
86
- * These methods add a condition (a `#where`) to the relation that checks if the association exists (or not)
87
- * You can specify condition on the association, so you could check only comments that are made by an admin.
83
+ * These methods add a condition (a `#where`) that checks if the association exists (or not)
84
+ * You can specify condition on the association, so you could check only for comments that are made by an admin.
88
85
  * Each method returns a new relation, meaning you can chain `#where`, `#order`, `limit`, etc.
89
86
  * common arguments:
90
87
  * association_name: the association we are doing the condition on.
@@ -120,6 +117,21 @@ where_assoc_count(left_operand, operator, association_name, conditions, options,
120
117
  supports infinite ranges and exclusive end
121
118
  * operator: one of `:<`, `:<=`, `:==`, `:!=`, `:>=`, `:>`
122
119
 
120
+ ## Intuition
121
+
122
+ Here is the basic intuition for the methods:
123
+
124
+ `#where_assoc_exists` filters the models, returning those *where* a record for the *association* matching a condition (by default any record in the association) *exists*.
125
+
126
+ `#where_assoc_not_exists` is the exact opposite of `#where_assoc_exists`. Filters the models, returning those *where* a record for the *association* matching a condition (by default any record in the association) do *not exists*
127
+
128
+ `#where_assoc_count` the more specific version of `#where_assoc_exists`. Filters the models, returning those *where* a record for the *association* matching a condition (by default any record in the association) do *not exists*
129
+
130
+ The condition that you may need on the record can be quite complicated. For this reason, you can pass a block to these methods.
131
+ The block will receive a relation on records of the association. Your job is then to call `where` and scopes to specify what you want to exist (or to not exist if using `#where_assoc_not_exists`).
132
+
133
+ So if you have `User.where_assoc_exists(:comments) {|rel| rel.where("content ilike '%github.com%'") }`, `rel` is a relation is on `Comment`, and you are specifying what you want to exist. So now we are looking for users that made a comment containing 'github.com'.
134
+
123
135
  ## Usage tips
124
136
 
125
137
  ### Nested associations
@@ -222,6 +234,19 @@ It is pretty complicated to support `#limit` and `#offset` of the `has_* :throug
222
234
 
223
235
  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.
224
236
 
237
+ ## Another recommended gem
238
+
239
+ If you feel a need for this gem's feature, you may also be interested in another gem I made: [activerecord_follow_assoc](https://github.com/MaxLap/activerecord_follow_assoc).
240
+
241
+ It allows you to follow an association of your choice while building a query (a scope). You start querying posts, and then you change to querying the authors of those posts. For simple cases, it's possible that both `where_assoc` and `follow_assoc` can build the query your need, but each can handle different situations. Here is an example:
242
+
243
+ ```ruby
244
+ # Find every posts that have comments by an admin
245
+ Post.where_assoc_exists([:comments, :author], &:admins)
246
+ ```
247
+
248
+ This could be done with `follow_assoc`: `User.admins.follow_assoc(:comments, :post)`. But if you wanted conditions on a second association, then `follow_assoc` wouldn't work. On the other hand, if you received a scope on users and wanted their posts, then `follow_assoc` would be a nicer tool for the job. It all depends on the context where you need to do the query and what starting point you have.
249
+
225
250
  ## Development
226
251
 
227
252
  After checking out the repo, run `bundle install` to install dependencies.
@@ -2,7 +2,17 @@
2
2
 
3
3
  module ActiveRecordWhereAssoc
4
4
  module ActiveRecordCompat
5
- if ActiveRecord.gem_version >= Gem::Version.new("5.1")
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")
6
16
  def self.join_keys(reflection, poly_belongs_to_klass)
7
17
  if poly_belongs_to_klass
8
18
  reflection.get_join_keys(poly_belongs_to_klass)
@@ -13,11 +13,12 @@ module ActiveRecordWhereAssoc
13
13
  # => "EXISTS (SELECT... *relation1*) OR EXISTS (SELECT... *relation2*)"
14
14
  def self.sql_for_any_exists(relations)
15
15
  relations = [relations] unless relations.is_a?(Array)
16
- sql = relations.map { |ns| "EXISTS (#{ns.select('1').to_sql})" }.join(" OR ")
17
- if relations.size > 1
18
- "(#{sql})" # Needed when embedding the sql in a `where`, because the OR could make things wrong
19
- elsif relations.size == 1
20
- sql
16
+ relations = relations.reject { |rel| rel.is_a?(ActiveRecord::NullRelation) }
17
+ sqls = relations.map { |rel| "EXISTS (#{rel.select('1').to_sql})" }
18
+ if sqls.size > 1
19
+ "(#{sqls.join(" OR ")})" # Parens needed when embedding the sql in a `where`, because the OR could make things wrong
20
+ elsif sqls.size == 1
21
+ sqls.first
21
22
  else
22
23
  "0=1"
23
24
  end
@@ -30,10 +31,11 @@ module ActiveRecordWhereAssoc
30
31
 
31
32
  # Returns the SQL for getting the sum of of the received relations
32
33
  # => "SUM((SELECT... *relation1*)) + SUM((SELECT... *relation2*))"
33
- def self.sql_for_sum_of_counts(nested_scopes)
34
- nested_scopes = [nested_scopes] unless nested_scopes.is_a?(Array)
34
+ def self.sql_for_sum_of_counts(relations)
35
+ relations = [relations] unless relations.is_a?(Array)
36
+ relations = relations.reject { |rel| rel.is_a?(ActiveRecord::NullRelation) }
35
37
  # Need the double parentheses
36
- nested_scopes.map { |ns| "SUM((#{ns.to_sql}))" }.join(" + ").presence || "0"
38
+ relations.map { |rel| "SUM((#{rel.to_sql}))" }.join(" + ").presence || "0"
37
39
  end
38
40
 
39
41
  # Block used when nesting associations for a where_assoc_count
@@ -83,7 +85,7 @@ module ActiveRecordWhereAssoc
83
85
  end
84
86
 
85
87
  nested_relations = relations_on_association(record_class, association_names, given_conditions, options, deepest_scope_mod, NestWithSumBlock)
86
-
88
+ nested_relations = nested_relations.reject { |rel| rel.is_a?(ActiveRecord::NullRelation) }
87
89
  nested_relations.map { |nr| "COALESCE((#{nr.to_sql}), 0)" }.join(" + ").presence || "0"
88
90
  end
89
91
 
@@ -139,7 +141,7 @@ module ActiveRecordWhereAssoc
139
141
  # Each step, we get all of the scoping lambdas that were defined on associations that apply for
140
142
  # the reflection's target
141
143
  # Basically, we start from the deepest part of the query and wrap it up
142
- reflection_chain, constaints_chain = ActiveRecordCompat.chained_reflection_and_chained_constraints(final_reflection)
144
+ reflection_chain, constraints_chain = ActiveRecordCompat.chained_reflection_and_chained_constraints(final_reflection)
143
145
  skip_next = false
144
146
 
145
147
  reflection_chain.each_with_index do |reflection, i|
@@ -151,7 +153,7 @@ module ActiveRecordWhereAssoc
151
153
  # the 2nd part of has_and_belongs_to_many is handled at the same time as the first.
152
154
  skip_next = true if actually_has_and_belongs_to_many?(reflection)
153
155
 
154
- init_scopes = initial_scopes_from_reflection(reflection_chain[i..-1], constaints_chain[i], options)
156
+ init_scopes = initial_scopes_from_reflection(record_class, reflection_chain[i..-1], constraints_chain[i], options)
155
157
  current_scopes = init_scopes.map do |alias_scope, current_scope, klass_scope|
156
158
  current_scope = process_association_step_limits(current_scope, reflection, record_class, options)
157
159
 
@@ -195,13 +197,13 @@ module ActiveRecordWhereAssoc
195
197
  end
196
198
 
197
199
  # Can return multiple pairs for polymorphic belongs_to, one per table to look into
198
- def self.initial_scopes_from_reflection(reflection_chain, assoc_scopes, options)
200
+ def self.initial_scopes_from_reflection(record_class, reflection_chain, assoc_scopes, options)
199
201
  reflection = reflection_chain.first
200
202
  actual_source_reflection = user_defined_actual_source_reflection(reflection)
201
203
 
202
204
  on_poly_belongs_to = option_value(options, :poly_belongs_to) if poly_belongs_to?(actual_source_reflection)
203
205
 
204
- classes_with_scope = classes_with_scope_for_reflection(reflection, options)
206
+ classes_with_scope = classes_with_scope_for_reflection(record_class, reflection, options)
205
207
 
206
208
  assoc_scope_allowed_lim_off = assoc_scope_to_keep_lim_off_from(reflection)
207
209
 
@@ -223,18 +225,18 @@ module ActiveRecordWhereAssoc
223
225
  # would be great, except we cannot add a given_conditions afterward because we are on the wrong "base class",
224
226
  # and we can't do #merge because of the LEW crap.
225
227
  # So we must do the joins ourself!
226
- _wrapper, sub_join_contraints = wrapper_and_join_constraints(reflection)
228
+ _wrapper, sub_join_contraints = wrapper_and_join_constraints(record_class, reflection)
227
229
  next_reflection = reflection_chain[1]
228
230
 
229
231
  current_scope = current_scope.joins(<<-SQL)
230
232
  INNER JOIN #{next_reflection.klass.quoted_table_name} ON #{sub_join_contraints.to_sql}
231
233
  SQL
232
234
 
233
- alias_scope, join_constaints = wrapper_and_join_constraints(next_reflection, habtm_other_reflection: reflection)
235
+ alias_scope, join_constraints = wrapper_and_join_constraints(record_class, next_reflection, habtm_other_reflection: reflection)
234
236
  elsif on_poly_belongs_to
235
- alias_scope, join_constaints = wrapper_and_join_constraints(reflection, poly_belongs_to_klass: klass)
237
+ alias_scope, join_constraints = wrapper_and_join_constraints(record_class, reflection, poly_belongs_to_klass: klass)
236
238
  else
237
- alias_scope, join_constaints = wrapper_and_join_constraints(reflection)
239
+ alias_scope, join_constraints = wrapper_and_join_constraints(record_class, reflection)
238
240
  end
239
241
 
240
242
  assoc_scopes.each do |callable|
@@ -254,7 +256,7 @@ module ActiveRecordWhereAssoc
254
256
  current_scope = current_scope.merge(relation)
255
257
  end
256
258
 
257
- [alias_scope, current_scope.where(join_constaints), klass_scope]
259
+ [alias_scope, current_scope.where(join_constraints), klass_scope]
258
260
  end
259
261
  end
260
262
 
@@ -270,7 +272,7 @@ module ActiveRecordWhereAssoc
270
272
  user_defined_actual_source_reflection(reflection).scope
271
273
  end
272
274
 
273
- def self.classes_with_scope_for_reflection(reflection, options)
275
+ def self.classes_with_scope_for_reflection(record_class, reflection, options)
274
276
  actual_source_reflection = user_defined_actual_source_reflection(reflection)
275
277
 
276
278
  if poly_belongs_to?(actual_source_reflection)
@@ -281,7 +283,15 @@ module ActiveRecordWhereAssoc
281
283
  else
282
284
  case on_poly_belongs_to
283
285
  when :pluck
284
- class_names = actual_source_reflection.active_record.distinct.pluck(actual_source_reflection.foreign_type)
286
+ model_for_ids = actual_source_reflection.active_record
287
+
288
+ if model_for_ids.abstract_class
289
+ # When the reflection is defined on an abstract model, we fallback to the model
290
+ # on which this was called
291
+ model_for_ids = record_class
292
+ end
293
+
294
+ class_names = model_for_ids.distinct.pluck(actual_source_reflection.foreign_type)
285
295
  class_names.compact.map!(&:safe_constantize).compact
286
296
  when Array, Hash
287
297
  array = on_poly_belongs_to.to_a
@@ -323,6 +333,9 @@ module ActiveRecordWhereAssoc
323
333
  return current_scope.unscope(:limit, :offset, :order)
324
334
  end
325
335
 
336
+ # No need to do transformations if this is already a NullRelation
337
+ return current_scope if current_scope.is_a?(ActiveRecord::NullRelation)
338
+
326
339
  current_scope = current_scope.limit(1) if reflection.macro == :has_one
327
340
 
328
341
  # Order is useless without either limit or offset
@@ -386,7 +399,7 @@ module ActiveRecordWhereAssoc
386
399
  alias_scope
387
400
  end
388
401
 
389
- def self.wrapper_and_join_constraints(reflection, options = {})
402
+ def self.wrapper_and_join_constraints(record_class, reflection, options = {})
390
403
  poly_belongs_to_klass = options[:poly_belongs_to_klass]
391
404
  join_keys = ActiveRecordCompat.join_keys(reflection, poly_belongs_to_klass)
392
405
 
@@ -395,6 +408,12 @@ module ActiveRecordWhereAssoc
395
408
 
396
409
  table = (poly_belongs_to_klass || reflection.klass).arel_table
397
410
  foreign_klass = reflection.send(:actual_source_reflection).active_record
411
+ if foreign_klass.abstract_class
412
+ # When the reflection is defined on an abstract model, we fallback to the model
413
+ # on which this was called
414
+ foreign_klass = record_class
415
+ end
416
+
398
417
  foreign_table = foreign_klass.arel_table
399
418
 
400
419
  habtm_other_reflection = options[:habtm_other_reflection]
@@ -58,8 +58,12 @@ module ActiveRecordWhereAssoc
58
58
  # Post.where_assoc_exists([:comments, :replies])
59
59
  #
60
60
  # === Condition
61
- # After the +association_name+ argument, you can pass additional conditions the associated
62
- # record must also match to be considered as existing.
61
+ # After the +association_name+ argument, you can pass conditions on your association to
62
+ # specify which of its records you care about. For example, you could only want Posts that
63
+ # have a comment marked as spam, so all you care about are comments marked as spam.
64
+ #
65
+ # Another way to look at this is that you are filtering your association (using a +#where+)
66
+ # and checking if a record of that association is still found, and you do this for each of you records.
63
67
  #
64
68
  # This +condition+ argument is passed directly to +#where+, so you can pass in the following:
65
69
  #
@@ -91,13 +95,13 @@ module ActiveRecordWhereAssoc
91
95
  #
92
96
  # === Block
93
97
  # The block is used to add more complex conditions. The effect is the same as the condition
94
- # parameter, in that these conditions must be matched for the association to be considered
95
- # to exist, but lets you use any scoping methods, such as +#where+, +#joins+, nested
98
+ # parameter. You are specifying which records in the association you care about, but using
99
+ # a block lets you use any scoping methods, such as +#where+, +#joins+, nested
96
100
  # +#where_assoc_*+, scopes on the model, etc.
97
101
  #
98
102
  # Note that using +#joins+ might lead to unexpected results when using #where_assoc_count,
99
103
  # since if the joins adds rows, it will change the resulting count. It probably makes more
100
- # sense to, again, use one of the +where_assoc_*+ methods.
104
+ # sense to, again, use one of the +where_assoc_*+ methods (they can be nested).
101
105
  #
102
106
  # There are 2 ways of using the block for adding conditions to the association.
103
107
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordWhereAssoc
4
- VERSION = "1.1.0".freeze
4
+ VERSION = "1.1.3".freeze
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord_where_assoc
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maxime Handfield Lapointe
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-02-24 00:00:00.000000000 Z
11
+ date: 2022-08-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -80,20 +80,6 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
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
83
  - !ruby/object:Gem::Dependency
98
84
  name: deep-cover
99
85
  requirement: !ruby/object:Gem::Requirement
@@ -204,7 +190,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
204
190
  - !ruby/object:Gem::Version
205
191
  version: '0'
206
192
  requirements: []
207
- rubygems_version: 3.0.3
193
+ rubygems_version: 3.3.7
208
194
  signing_key:
209
195
  specification_version: 4
210
196
  summary: Make ActiveRecord do conditions on your associations