union_of 0.0.2 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +167 -13
- data/lib/union_of/association.rb +16 -0
- data/lib/union_of/builder.rb +8 -0
- data/lib/union_of/macro.rb +26 -0
- data/lib/union_of/preloader/association.rb +88 -0
- data/lib/union_of/preloader.rb +7 -0
- data/lib/union_of/railtie.rb +137 -0
- data/lib/union_of/readonly_association.rb +40 -0
- data/lib/union_of/readonly_association_proxy.rb +18 -0
- data/lib/union_of/reflection.rb +97 -0
- data/lib/union_of/scope.rb +125 -0
- data/lib/union_of/version.rb +1 -1
- data/lib/union_of.rb +19 -0
- metadata +89 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6a4e286d4ad0ea71318a2995e3609209fb9c3829878c8638abda047602922525
|
4
|
+
data.tar.gz: c0b02d918a69fb395504b2036cf2da55903960229a8abe4ccf10d7f58fc0ac9b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1ef814ee0cc9e2749801e1f16d186ccf65357e99912e50e9e7db7691c134a0e0616a29c9cdbc24c7409b0e8b86430b57ad18863ffc1f2c6bda16819211ed42df
|
7
|
+
data.tar.gz: 305fd61e60429096a23fa8d824f7822e4f5d023cbbcdedfdde9209a710646dd01835a33f233a804f6bdcdd5fed50b2e710b78433323d789ffb35cac822da8704
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,31 +1,185 @@
|
|
1
|
-
#
|
1
|
+
# union_of
|
2
2
|
|
3
|
-
|
3
|
+
[![CI](https://github.com/keygen-sh/union_of/actions/workflows/test.yml/badge.svg)](https://github.com/keygen-sh/union_of/actions)
|
4
|
+
[![Gem Version](https://badge.fury.io/rb/union_of.svg)](https://badge.fury.io/rb/union_of)
|
4
5
|
|
5
|
-
|
6
|
+
Use `union_of` to create associations that combine multiple Active Record
|
7
|
+
associations using a SQL `UNION` under the hood. `union_of` fully supports
|
8
|
+
joins, preloading, and eager loading, as well as through-union associations.
|
9
|
+
|
10
|
+
This gem was extracted from [Keygen](https://keygen.sh) and is being used in
|
11
|
+
production to serve millions of API requests per day, performantly querying
|
12
|
+
tables with millions and millions of rows.
|
13
|
+
|
14
|
+
Sponsored by:
|
15
|
+
|
16
|
+
<a href="https://keygen.sh?ref=union_of">
|
17
|
+
<div>
|
18
|
+
<img src="https://keygen.sh/images/logo-pill.png" width="200" alt="Keygen">
|
19
|
+
</div>
|
20
|
+
</a>
|
21
|
+
|
22
|
+
_A fair source software licensing and distribution API._
|
6
23
|
|
7
24
|
## Installation
|
8
25
|
|
9
|
-
|
26
|
+
Add this line to your application's `Gemfile`:
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
gem 'union_of'
|
30
|
+
```
|
10
31
|
|
11
|
-
|
32
|
+
And then execute:
|
12
33
|
|
13
|
-
|
34
|
+
```bash
|
35
|
+
$ bundle
|
36
|
+
```
|
14
37
|
|
15
|
-
|
38
|
+
Or install it yourself as:
|
16
39
|
|
17
|
-
|
40
|
+
```bash
|
41
|
+
$ gem install union_of
|
42
|
+
```
|
18
43
|
|
19
44
|
## Usage
|
20
45
|
|
21
|
-
|
46
|
+
To use `union_of`, define a `has_many` association as you would normally, and
|
47
|
+
provide the associations you'd like to union together via the `union_of:`
|
48
|
+
keyword:
|
49
|
+
|
50
|
+
```ruby
|
51
|
+
class Book < ActiveRecord::Base
|
52
|
+
# the primary author of the book
|
53
|
+
belongs_to :author, class_name: 'User', foreign_key: 'author_id', optional: true
|
54
|
+
|
55
|
+
# coauthors of the book via a join table
|
56
|
+
has_many :coauthorships
|
57
|
+
has_many :coauthors, through: :coauthorships, source: :user
|
58
|
+
|
59
|
+
# prefacers for the book via a join table
|
60
|
+
has_many :prefaces
|
61
|
+
has_many :prefacers, through: :prefaces, source: :user
|
62
|
+
|
63
|
+
# prefacers for the book via a join table
|
64
|
+
has_many :forewords
|
65
|
+
has_many :foreworders, through: :forewords, source: :user
|
66
|
+
|
67
|
+
# illustrators for the book via a join table
|
68
|
+
has_many :illustrations
|
69
|
+
has_many :illustrators, -> { distinct }, through: :illustrations, source: :user
|
70
|
+
|
71
|
+
# editors for the book via a join table
|
72
|
+
has_many :edits
|
73
|
+
has_many :editors, -> { distinct }, through: :edits, source: :user
|
74
|
+
|
75
|
+
# union association for all contributors to the book
|
76
|
+
has_many :contributors, class_name: 'User', union_of: %i[
|
77
|
+
author
|
78
|
+
coauthors
|
79
|
+
foreworders
|
80
|
+
prefacers
|
81
|
+
illustrators
|
82
|
+
editors
|
83
|
+
]
|
84
|
+
end
|
85
|
+
```
|
86
|
+
|
87
|
+
Here's a quick example of what's possible:
|
88
|
+
|
89
|
+
```ruby
|
90
|
+
# contributors to the book
|
91
|
+
author = User.create(name: 'Isaac Asimov')
|
92
|
+
editor = User.create(name: 'John W. Campbell')
|
93
|
+
illustrator = User.create(name: 'Frank Kelly Freas')
|
94
|
+
writer = User.create(name: 'Ray Bradbury')
|
95
|
+
|
96
|
+
# create book by the author
|
97
|
+
book = Book.create(title: 'I, Robot', author:)
|
98
|
+
|
99
|
+
# assign a preface by the author
|
100
|
+
Preface.create(user: author, book:)
|
101
|
+
|
102
|
+
# assign foreword writers
|
103
|
+
Foreword.create(user: writer, book:)
|
104
|
+
Foreword.create(user: editor, book:)
|
22
105
|
|
23
|
-
|
106
|
+
# assign an illustrator
|
107
|
+
Illustration.create(user: illustrator, book:)
|
24
108
|
|
25
|
-
|
109
|
+
# assign an editor
|
110
|
+
Edit.create(user: editor, book:)
|
26
111
|
|
27
|
-
|
112
|
+
# access all contributors (author, editors, illustrator, etc.)
|
113
|
+
book.contributors.to_a
|
114
|
+
# => [#<User id=1, name="Isaac Asimov">, #<User id=2, name="John W. Campbell">, #<User id=3, name="Frank Kelly Freas">, #<User id=4, name="Ray Bradbury">]
|
115
|
+
|
116
|
+
# example of querying the union of contributors
|
117
|
+
book.contributors.order(:name).limit(3)
|
118
|
+
# => [#<User id=4, name="Frank Kelly Freas">, #<User id=3, name="Isaac Asimov">, #<User id=2, name="John W. Campbell">]
|
119
|
+
|
120
|
+
book.contributors.where(id: editor.id)
|
121
|
+
# => [#<User id=2, name="John W. Campbell">]
|
122
|
+
|
123
|
+
book.contributors.to_sql
|
124
|
+
# => SELECT * FROM users WHERE id IN (
|
125
|
+
# SELECT id FROM users WHERE id = 1
|
126
|
+
# UNION
|
127
|
+
# SELECT users.id FROM users INNER JOIN prefaces ON users.id = prefaces.user_id WHERE prefaces.book_id = 1
|
128
|
+
# UNION
|
129
|
+
# SELECT users.id FROM users INNER JOIN forewords ON users.id = forewords.user_id WHERE forewords.book_id = 1
|
130
|
+
# UNION
|
131
|
+
# SELECT DISTINCT users.id FROM users INNER JOIN illustrations ON users.id = illustrations.user_id WHERE illustrations.book_id = 1
|
132
|
+
# UNION
|
133
|
+
# SELECT DISTINCT users.id FROM users INNER JOIN edits ON users.id = edits.user_id WHERE edits.book_id = 1
|
134
|
+
# )
|
135
|
+
|
136
|
+
# example of more advanced querying e.g. preloading the union
|
137
|
+
Book.joins(:contributors).where(contributors: { ... })
|
138
|
+
Book.preload(:contributors)
|
139
|
+
Book.eager_load(:contributors)
|
140
|
+
Book.includes(:contributors)
|
141
|
+
```
|
142
|
+
|
143
|
+
Right now, the underlying table and model for each unioned association must
|
144
|
+
match. We'd like to change that in the future. Originally, `union_of` was
|
145
|
+
created to make migrating from a one-to-many relationship to a many-to-many
|
146
|
+
relationship easier and safer, while retaining backwards compatibility.
|
147
|
+
|
148
|
+
There is support for complex unions as well, e.g. a union made up of direct and
|
149
|
+
through associations, even when those associations utilize union associations.
|
150
|
+
|
151
|
+
## Supported databases
|
152
|
+
|
153
|
+
We currently support PostgreSQL, MySQL, and MariaDB. We'd love contributions
|
154
|
+
that add SQLite support, but we probably won't add it ourselves.
|
155
|
+
|
156
|
+
## Supported Rubies
|
157
|
+
|
158
|
+
**`union_of` supports Ruby 3.1 and above.** We encourage you to upgrade if
|
159
|
+
you're on an older version. Ruby 3 provides a lot of great features, like better
|
160
|
+
pattern matching and a new shorthand hash syntax.
|
161
|
+
|
162
|
+
## Performance notes
|
163
|
+
|
164
|
+
As is expected, you will need to pay close attention to performance and ensure
|
165
|
+
your tables are indexed well. We have tried to make the underlying `UNION`
|
166
|
+
queries as efficient as possible, but please open an issue or PR if you are
|
167
|
+
encountering a performance issue that is caused by this gem. But good indexing
|
168
|
+
will go a long way.
|
169
|
+
|
170
|
+
We use Postgres in production, but we do not actively use MySQL or MariaDB, so
|
171
|
+
there may be performance issues we are unaware of. If you stumble upon issues,
|
172
|
+
please open an issue or a PR.
|
173
|
+
|
174
|
+
## Is it any good?
|
175
|
+
|
176
|
+
Yes.
|
28
177
|
|
29
178
|
## Contributing
|
30
179
|
|
31
|
-
|
180
|
+
If you have an idea, or have discovered a bug, please open an issue or create a
|
181
|
+
pull request.
|
182
|
+
|
183
|
+
## License
|
184
|
+
|
185
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'readonly_association'
|
4
|
+
require_relative 'scope'
|
5
|
+
|
6
|
+
module UnionOf
|
7
|
+
class Association < UnionOf::ReadonlyAssociation
|
8
|
+
def skip_statement_cache?(...) = true # doesn't work with cache
|
9
|
+
def association_scope
|
10
|
+
return if
|
11
|
+
klass.nil?
|
12
|
+
|
13
|
+
@association_scope ||= UnionOf::Scope.create.scope(self)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UnionOf
|
4
|
+
class Builder < ActiveRecord::Associations::Builder::CollectionAssociation
|
5
|
+
private_class_method def self.valid_options(...) = %i[sources class_name inverse_of extend]
|
6
|
+
private_class_method def self.macro = :union_of
|
7
|
+
end
|
8
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'builder'
|
4
|
+
|
5
|
+
module UnionOf
|
6
|
+
module Macro
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
class_methods do
|
10
|
+
def union_of(name, scope = nil, **options, &extension)
|
11
|
+
reflection = UnionOf::Builder.build(self, name, scope, options, &extension)
|
12
|
+
|
13
|
+
ActiveRecord::Reflection.add_union_reflection(self, name, reflection)
|
14
|
+
ActiveRecord::Reflection.add_reflection(self, name, reflection)
|
15
|
+
end
|
16
|
+
|
17
|
+
def has_many(name, scope = nil, **options, &extension)
|
18
|
+
if sources = options.delete(:union_of)
|
19
|
+
union_of(name, scope, **options.merge(sources:), &extension)
|
20
|
+
else
|
21
|
+
super
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module UnionOf
|
5
|
+
module Preloader
|
6
|
+
class Association < ActiveRecord::Associations::Preloader::ThroughAssociation
|
7
|
+
def load_records(*)
|
8
|
+
preloaded_records # we don't need to load anything except the union associations
|
9
|
+
end
|
10
|
+
|
11
|
+
def preloaded_records
|
12
|
+
@preloaded_records ||= union_preloaders.flat_map(&:preloaded_records)
|
13
|
+
end
|
14
|
+
|
15
|
+
def records_by_owner
|
16
|
+
@records_by_owner ||= owners.each_with_object({}) do |owner, result|
|
17
|
+
if loaded?(owner)
|
18
|
+
result[owner] = target_for(owner)
|
19
|
+
|
20
|
+
next
|
21
|
+
end
|
22
|
+
|
23
|
+
records = union_records_by_owner[owner] || []
|
24
|
+
records.compact!
|
25
|
+
records.sort_by! { preload_index[_1] } unless scope.order_values.empty?
|
26
|
+
records.uniq! if scope.distinct_value
|
27
|
+
|
28
|
+
result[owner] = records
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def runnable_loaders
|
33
|
+
return [self] if
|
34
|
+
data_available?
|
35
|
+
|
36
|
+
union_preloaders.flat_map(&:runnable_loaders)
|
37
|
+
end
|
38
|
+
|
39
|
+
def future_classes
|
40
|
+
return [] if
|
41
|
+
run?
|
42
|
+
|
43
|
+
union_classes = union_preloaders.flat_map(&:future_classes).uniq
|
44
|
+
source_classes = source_reflection.chain.map(&:klass)
|
45
|
+
|
46
|
+
(union_classes + source_classes).uniq
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def data_available?
|
52
|
+
owners.all? { loaded?(_1) } || union_preloaders.all?(&:run?)
|
53
|
+
end
|
54
|
+
|
55
|
+
def source_reflection = reflection
|
56
|
+
def union_reflections = reflection.union_reflections
|
57
|
+
|
58
|
+
def union_preloaders
|
59
|
+
@union_preloaders ||= ActiveRecord::Associations::Preloader.new(scope:, records: owners, associations: union_reflections.collect(&:name))
|
60
|
+
.loaders
|
61
|
+
end
|
62
|
+
|
63
|
+
def union_records_by_owner
|
64
|
+
@union_records_by_owner ||= union_preloaders.map(&:records_by_owner).reduce do |left, right|
|
65
|
+
left.merge(right) do |owner, left_records, right_records|
|
66
|
+
left_records | right_records # merge record sets
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def build_scope
|
72
|
+
scope = source_reflection.klass.unscoped
|
73
|
+
|
74
|
+
if reflection.type && !reflection.through_reflection?
|
75
|
+
scope.where!(reflection.type => model.polymorphic_name)
|
76
|
+
end
|
77
|
+
|
78
|
+
scope.merge!(reflection_scope) unless reflection_scope.empty_scope?
|
79
|
+
|
80
|
+
if preload_scope && !preload_scope.empty_scope?
|
81
|
+
scope.merge!(preload_scope)
|
82
|
+
end
|
83
|
+
|
84
|
+
cascade_strict_loading(scope)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
# frozen_string_literal
|
2
|
+
|
3
|
+
require_relative 'macro'
|
4
|
+
require_relative 'preloader'
|
5
|
+
require_relative 'readonly_association_proxy'
|
6
|
+
|
7
|
+
module UnionOf
|
8
|
+
module ReflectionExtension
|
9
|
+
def add_union_reflection(model, name, reflection)
|
10
|
+
model.union_reflections = model.union_reflections.merge(name.to_s => reflection)
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def reflection_class_for(macro)
|
16
|
+
case macro
|
17
|
+
when :union_of
|
18
|
+
UnionOf::Reflection
|
19
|
+
else
|
20
|
+
super
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
module MacroReflectionExtension
|
26
|
+
def through_union_of? = false
|
27
|
+
def union_of? = false
|
28
|
+
end
|
29
|
+
|
30
|
+
module RuntimeReflectionExtension
|
31
|
+
delegate :union_of?, :union_sources, to: :@reflection
|
32
|
+
delegate :name, :active_record_primary_key, to: :@reflection # FIXME(ezekg)
|
33
|
+
end
|
34
|
+
|
35
|
+
module ActiveRecordExtensions
|
36
|
+
extend ActiveSupport::Concern
|
37
|
+
|
38
|
+
included do
|
39
|
+
include UnionOf::Macro
|
40
|
+
|
41
|
+
class_attribute :union_reflections, instance_writer: false, default: {}
|
42
|
+
end
|
43
|
+
|
44
|
+
class_methods do
|
45
|
+
def reflect_on_all_unions = union_reflections.values
|
46
|
+
def reflect_on_union(union)
|
47
|
+
union_reflections[union.to_s]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
module ThroughReflectionExtension
|
53
|
+
delegate :union_of?, :union_sources, to: :source_reflection
|
54
|
+
delegate :join_scope, to: :source_reflection
|
55
|
+
|
56
|
+
def through_union_of? = through_reflection.union_of? || through_reflection.through_union_of?
|
57
|
+
end
|
58
|
+
|
59
|
+
module AssociationExtension
|
60
|
+
def scope
|
61
|
+
if reflection.union_of? || reflection.through_union_of?
|
62
|
+
UnionOf::Scope.create.scope(self)
|
63
|
+
else
|
64
|
+
super
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
module PreloaderExtension
|
70
|
+
def preloader_for(reflection)
|
71
|
+
if reflection.union_of?
|
72
|
+
UnionOf::Preloader::Association
|
73
|
+
else
|
74
|
+
super
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
module DelegationExtension
|
80
|
+
def delegated_classes
|
81
|
+
super << UnionOf::ReadonlyAssociationProxy
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
module JoinAssociationExtension
|
86
|
+
# Overloads Rails internals to prepend our left outer joins onto the join chain since Rails
|
87
|
+
# unfortunately does not do this for us (it can do inner joins via the LeadingJoin arel
|
88
|
+
# node, but it can't do outer joins because there is no LeadingOuterJoin node).
|
89
|
+
def join_constraints(foreign_table, foreign_klass, join_type, alias_tracker)
|
90
|
+
chain = reflection.chain.reverse
|
91
|
+
joins = super
|
92
|
+
|
93
|
+
# FIXME(ezekg) This is inefficient (we're recreating reflection scopes).
|
94
|
+
chain.zip(joins).each do |reflection, join|
|
95
|
+
klass = reflection.klass
|
96
|
+
table = join.left
|
97
|
+
|
98
|
+
if reflection.union_of?
|
99
|
+
scope = reflection.join_scope(table, foreign_table, foreign_klass, alias_tracker)
|
100
|
+
arel = scope.arel(alias_tracker.aliases)
|
101
|
+
|
102
|
+
# Splice union dependencies, i.e. left joins, into the join chain. This is the least
|
103
|
+
# intrusive way of doing this, since we don't want to overload AR internals.
|
104
|
+
unless arel.join_sources.empty?
|
105
|
+
index = joins.index(join)
|
106
|
+
|
107
|
+
unless (constraints = arel.constraints).empty?
|
108
|
+
right = join.right
|
109
|
+
|
110
|
+
right.expr = constraints # updated aliases
|
111
|
+
end
|
112
|
+
|
113
|
+
joins.insert(index, *arel.join_sources)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# The current table in this iteration becomes the foreign table in the next
|
118
|
+
foreign_table, foreign_klass = table, klass
|
119
|
+
end
|
120
|
+
|
121
|
+
joins
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
ActiveSupport.on_load :active_record do
|
126
|
+
include ActiveRecordExtensions
|
127
|
+
|
128
|
+
ActiveRecord::Reflection.singleton_class.prepend(ReflectionExtension)
|
129
|
+
ActiveRecord::Reflection::MacroReflection.prepend(MacroReflectionExtension)
|
130
|
+
ActiveRecord::Reflection::RuntimeReflection.prepend(RuntimeReflectionExtension)
|
131
|
+
ActiveRecord::Reflection::ThroughReflection.prepend(ThroughReflectionExtension)
|
132
|
+
ActiveRecord::Associations::Association.prepend(AssociationExtension)
|
133
|
+
ActiveRecord::Associations::JoinDependency::JoinAssociation.prepend(JoinAssociationExtension)
|
134
|
+
ActiveRecord::Associations::Preloader::Branch.prepend(PreloaderExtension)
|
135
|
+
ActiveRecord::Delegation.singleton_class.prepend(DelegationExtension)
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UnionOf
|
4
|
+
class ReadonlyAssociation < ActiveRecord::Associations::CollectionAssociation
|
5
|
+
MUTATION_METHODS = %i[
|
6
|
+
writer ids_writer
|
7
|
+
insert_record build_record
|
8
|
+
destroy_all delete_all delete_records
|
9
|
+
update_all concat_records
|
10
|
+
]
|
11
|
+
|
12
|
+
MUTATION_METHODS.each do |method_name|
|
13
|
+
define_method method_name do |*, **|
|
14
|
+
raise UnionOf::ReadonlyAssociationError.new(owner, reflection)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def reader
|
19
|
+
ensure_klass_exists!
|
20
|
+
|
21
|
+
if stale_target?
|
22
|
+
reload
|
23
|
+
end
|
24
|
+
|
25
|
+
@proxy ||= UnionOf::ReadonlyAssociationProxy.create(klass, self)
|
26
|
+
@proxy.reset_scope
|
27
|
+
end
|
28
|
+
|
29
|
+
def count_records
|
30
|
+
count = scope.count(:all)
|
31
|
+
|
32
|
+
if count.zero?
|
33
|
+
target.select!(&:new_record?)
|
34
|
+
loaded!
|
35
|
+
end
|
36
|
+
|
37
|
+
[association_scope.limit_value, count].compact.min
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UnionOf
|
4
|
+
class ReadonlyAssociationProxy < ActiveRecord::Associations::CollectionProxy
|
5
|
+
MUTATION_METHODS = %i[
|
6
|
+
insert insert! insert_all insert_all!
|
7
|
+
build new create create!
|
8
|
+
upsert upsert_all update_all update! update
|
9
|
+
delete destroy destroy_all delete_all
|
10
|
+
]
|
11
|
+
|
12
|
+
MUTATION_METHODS.each do |method_name|
|
13
|
+
define_method method_name do |*, **|
|
14
|
+
raise UnionOf::ReadonlyAssociationError.new(@association.owner, @association.reflection)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UnionOf
|
4
|
+
class Reflection < ActiveRecord::Reflection::AssociationReflection
|
5
|
+
attr_reader :union_sources
|
6
|
+
|
7
|
+
def initialize(...)
|
8
|
+
super
|
9
|
+
|
10
|
+
@union_sources = @options[:sources]
|
11
|
+
end
|
12
|
+
|
13
|
+
def macro = :union_of
|
14
|
+
def union_of? = true
|
15
|
+
def collection? = true
|
16
|
+
def association_class = UnionOf::Association
|
17
|
+
def union_reflections = union_sources.collect { active_record.reflect_on_association(_1) }
|
18
|
+
|
19
|
+
def join_scope(table, foreign_table, foreign_klass, alias_tracker = nil)
|
20
|
+
predicate_builder = predicate_builder(table)
|
21
|
+
scope_chain_items = join_scopes(table, predicate_builder)
|
22
|
+
klass_scope = klass_join_scope(table, predicate_builder)
|
23
|
+
|
24
|
+
# This holds our union's constraints. For example, if we're unioning across 3
|
25
|
+
# tables, then this will hold constraints for all 3 of those tables, so that
|
26
|
+
# the join on our target table mirrors the union of all 3 associations.
|
27
|
+
foreign_constraints = []
|
28
|
+
|
29
|
+
union_sources.each do |union_source|
|
30
|
+
union_reflection = foreign_klass.reflect_on_association(union_source)
|
31
|
+
|
32
|
+
if union_reflection.through_reflection?
|
33
|
+
source_reflection = union_reflection.source_reflection
|
34
|
+
through_reflection = union_reflection.through_reflection
|
35
|
+
through_klass = through_reflection.klass
|
36
|
+
through_table = through_klass.arel_table
|
37
|
+
|
38
|
+
# Alias table if we're provided with an alias tracker (i.e. via our #join_constraints overload)
|
39
|
+
unless alias_tracker.nil?
|
40
|
+
through_table = alias_tracker.aliased_table_for(through_table) do
|
41
|
+
through_reflection.alias_candidate(union_source)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Create base join constraints and add default constraints if available
|
46
|
+
through_constraint = through_table[through_reflection.join_primary_key].eq(
|
47
|
+
foreign_table[through_reflection.join_foreign_key],
|
48
|
+
)
|
49
|
+
|
50
|
+
unless (where_clause = through_klass.default_scoped.where_clause).empty?
|
51
|
+
through_constraint = where_clause.ast.and(through_constraint)
|
52
|
+
end
|
53
|
+
|
54
|
+
klass_scope.joins!(
|
55
|
+
Arel::Nodes::OuterJoin.new(
|
56
|
+
through_table,
|
57
|
+
Arel::Nodes::On.new(through_constraint),
|
58
|
+
),
|
59
|
+
)
|
60
|
+
|
61
|
+
foreign_constraints << table[source_reflection.join_primary_key].eq(through_table[source_reflection.join_foreign_key])
|
62
|
+
else
|
63
|
+
foreign_constraints << table[union_reflection.join_primary_key].eq(foreign_table[union_reflection.join_foreign_key])
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
unless foreign_constraints.empty?
|
68
|
+
foreign_constraint = foreign_constraints.reduce(&:or)
|
69
|
+
|
70
|
+
klass_scope.where!(foreign_constraint)
|
71
|
+
end
|
72
|
+
|
73
|
+
unless scope_chain_items.empty?
|
74
|
+
scope_chain_items.reduce(klass_scope) do |scope, item|
|
75
|
+
scope.merge!(item) # e.g. default scope constraints
|
76
|
+
end
|
77
|
+
|
78
|
+
# FIXME(ezekg) Wrapping the where clause in a grouping node so that Rails
|
79
|
+
# doesn't append our left outer joins a second time. This is
|
80
|
+
# because internally, during joining in #join_constraints,
|
81
|
+
# if Rails sees an Arel::Nodes::And node with predicates that
|
82
|
+
# don't match the current table, it'll concat all join
|
83
|
+
# sources. We don't want that, thus the hack.
|
84
|
+
klass_scope.where_clause = ActiveRecord::Relation::WhereClause.new(
|
85
|
+
[Arel::Nodes::Grouping.new(klass_scope.where_clause.ast)],
|
86
|
+
)
|
87
|
+
end
|
88
|
+
|
89
|
+
klass_scope
|
90
|
+
end
|
91
|
+
|
92
|
+
# FIXME(ezekg) scope cache is borked
|
93
|
+
def association_scope_cache(...) = raise NotImplementedError
|
94
|
+
|
95
|
+
def deconstruct_keys(keys) = { name:, options: }
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UnionOf
|
4
|
+
class Scope < ActiveRecord::Associations::AssociationScope
|
5
|
+
private
|
6
|
+
|
7
|
+
def last_chain_scope(scope, reflection, owner)
|
8
|
+
return super unless reflection.union_of?
|
9
|
+
|
10
|
+
foreign_klass = reflection.klass
|
11
|
+
foreign_table = reflection.aliased_table
|
12
|
+
primary_key = reflection.active_record_primary_key
|
13
|
+
|
14
|
+
sources = reflection.union_sources.map do |source|
|
15
|
+
association = owner.association(source)
|
16
|
+
|
17
|
+
association.scope.select(association.reflection.active_record_primary_key)
|
18
|
+
.unscope(:order)
|
19
|
+
.arel
|
20
|
+
end
|
21
|
+
|
22
|
+
unions = sources.compact.reduce(nil) do |left, right|
|
23
|
+
if left
|
24
|
+
Arel::Nodes::Union.new(left, right)
|
25
|
+
else
|
26
|
+
right
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# We can simplify the query if the scope class is the same as our foreign class
|
31
|
+
if scope.klass == foreign_klass
|
32
|
+
scope.where!(
|
33
|
+
foreign_table[primary_key].in(
|
34
|
+
foreign_table.project(foreign_table[primary_key])
|
35
|
+
.from(
|
36
|
+
Arel::Nodes::TableAlias.new(unions, foreign_table.name),
|
37
|
+
),
|
38
|
+
),
|
39
|
+
)
|
40
|
+
else
|
41
|
+
# FIXME(ezekg) Selecting IDs in a separate query is faster than a subquery
|
42
|
+
# selecting IDs, or an EXISTS subquery, or even a
|
43
|
+
# materialized CTE. Not sure why...
|
44
|
+
ids = foreign_klass.find_by_sql(
|
45
|
+
foreign_table.project(foreign_table[primary_key])
|
46
|
+
.from(
|
47
|
+
Arel::Nodes::TableAlias.new(unions, foreign_table.name),
|
48
|
+
),
|
49
|
+
)
|
50
|
+
.pluck(
|
51
|
+
primary_key,
|
52
|
+
)
|
53
|
+
|
54
|
+
scope.where!(
|
55
|
+
foreign_table[primary_key].in(ids),
|
56
|
+
)
|
57
|
+
end
|
58
|
+
|
59
|
+
scope.merge!(
|
60
|
+
scope.default_scoped,
|
61
|
+
)
|
62
|
+
|
63
|
+
scope
|
64
|
+
end
|
65
|
+
|
66
|
+
def next_chain_scope(scope, reflection, next_reflection)
|
67
|
+
return super unless reflection.union_of?
|
68
|
+
|
69
|
+
klass = reflection.klass
|
70
|
+
table = klass.arel_table
|
71
|
+
foreign_klass = next_reflection.klass
|
72
|
+
foreign_table = foreign_klass.arel_table
|
73
|
+
|
74
|
+
# This holds our union's constraints. For example, if we're unioning across 3
|
75
|
+
# tables, then this will hold constraints for all 3 of those tables, so that
|
76
|
+
# the join on our target table mirrors the union of all 3 associations.
|
77
|
+
foreign_constraints = []
|
78
|
+
|
79
|
+
reflection.union_sources.each do |union_source|
|
80
|
+
union_reflection = foreign_klass.reflect_on_association(union_source)
|
81
|
+
|
82
|
+
if union_reflection.through_reflection?
|
83
|
+
through_reflection = union_reflection.through_reflection
|
84
|
+
through_table = through_reflection.klass.arel_table
|
85
|
+
|
86
|
+
scope.left_outer_joins!(
|
87
|
+
through_reflection.name,
|
88
|
+
)
|
89
|
+
|
90
|
+
foreign_constraints << foreign_table[through_reflection.join_foreign_key].eq(through_table[through_reflection.join_primary_key])
|
91
|
+
else
|
92
|
+
foreign_constraints << foreign_table[union_reflection.join_foreign_key].eq(table[union_reflection.join_primary_key])
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Flatten union constraints and add any default constraints
|
97
|
+
foreign_constraint = unless (where_clause = foreign_klass.default_scoped.where_clause).empty?
|
98
|
+
where_clause.ast.and(foreign_constraints.reduce(&:or))
|
99
|
+
else
|
100
|
+
foreign_constraints.reduce(&:or)
|
101
|
+
end
|
102
|
+
|
103
|
+
scope.joins!(
|
104
|
+
Arel::Nodes::InnerJoin.new(
|
105
|
+
foreign_table,
|
106
|
+
Arel::Nodes::On.new(
|
107
|
+
foreign_constraint,
|
108
|
+
),
|
109
|
+
),
|
110
|
+
)
|
111
|
+
|
112
|
+
# FIXME(ezekg) Why is this needed? Should be handled automatically...
|
113
|
+
scope.merge!(
|
114
|
+
scope.default_scoped,
|
115
|
+
)
|
116
|
+
|
117
|
+
scope
|
118
|
+
end
|
119
|
+
|
120
|
+
# NOTE(ezekg) This overloads our scope's joins to not use an Arel::Nodes::LeadingJoin node.
|
121
|
+
def join(table, constraint)
|
122
|
+
Arel::Nodes::InnerJoin.new(table, Arel::Nodes::On.new(constraint))
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
data/lib/union_of/version.rb
CHANGED
data/lib/union_of.rb
CHANGED
@@ -1,6 +1,25 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require 'active_support'
|
4
|
+
require 'active_record'
|
5
|
+
|
6
|
+
require_relative 'union_of/association'
|
7
|
+
require_relative 'union_of/builder'
|
8
|
+
require_relative 'union_of/macro'
|
9
|
+
require_relative 'union_of/preloader'
|
10
|
+
require_relative 'union_of/readonly_association_proxy'
|
11
|
+
require_relative 'union_of/readonly_association'
|
12
|
+
require_relative 'union_of/reflection'
|
13
|
+
require_relative 'union_of/scope'
|
3
14
|
require_relative 'union_of/version'
|
15
|
+
require_relative 'union_of/railtie'
|
4
16
|
|
5
17
|
module UnionOf
|
18
|
+
class Error < ActiveRecord::ActiveRecordError; end
|
19
|
+
|
20
|
+
class ReadonlyAssociationError < Error
|
21
|
+
def initialize(owner, reflection)
|
22
|
+
super("Cannot modify association '#{owner.class.name}##{reflection.name}' because it is read-only")
|
23
|
+
end
|
24
|
+
end
|
6
25
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: union_of
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 1.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Zeke Gabrielse
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-08-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -16,14 +16,14 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
19
|
+
version: '7.0'
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '
|
26
|
+
version: '7.0'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: rspec-rails
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -38,9 +38,79 @@ dependencies:
|
|
38
38
|
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '0'
|
41
|
-
|
42
|
-
|
43
|
-
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: temporary_tables
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: sql_matchers
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: sqlite3
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.4'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.4'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: mysql2
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: pg
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
description: Create associations that combine multiple Active Record associations
|
112
|
+
using a SQL UNION under the hood, with full support for joins, preloading and eager
|
113
|
+
loading on the union association, as well as through-union associations.
|
44
114
|
email:
|
45
115
|
- oss@keygen.sh
|
46
116
|
executables: []
|
@@ -53,6 +123,16 @@ files:
|
|
53
123
|
- README.md
|
54
124
|
- SECURITY.md
|
55
125
|
- lib/union_of.rb
|
126
|
+
- lib/union_of/association.rb
|
127
|
+
- lib/union_of/builder.rb
|
128
|
+
- lib/union_of/macro.rb
|
129
|
+
- lib/union_of/preloader.rb
|
130
|
+
- lib/union_of/preloader/association.rb
|
131
|
+
- lib/union_of/railtie.rb
|
132
|
+
- lib/union_of/readonly_association.rb
|
133
|
+
- lib/union_of/readonly_association_proxy.rb
|
134
|
+
- lib/union_of/reflection.rb
|
135
|
+
- lib/union_of/scope.rb
|
56
136
|
- lib/union_of/version.rb
|
57
137
|
homepage: https://github.com/keygen-sh/union_of
|
58
138
|
licenses:
|
@@ -76,6 +156,6 @@ requirements: []
|
|
76
156
|
rubygems_version: 3.4.13
|
77
157
|
signing_key:
|
78
158
|
specification_version: 4
|
79
|
-
summary:
|
80
|
-
|
159
|
+
summary: Create associations that combine multiple Active Record associations using
|
160
|
+
a SQL UNION under the hood.
|
81
161
|
test_files: []
|