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 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'bundler/gem_tasks'
5
+ require 'rspec/core/rake_task'
6
+ require 'rubocop/rake_task'
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+ RuboCop::RakeTask.new
10
+
11
+ task default: %i[spec rubocop]
@@ -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,8 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module WhereExists
6
+ VERSION = '0.1.0'
7
+ end
8
+ 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: []