union_of 0.0.2 → 1.0.0.pre.rc.1
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 +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 +91 -11
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4e5752ebe53ef7c334a17a993b25cb2c6022914ea4fd098e2556c1ec729f0db2
|
4
|
+
data.tar.gz: ececada3388808b0d97247a69849c86039c91489dc14ba33695ea8029f858c88
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0b1d9166f9a4bc93a730e1e649acbd1bfd2a4ae7adbf36632359b8967b1bb7e7e7042721fb85c3be4422b9031161a9906f4a8e41e087290e6225fe4db7ddfe34
|
7
|
+
data.tar.gz: 3338d36c00bab96d8e90c6255db89d7ea13b5cce6306d3b3463572b28f5c8c4dc5831b99e38cc4d3b7bc8b198c170269291a213cfbf2b08d777210ebd528fc26
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,31 +1,185 @@
|
|
1
|
-
#
|
1
|
+
# union_of
|
2
2
|
|
3
|
-
|
3
|
+
[](https://github.com/keygen-sh/union_of/actions)
|
4
|
+
[](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.pre.rc.1
|
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:
|
@@ -69,13 +149,13 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
69
149
|
version: '3.1'
|
70
150
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
71
151
|
requirements:
|
72
|
-
- - "
|
152
|
+
- - ">"
|
73
153
|
- !ruby/object:Gem::Version
|
74
|
-
version:
|
154
|
+
version: 1.3.1
|
75
155
|
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: []
|