active_record-where_exists 0.1.0
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +161 -0
- data/Rakefile +11 -0
- data/lib/active_record/where_exists/association_subquery.rb +236 -0
- data/lib/active_record/where_exists/relation_extension.rb +17 -0
- data/lib/active_record/where_exists/version.rb +8 -0
- data/lib/active_record/where_exists/where_chain.rb +67 -0
- data/lib/active_record/where_exists.rb +48 -0
- data/lib/tapioca/dsl/compilers/active_record_where_exists.rb +70 -0
- metadata +67 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fdd433c489a4724c42ae1b7937377978134063af8215d12a4c0209685b3b163f
|
|
4
|
+
data.tar.gz: f60d150867ca8bb71cf4b52fbce58b542eb1c2e2c9bf756f557655deacf3973c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: eab9ab3196e655f9b16e96dfc904c78bea0f62328757d31ca89ec17014ba06be043a19e9a80da04eec1cd5fad8631592965b92d663b208144b84b16dee04d702
|
|
7
|
+
data.tar.gz: 9f524b218a58afe7f25771a551299d7ece04f604c8a9877feb6ef43d4106e9a8149fb093eeefe02893b141a6e0b95c55a52702db031062b3617f4be374e08d2f
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Minty Fresh
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# ActiveRecord::WhereExists
|
|
2
|
+
|
|
3
|
+
Filters ActiveRecord records by the existence of associated records, using correlated `EXISTS` subqueries.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem 'active_record-where_exists', github: 'mintyfresh/active_record-where_exists'
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
And then execute:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
$ bundle
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then extend your models with `ActiveRecord::WhereExists` (typically in `ApplicationRecord`, so all models get it):
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
23
|
+
primary_abstract_class
|
|
24
|
+
|
|
25
|
+
extend ActiveRecord::WhereExists
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
Given:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
class User < ApplicationRecord
|
|
35
|
+
has_many :orders
|
|
36
|
+
has_many :completed_orders, -> { completed }, class_name: 'Order'
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`where.exists` filters records to those with associated records matching a relation:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
User.where.exists(orders: Order.completed)
|
|
44
|
+
# SELECT "users".* FROM "users" WHERE EXISTS (
|
|
45
|
+
# SELECT 1 FROM "orders" WHERE "orders"."status" = 'completed' AND "orders"."user_id" = "users"."id"
|
|
46
|
+
# )
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
And `where.not_exists` filters records to those without them:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
User.where.not_exists(orders: Order.completed)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Associations given by name match any associated record (ie. `exists(:orders)` is the same as `exists(orders: Order.all)`):
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
User.where.exists(:orders)
|
|
59
|
+
User.where.not_exists(:orders)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Several associations can be given at once. Records must match all of them with `exists`, and none of them with `not_exists`:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
User.where.exists(:addresses, orders: Order.completed)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Association scopes
|
|
69
|
+
|
|
70
|
+
The subquery is constrained in the same way as joining the association (ie. `joins(:association)`).
|
|
71
|
+
So it applies the scope of the association, as well as the default scope of the associated model:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
User.where.exists(completed_orders: Order.where(total: 100..))
|
|
75
|
+
# SELECT "users".* FROM "users" WHERE EXISTS (
|
|
76
|
+
# SELECT 1 FROM "orders"
|
|
77
|
+
# WHERE "orders"."total" >= 100.0 AND "orders"."status" = 'completed' AND "orders"."user_id" = "users"."id"
|
|
78
|
+
# )
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
It also respects extensions to how associations are joined, such as the filtering of soft-deleted records by
|
|
82
|
+
[SoftDeletable](https://github.com/mintyfresh/soft_deletable).
|
|
83
|
+
|
|
84
|
+
Note that, as with joins, ordering in the scope of a `has_one` association is ignored.
|
|
85
|
+
So `exists(latest_order: Order.completed)` matches records with any completed order.
|
|
86
|
+
|
|
87
|
+
### Procs
|
|
88
|
+
|
|
89
|
+
Associated records can also be filtered by a proc, which is given a relation of the associated records:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
User.where.exists(orders: -> (orders) { orders.completed })
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A proc is needed for self-referential associations (or any association to the table of the records being filtered),
|
|
96
|
+
where the associated records are referred to by an alias, so that their conditions apply to the alias:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class User < ApplicationRecord
|
|
100
|
+
has_many :friendships
|
|
101
|
+
has_many :friends, through: :friendships
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
User.where.exists(friends: -> (friends) { friends.where(name: 'Alice') })
|
|
105
|
+
# SELECT "users".* FROM "users" WHERE EXISTS (
|
|
106
|
+
# SELECT 1 FROM "users" AS "friends_users"
|
|
107
|
+
# INNER JOIN "friendships" ON "friends_users"."id" = "friendships"."friend_id"
|
|
108
|
+
# WHERE "friends_users"."name" = 'Alice' AND "friendships"."user_id" = "users"."id"
|
|
109
|
+
# )
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Relations that refer to the table of the records being filtered (eg. `User.where.exists(friends: User.all)`,
|
|
113
|
+
or `User.where.exists(orders: Order.joins(:user))`) raise an `ArgumentError`, since their conditions would be
|
|
114
|
+
ambiguous between the associated records and the records being filtered.
|
|
115
|
+
|
|
116
|
+
### Supported associations
|
|
117
|
+
|
|
118
|
+
All kinds of associations are supported, including `has_many :through`, `has_and_belongs_to_many`,
|
|
119
|
+
and self-referential associations.
|
|
120
|
+
|
|
121
|
+
Polymorphic `belongs_to` associations need a relation (or model), which specifies the type of the associated record:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
class Comment < ApplicationRecord
|
|
125
|
+
belongs_to :commentable, polymorphic: true
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
Comment.where.exists(commentable: Post.published)
|
|
129
|
+
Comment.where.exists(commentable: Post)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Projections
|
|
133
|
+
|
|
134
|
+
The subquery selects `1` (so the database doesn't need to read the rows of the associated table),
|
|
135
|
+
unless the relation has its own projection, which is kept:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
User.where.exists(orders: Order.select(:user_id).group(:user_id).having('SUM(total) > 100'))
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Sorbet
|
|
142
|
+
|
|
143
|
+
A Tapioca DSL compiler is included, which adds the `exists` and `not_exists` methods
|
|
144
|
+
to the where-chains generated for models extended by `ActiveRecord::WhereExists`.
|
|
145
|
+
It's loaded automatically by `bin/tapioca dsl`.
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
The specs run against a dummy Rails application in `spec/dummy`, using SQLite by default:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
$ bin/rspec
|
|
153
|
+
$ bin/rubocop
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
PostgreSQL and MySQL are selected with the `DATABASE_ADAPTER` and `DATABASE_URL` environment variables
|
|
157
|
+
(see `.github/workflows/ci.yml`).
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module WhereExists
|
|
6
|
+
# Builds the subquery selecting the associated records of each row of an outer relation.
|
|
7
|
+
#
|
|
8
|
+
# The subquery is constrained as if joining the association (ie. `joins(:association)`), so it respects the scope
|
|
9
|
+
# of the association, the default scope of the associated model, and any extensions to how associations are joined.
|
|
10
|
+
#
|
|
11
|
+
# User.where.exists(orders: Order.completed)
|
|
12
|
+
# # SELECT "users".* FROM "users" WHERE EXISTS (
|
|
13
|
+
# # SELECT 1 FROM "orders" WHERE "orders"."status" = 'completed' AND "orders"."user_id" = "users"."id"
|
|
14
|
+
# # )
|
|
15
|
+
class AssociationSubquery
|
|
16
|
+
# The relation that the subquery selects from, the table it refers to the associated records by,
|
|
17
|
+
# and the tables already in use by the subquery.
|
|
18
|
+
Target = Data.define(:relation, :table, :alias_tracker)
|
|
19
|
+
|
|
20
|
+
#: ActiveRecord::Reflection::AbstractReflection
|
|
21
|
+
attr_reader :reflection
|
|
22
|
+
|
|
23
|
+
# The associated records can be filtered by a relation (or model), or by a proc which is given a relation of the
|
|
24
|
+
# associated records (on the table they're referred to by, eg. an alias in self-referential associations).
|
|
25
|
+
# Associations given by name match any associated record (ie. `exists(:orders)` is `exists(orders: Order.all)`,
|
|
26
|
+
# or `exists(friends: -> (friends) { friends })` when the associated records are aliased).
|
|
27
|
+
#
|
|
28
|
+
#: (
|
|
29
|
+
#| ActiveRecord::Relation scope,
|
|
30
|
+
#| ActiveRecord::Reflection::AbstractReflection reflection,
|
|
31
|
+
#| (
|
|
32
|
+
#| ActiveRecord::Relation | singleton(ActiveRecord::Base) | ^(ActiveRecord::Relation) -> ActiveRecord::Relation
|
|
33
|
+
#| )? condition
|
|
34
|
+
#| ) -> void
|
|
35
|
+
def initialize(scope, reflection, condition)
|
|
36
|
+
@scope = scope
|
|
37
|
+
@reflection = reflection
|
|
38
|
+
@condition = condition
|
|
39
|
+
|
|
40
|
+
reflection.check_validity!
|
|
41
|
+
validate_polymorphic! if reflection.polymorphic?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Selects a constant unless the relation has its own projection (eg. for a GROUP BY),
|
|
45
|
+
# so that the database needn't read the rows of the subquery (eg. when an index covers its conditions).
|
|
46
|
+
#
|
|
47
|
+
#: -> Arel::Nodes::Exists
|
|
48
|
+
def exists
|
|
49
|
+
subquery = relation
|
|
50
|
+
subquery = subquery.select(Arel.sql('1')) if subquery.select_values.empty?
|
|
51
|
+
subquery.arel.exists
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The associated records of each row of the outer relation, as a relation correlated to its table.
|
|
55
|
+
#
|
|
56
|
+
#: -> ActiveRecord::Relation
|
|
57
|
+
def relation
|
|
58
|
+
@relation ||= reflection.polymorphic? ? build_polymorphic : build
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
#: -> Arel::Table
|
|
64
|
+
def outer_table
|
|
65
|
+
@scope.table
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Polymorphic associations are resolved by the model of the relation.
|
|
69
|
+
#
|
|
70
|
+
#: -> singleton(ActiveRecord::Base)
|
|
71
|
+
def klass
|
|
72
|
+
reflection.polymorphic? ? @condition.all.klass : reflection.klass
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
#: -> void
|
|
76
|
+
def validate_polymorphic!
|
|
77
|
+
return if @condition.is_a?(ActiveRecord::Relation)
|
|
78
|
+
return if @condition.is_a?(Class) && @condition < ActiveRecord::Base
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "A relation or model must be given for the polymorphic association `:#{reflection.name}`."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
#: (untyped relation) -> void
|
|
84
|
+
def validate_relation!(relation)
|
|
85
|
+
unless relation.is_a?(ActiveRecord::Relation) && relation.klass.base_class == klass.base_class
|
|
86
|
+
raise ArgumentError, "Expected a relation of `#{klass.name}` for the association `:#{reflection.name}`, " \
|
|
87
|
+
"got #{relation.inspect}."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
validate_outer_table_unshadowed!(relation)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The subquery refers to the outer table by name, so none of the relation's tables may share its name.
|
|
94
|
+
#
|
|
95
|
+
#: (ActiveRecord::Relation relation) -> void
|
|
96
|
+
def validate_outer_table_unshadowed!(relation)
|
|
97
|
+
return if target_alias_tracker(relation).aliases[outer_table.name].zero?
|
|
98
|
+
|
|
99
|
+
name = reflection.name
|
|
100
|
+
raise ArgumentError, "The relation for the association `:#{name}` refers to the outer table " \
|
|
101
|
+
"`#{outer_table.name}`. Pass a proc to filter the associated records by their own " \
|
|
102
|
+
"table, e.g. `#{name}: -> (#{name}) { #{name}.where(...) }`."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Tracks the tables of the relation, so that the tables joined by the association are aliased apart from them.
|
|
106
|
+
#
|
|
107
|
+
#: (ActiveRecord::Relation relation) -> ActiveRecord::Associations::AliasTracker
|
|
108
|
+
def target_alias_tracker(relation)
|
|
109
|
+
ActiveRecord::Associations::AliasTracker.create(
|
|
110
|
+
klass.connection_pool, relation.table.name, relation.arel.join_sources
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Builds the subquery from the joins of the association, which start from the outer table:
|
|
115
|
+
#
|
|
116
|
+
# FROM outer INNER JOIN t1 ON c1 INNER JOIN t2 ON c2 ... INNER JOIN target ON cN
|
|
117
|
+
#
|
|
118
|
+
# and are rearranged to start from the target table (where the relation's conditions apply),
|
|
119
|
+
# moving the condition on the outer table into the subquery's WHERE clause:
|
|
120
|
+
#
|
|
121
|
+
# FROM target INNER JOIN tN-1 ON cN ... INNER JOIN t1 ON c2 WHERE c1
|
|
122
|
+
#
|
|
123
|
+
#: -> ActiveRecord::Relation
|
|
124
|
+
def build
|
|
125
|
+
tables, joins = association_joins
|
|
126
|
+
|
|
127
|
+
# the chain of tables starts at the target, and its conditions each join a table to the next one
|
|
128
|
+
conditions = tables.map { |table| joins.find { |join| join.left.equal?(table) }.right.expr }
|
|
129
|
+
chain_joins = tables.drop(1).zip(conditions).map do |table, condition|
|
|
130
|
+
Arel::Nodes::InnerJoin.new(table, Arel::Nodes::On.new(condition))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
join_all(target.relation, chain_joins + scope_joins(tables, joins)).where(conditions.last)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Joins from the scopes of the association, which aren't part of the chain.
|
|
137
|
+
#
|
|
138
|
+
#: (Array[Arel::Table | Arel::Nodes::TableAlias] tables, Array[Arel::Nodes::Join] joins) -> Array[Arel::Nodes::Join]
|
|
139
|
+
def scope_joins(tables, joins)
|
|
140
|
+
joins.reject { |join| tables.any? { |table| join.left.equal?(table) } }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Joins the association from the outer table, as `joins(:association)` would.
|
|
144
|
+
#
|
|
145
|
+
#: -> [Array[Arel::Table | Arel::Nodes::TableAlias], Array[Arel::Nodes::Join]]
|
|
146
|
+
def association_joins
|
|
147
|
+
tables = [] #: Array[Arel::Table | Arel::Nodes::TableAlias]
|
|
148
|
+
join_association = ActiveRecord::Associations::JoinDependency::JoinAssociation.new(reflection, [])
|
|
149
|
+
|
|
150
|
+
joins = join_association.join_constraints(
|
|
151
|
+
outer_table, @scope.model, Arel::Nodes::InnerJoin, target.alias_tracker
|
|
152
|
+
) do |step|
|
|
153
|
+
tables << (tables.empty? ? target.table : intermediate_table(step))
|
|
154
|
+
tables.last
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
[tables, joins]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
#: (ActiveRecord::Relation relation, Array[Arel::Nodes::Join] joins) -> ActiveRecord::Relation
|
|
161
|
+
def join_all(relation, joins)
|
|
162
|
+
joins.empty? ? relation : relation.joins(*joins)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
#: (ActiveRecord::Reflection::AbstractReflection step) -> (Arel::Table | Arel::Nodes::TableAlias)
|
|
166
|
+
def intermediate_table(step)
|
|
167
|
+
target.alias_tracker.aliased_table_for(step.klass.arel_table) do
|
|
168
|
+
"#{step.alias_candidate(outer_table.name)}_join"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Polymorphic associations can't be joined, so the target is given by the relation.
|
|
173
|
+
#
|
|
174
|
+
#: -> ActiveRecord::Relation
|
|
175
|
+
def build_polymorphic
|
|
176
|
+
predicate_builder = klass.predicate_builder.with(ActiveRecord::TableMetadata.new(klass, target.table))
|
|
177
|
+
association_scopes = reflection.join_scopes(target.table, predicate_builder, klass)
|
|
178
|
+
|
|
179
|
+
association_scopes.inject(target.relation, &:merge).where(Arel::Nodes::And.new(polymorphic_conditions))
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
#: -> Array[Arel::Nodes::Node]
|
|
183
|
+
def polymorphic_conditions
|
|
184
|
+
keys = Array(reflection.join_primary_key(klass)).zip(Array(reflection.join_foreign_key))
|
|
185
|
+
|
|
186
|
+
conditions = keys.map { |primary_key, foreign_key| target.table[primary_key].eq(outer_table[foreign_key]) }
|
|
187
|
+
conditions << outer_table[reflection.foreign_type].eq(klass.polymorphic_name)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
#: -> Target
|
|
191
|
+
def target
|
|
192
|
+
@target ||= begin
|
|
193
|
+
relation = resolve_condition
|
|
194
|
+
validate_relation!(relation)
|
|
195
|
+
|
|
196
|
+
alias_tracker = target_alias_tracker(relation)
|
|
197
|
+
alias_tracker.aliased_table_for(outer_table)
|
|
198
|
+
|
|
199
|
+
Target.new(relation, relation.table, alias_tracker)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Procs may return anything, so the result is validated by `validate_relation!`.
|
|
204
|
+
#
|
|
205
|
+
#: -> untyped
|
|
206
|
+
def resolve_condition
|
|
207
|
+
case @condition
|
|
208
|
+
when nil then associated_records
|
|
209
|
+
when Proc then @condition.call(associated_records)
|
|
210
|
+
else @condition.all
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A relation of every associated record, on the table they're referred to by.
|
|
215
|
+
# They're aliased when their table is the outer table (eg. in self-referential associations).
|
|
216
|
+
# (The default scope of the model is applied by the association's join constraints.)
|
|
217
|
+
#
|
|
218
|
+
#: -> ActiveRecord::Relation
|
|
219
|
+
def associated_records
|
|
220
|
+
return klass.all if klass.table_name != outer_table.name
|
|
221
|
+
|
|
222
|
+
table = klass.arel_table.alias(associated_records_alias)
|
|
223
|
+
predicate_builder = klass.predicate_builder.with(ActiveRecord::TableMetadata.new(klass, table))
|
|
224
|
+
|
|
225
|
+
ActiveRecord::Relation.create(klass, table:, predicate_builder:)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
#: -> String
|
|
229
|
+
def associated_records_alias
|
|
230
|
+
klass.connection_pool.with_connection do |connection|
|
|
231
|
+
connection.table_alias_for(reflection.alias_candidate(outer_table.name))
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module WhereExists
|
|
6
|
+
module RelationExtension
|
|
7
|
+
# Returns a where-chain supporting `exists` and `not_exists` when called without arguments.
|
|
8
|
+
#
|
|
9
|
+
#: (*untyped args) -> untyped
|
|
10
|
+
def where(*args)
|
|
11
|
+
return super unless args.empty?
|
|
12
|
+
|
|
13
|
+
WhereChain.new(spawn)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveRecord
|
|
5
|
+
module WhereExists
|
|
6
|
+
class WhereChain < ActiveRecord::QueryMethods::WhereChain
|
|
7
|
+
# Filters records to those with associated records.
|
|
8
|
+
# Associations can be given by name, or mapped to a relation (or model) that associated records must match,
|
|
9
|
+
# or to a proc which filters a relation of the associated records:
|
|
10
|
+
#
|
|
11
|
+
# User.where.exists(:orders)
|
|
12
|
+
# User.where.exists(orders: Order.completed)
|
|
13
|
+
# User.where.exists(friends: -> (friends) { friends.where(name: 'Alice') })
|
|
14
|
+
#
|
|
15
|
+
# A proc is needed when the associated records are in the same table as the records being filtered
|
|
16
|
+
# (eg. in self-referential associations), since the associated records are then referred to by an alias.
|
|
17
|
+
#
|
|
18
|
+
# Records must match every condition given.
|
|
19
|
+
#
|
|
20
|
+
#: (
|
|
21
|
+
#| *Symbol | String associations,
|
|
22
|
+
#| **(
|
|
23
|
+
#| ActiveRecord::Relation | singleton(ActiveRecord::Base) | ^(ActiveRecord::Relation) -> ActiveRecord::Relation
|
|
24
|
+
#| ) scoped_associations
|
|
25
|
+
#| ) -> ActiveRecord::Relation
|
|
26
|
+
def exists(*associations, **scoped_associations)
|
|
27
|
+
each_subquery(associations, scoped_associations) { |subquery| @scope.where!(subquery.exists) }
|
|
28
|
+
@scope
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Filters records to those without associated records (see `exists`).
|
|
32
|
+
#
|
|
33
|
+
# User.where.not_exists(:orders)
|
|
34
|
+
# User.where.not_exists(orders: Order.completed)
|
|
35
|
+
# User.where.not_exists(friends: -> (friends) { friends.where(name: 'Alice') })
|
|
36
|
+
#
|
|
37
|
+
# Records must match none of the conditions given.
|
|
38
|
+
#
|
|
39
|
+
#: (
|
|
40
|
+
#| *Symbol | String associations,
|
|
41
|
+
#| **(
|
|
42
|
+
#| ActiveRecord::Relation | singleton(ActiveRecord::Base) | ^(ActiveRecord::Relation) -> ActiveRecord::Relation
|
|
43
|
+
#| ) scoped_associations
|
|
44
|
+
#| ) -> ActiveRecord::Relation
|
|
45
|
+
def not_exists(*associations, **scoped_associations)
|
|
46
|
+
each_subquery(associations, scoped_associations) { |subquery| @scope.where!(subquery.exists.not) }
|
|
47
|
+
@scope
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
#: (
|
|
53
|
+
#| Array[Symbol | String] associations,
|
|
54
|
+
#| Hash[Symbol | String, untyped] scoped_associations
|
|
55
|
+
#| ) { (AssociationSubquery) -> void } -> void
|
|
56
|
+
def each_subquery(associations, scoped_associations)
|
|
57
|
+
if associations.empty? && scoped_associations.empty?
|
|
58
|
+
raise ArgumentError, 'At least one association must be given.'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
associations.map { |name| [name, nil] }.concat(scoped_associations.to_a).each do |name, relation|
|
|
62
|
+
yield AssociationSubquery.new(@scope, scope_association_reflection(name), relation)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'active_record'
|
|
5
|
+
require 'active_record/where_exists/version'
|
|
6
|
+
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
# Adds `where.exists` and `where.not_exists` to the relations of a model and its subclasses,
|
|
9
|
+
# which filter records by the existence of associated records, using correlated `EXISTS` subqueries:
|
|
10
|
+
#
|
|
11
|
+
# class ApplicationRecord < ActiveRecord::Base
|
|
12
|
+
# extend ActiveRecord::WhereExists
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# User.where.exists(orders: Order.completed)
|
|
16
|
+
# User.where.not_exists(:orders)
|
|
17
|
+
module WhereExists
|
|
18
|
+
autoload :AssociationSubquery, 'active_record/where_exists/association_subquery'
|
|
19
|
+
autoload :RelationExtension, 'active_record/where_exists/relation_extension'
|
|
20
|
+
autoload :WhereChain, 'active_record/where_exists/where_chain'
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Extends the relations of a model, and of any of its subclasses that were already defined.
|
|
24
|
+
#
|
|
25
|
+
#: (singleton(ActiveRecord::Base) base) -> void
|
|
26
|
+
def extended(base)
|
|
27
|
+
super
|
|
28
|
+
[base, *base.descendants].each(&:install_where_exists)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Rails builds the relation classes of each model as it's defined (see `ActiveRecord::Delegation::DelegateCache`),
|
|
33
|
+
# so subclasses defined after the model was extended are installed here.
|
|
34
|
+
#
|
|
35
|
+
#: -> void
|
|
36
|
+
def initialize_relation_delegate_cache
|
|
37
|
+
super
|
|
38
|
+
install_where_exists
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#: -> void
|
|
42
|
+
def install_where_exists
|
|
43
|
+
ActiveRecord::Delegation.delegated_classes.each do |relation_class|
|
|
44
|
+
relation_delegate_class(relation_class).include(RelationExtension)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
return unless defined?(ActiveRecord::Base)
|
|
5
|
+
|
|
6
|
+
require 'tapioca/dsl/compilers/active_record_relations'
|
|
7
|
+
|
|
8
|
+
module Tapioca
|
|
9
|
+
module Dsl
|
|
10
|
+
module Compilers
|
|
11
|
+
# Adds `exists` and `not_exists` to the where-chains generated by the ActiveRecordRelations compiler,
|
|
12
|
+
# for models extended by ActiveRecord::WhereExists.
|
|
13
|
+
# Compilers decorate a constant in order of their names, so this runs after ActiveRecordRelations.
|
|
14
|
+
#
|
|
15
|
+
#: [ConstantType = singleton(::ActiveRecord::Base)]
|
|
16
|
+
class ActiveRecordWhereExists < Compiler
|
|
17
|
+
include Helpers::ActiveRecordConstantsHelper
|
|
18
|
+
|
|
19
|
+
WHERE_CHAIN_METHODS = %w[exists not_exists].freeze #: Array[String]
|
|
20
|
+
|
|
21
|
+
# Procs are given a relation of the associated model, which depends on the association, so it's untyped.
|
|
22
|
+
SCOPED_ASSOCIATION_TYPE = 'T.any(' \
|
|
23
|
+
'::ActiveRecord::Relation, ' \
|
|
24
|
+
'T.class_of(::ActiveRecord::Base), ' \
|
|
25
|
+
'T.proc.params(relation: T.untyped).returns(::ActiveRecord::Relation)' \
|
|
26
|
+
')' #: String
|
|
27
|
+
|
|
28
|
+
# @override
|
|
29
|
+
#: -> void
|
|
30
|
+
def decorate
|
|
31
|
+
return unless compiler_enabled?('ActiveRecordRelations')
|
|
32
|
+
|
|
33
|
+
root.create_path(constant) do |model|
|
|
34
|
+
model.create_class(RelationWhereChainClassName) do |klass|
|
|
35
|
+
create_where_chain_methods(klass, RelationClassName)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
model.create_class(AssociationRelationWhereChainClassName) do |klass|
|
|
39
|
+
create_where_chain_methods(klass, AssociationRelationClassName)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# @override
|
|
46
|
+
#: -> Enumerable[Module[top]]
|
|
47
|
+
def gather_constants
|
|
48
|
+
ActiveRecordRelations.gather_constants.grep(::ActiveRecord::WhereExists)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
#: (RBI::Scope scope, String return_type) -> void
|
|
55
|
+
def create_where_chain_methods(scope, return_type)
|
|
56
|
+
WHERE_CHAIN_METHODS.each do |name|
|
|
57
|
+
scope.create_method(
|
|
58
|
+
name,
|
|
59
|
+
parameters: [
|
|
60
|
+
create_rest_param('associations', type: 'T.any(Symbol, String)'),
|
|
61
|
+
create_kw_rest_param('scoped_associations', type: SCOPED_ASSOCIATION_TYPE),
|
|
62
|
+
],
|
|
63
|
+
return_type:
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: active_record-where_exists
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Minty Fresh
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.1'
|
|
26
|
+
description: Adds where.exists and where.not_exists to ActiveRecord relations, filtering
|
|
27
|
+
records by the existence of associated records.
|
|
28
|
+
email:
|
|
29
|
+
- 7896757+mintyfresh@users.noreply.github.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- MIT-LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- Rakefile
|
|
37
|
+
- lib/active_record/where_exists.rb
|
|
38
|
+
- lib/active_record/where_exists/association_subquery.rb
|
|
39
|
+
- lib/active_record/where_exists/relation_extension.rb
|
|
40
|
+
- lib/active_record/where_exists/version.rb
|
|
41
|
+
- lib/active_record/where_exists/where_chain.rb
|
|
42
|
+
- lib/tapioca/dsl/compilers/active_record_where_exists.rb
|
|
43
|
+
homepage: https://github.com/mintyfresh/active_record-where_exists
|
|
44
|
+
licenses:
|
|
45
|
+
- MIT
|
|
46
|
+
metadata:
|
|
47
|
+
homepage_uri: https://github.com/mintyfresh/active_record-where_exists
|
|
48
|
+
source_code_uri: https://github.com/mintyfresh/active_record-where_exists
|
|
49
|
+
rubygems_mfa_required: 'true'
|
|
50
|
+
rdoc_options: []
|
|
51
|
+
require_paths:
|
|
52
|
+
- lib
|
|
53
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '3.2'
|
|
58
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '0'
|
|
63
|
+
requirements: []
|
|
64
|
+
rubygems_version: 4.0.20
|
|
65
|
+
specification_version: 4
|
|
66
|
+
summary: EXISTS subqueries for ActiveRecord associations
|
|
67
|
+
test_files: []
|