activerecord_where_assoc 1.1.0 → 1.1.3

Sign up to get free protection for your applications and to get access to all the features.
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