batch-loader-active-record 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +164 -5
- data/lib/batch_loader_active_record.rb +72 -15
- data/lib/batch_loader_active_record/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c7c24c7d661f0d2449d676892c1d328f8c5147e3
|
4
|
+
data.tar.gz: 83d9fa13f7dcab069ed2b1d1f11fc4e1f36834f9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0677e7a74dfe10a238bd431d171d44bbff07b3c8a6f775bfbe9bf595f345ea0fce7c35a494eceb7702e5abb56ffcada2ea426c23097286a77bfb200a0022ebb3
|
7
|
+
data.tar.gz: 0e0e3edd2866a7c578567f5171e23fae3356b8469ada0123781d5d7b0276c655af33149baa1da985e8c4d198b8ddf3ad48f961b38609b03ed959ea7dfaeff58b
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -1,9 +1,48 @@
|
|
1
1
|
# Batch Loader - Active Record #
|
2
2
|
|
3
3
|
[![Build Status](https://travis-ci.org/mathieul/batch-loader-active-record.svg?branch=master)](https://travis-ci.org/mathieul/batch-loader-active-record)
|
4
|
+
[![Gem Version](https://badge.fury.io/rb/batch-loader-active-record.svg)](https://badge.fury.io/rb/batch-loader-active-record)
|
4
5
|
|
5
6
|
This gem allows to leverage the awesome [batch-loader gem](https://github.com/exAspArk/batch-loader) to generate lazy Active Record relationships without any boilerplate.
|
6
7
|
|
8
|
+
It is not intended to be used for all associations though, but only where necessary. It should be used as a complement to vanilla batch loaders written directly using [batch-loader gem](https://github.com/exAspArk/batch-loader).
|
9
|
+
|
10
|
+
**This gem is in active deployment and is likely not yet ready to be used on production.**
|
11
|
+
|
12
|
+
|
13
|
+
## Description
|
14
|
+
|
15
|
+
This is a very simple gem which is basically a mixin containg replacement macros for the 3 active record association macros (note that **polymorphic associations and association scopes are not yet supported**):
|
16
|
+
|
17
|
+
* `belongs_to_lazy`
|
18
|
+
* `has_one_lazy`
|
19
|
+
* `has_many_lazy`
|
20
|
+
|
21
|
+
Those are intended to use in replacement of the original Active Record class methods when you also want to generate a method to avoid N+1 calls to the database when accessed for the elements of a collection. For more details on why N+1 queries are common when fetching associations read [the batch-loader gem README](https://github.com/exAspArk/batch-loader/#why).
|
22
|
+
|
23
|
+
For example let's imagine a post which can have many comments:
|
24
|
+
|
25
|
+
```ruby
|
26
|
+
class Post < ActiveRecord::Base
|
27
|
+
has_many_lazy :comments
|
28
|
+
end
|
29
|
+
|
30
|
+
class Comment < ActiveRecord::Base
|
31
|
+
belongs_to :post
|
32
|
+
end
|
33
|
+
```
|
34
|
+
|
35
|
+
Now we get a list of post objects and we want to fetch all the comments for each post. When we know in advance that we'll need the post comments, then Active Record query [#includes](http://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-includes) will trigger a single query to fetch posts and comments.
|
36
|
+
|
37
|
+
But often we don't know in advance in the code responsible to fetch the posts if we'll need access to the comments as well. When implenting a GraphQL API for instance, the post resolver doesn't know if the comments are also part of the GraphQL query.Using `#includes` in this case would be wasteful and slower for the cases when we don't need the comments.
|
38
|
+
|
39
|
+
When using the lazy association accessor (i.e.: `post.comments_lazy`), a Batch Loader object is returned instead of a model relation and the query with the post id is buffered temporarily in the thread hash. No query to the database is executed yet. Calling the same association accessor on another post instance will add this post id to the list in the tread context. And so on until we access one of those Batch Loader objects returned. Only then is the database query executed and all Batch Loader objects are replaced by the records just fetched (not really replaced, they use delegation under the cover).
|
40
|
+
|
41
|
+
It is important to note that Active Record association accessors return relations which can be chained using the Active Record query API. But the lazy association accessors generated by `batch-loader-active-record` return (for all intents and purposes) an active record instance or an array of active record instances which can't be chained.
|
42
|
+
|
43
|
+
To benefit from the query batching we must first collect the lazy associations for each model instance in our collection, and only then we can start using them to access their content. Accessing a lazy object too early triggers the database query too early. For instance using `#flat_map` to collect and use the lazy objects would fail as `#flat_map` does access each element of the collection immediately in order to flatten the result.
|
44
|
+
|
45
|
+
|
7
46
|
## Installation
|
8
47
|
|
9
48
|
Add this line to your application's Gemfile:
|
@@ -20,15 +59,135 @@ Or install it yourself as:
|
|
20
59
|
|
21
60
|
$ gem install batch-loader-active-record
|
22
61
|
|
62
|
+
|
23
63
|
## Usage
|
24
64
|
|
25
|
-
|
65
|
+
Include the `BatchLoaderActiveRecord` module at the beginning of the model classes where lazy associations are needed, and use one of the lazy class macros to declare all lazy associations.
|
26
66
|
|
27
|
-
|
28
|
-
|
29
|
-
|
67
|
+
### Belongs To ###
|
68
|
+
|
69
|
+
Consider the following data model:
|
70
|
+
|
71
|
+
```ruby
|
72
|
+
class Post < ActiveRecord::Base
|
73
|
+
has_many :comments
|
74
|
+
end
|
75
|
+
|
76
|
+
class Comment < ActiveRecord::Base
|
77
|
+
include BatchLoaderActiveRecord
|
78
|
+
belongs_to_lazy :post
|
79
|
+
end
|
80
|
+
```
|
81
|
+
|
82
|
+
We need to know the `post` owning each instance of `comments`:
|
83
|
+
|
84
|
+
```ruby
|
85
|
+
posts = comments.map(&:post_lazy)
|
86
|
+
# no DB query executed yet
|
87
|
+
posts.map(&:author_first_name)
|
88
|
+
# DB query was executed
|
89
|
+
# => ["Jane", "Anne", ...]
|
90
|
+
```
|
91
|
+
|
92
|
+
### Has One ###
|
93
|
+
|
94
|
+
Consider the following data model:
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
class Account < ActiveRecord::Base
|
98
|
+
include BatchLoaderActiveRecord
|
99
|
+
has_one_lazy :affiliate
|
100
|
+
end
|
101
|
+
|
102
|
+
class Affiliate < ActiveRecord::Base
|
103
|
+
belongs_to :account
|
104
|
+
end
|
105
|
+
```
|
106
|
+
|
107
|
+
Fetching all affiliates for the accounts who do have one affiliate:
|
108
|
+
|
109
|
+
```ruby
|
110
|
+
affiliates = accounts.map(&:affiliate_lazy)
|
111
|
+
# no DB query executed yet
|
112
|
+
affiliates.first.name
|
113
|
+
# DB query was executed
|
114
|
+
affiliates.compact
|
115
|
+
# => [#<Affiliate id: 123>, #<Affiliate id: 456>]
|
116
|
+
```
|
117
|
+
|
118
|
+
### Has Many ###
|
119
|
+
|
120
|
+
Consider the following data model:
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
class Contact < ActiveRecord::Base
|
124
|
+
include BatchLoaderActiveRecord
|
125
|
+
has_many_lazy :phone_numbers
|
126
|
+
end
|
127
|
+
|
128
|
+
class PhoneNumber < ActiveRecord::Base
|
129
|
+
belongs_to :contact
|
130
|
+
scope :active, -> { where(enabled: true) }
|
131
|
+
end
|
132
|
+
```
|
133
|
+
|
134
|
+
This time we want the list of phone numbers for a collection of contacts.
|
135
|
+
|
136
|
+
```ruby
|
137
|
+
contacts.map(&:phone_numbers_lazy).flatten
|
138
|
+
```
|
139
|
+
|
140
|
+
It is also possible to apply scopes and conditions to a lazy has_many association. For instance if we want to only fetch active phone numbers in the example above, you would specify the scope like so:
|
141
|
+
|
142
|
+
```ruby
|
143
|
+
contacts.map { |contact| contact.phone_numbers_lazy(PhoneNumber.active) }.flatten
|
144
|
+
```
|
145
|
+
|
146
|
+
|
147
|
+
### Has Many :through ###
|
148
|
+
|
149
|
+
Consider the following data model with a has-many association going through another has-many-through association. Agents can have many phones they use to call providers:
|
150
|
+
|
151
|
+
```ruby
|
152
|
+
class Agent < ActiveRecord::Base
|
153
|
+
include BatchLoaderActiveRecord
|
154
|
+
has_many :phones
|
155
|
+
has_many_lazy :providers, through: :phones
|
156
|
+
end
|
157
|
+
|
158
|
+
class Phone < ActiveRecord::Base
|
159
|
+
belongs_to :agent
|
160
|
+
has_many :calls
|
161
|
+
has_many :providers, through: :calls
|
162
|
+
end
|
163
|
+
|
164
|
+
class Call < ActiveRecord::Base
|
165
|
+
belongs_to :provider
|
166
|
+
belongs_to :phone
|
167
|
+
end
|
168
|
+
|
169
|
+
class Provider < ActiveRecord::Base
|
170
|
+
has_many :calls
|
171
|
+
end
|
172
|
+
```
|
173
|
+
|
174
|
+
We want to fetch the list of providers who were called by a list of agents:
|
175
|
+
|
176
|
+
```ruby
|
177
|
+
agents.map(&:providers_lazy).uniq
|
178
|
+
```
|
179
|
+
|
180
|
+
This would trigger this query for the collection of agents with ids 4212, 265 and 2309:
|
181
|
+
|
182
|
+
```sql
|
183
|
+
SELECT providers.*, agents.ID AS _instance_id
|
184
|
+
FROM providers
|
185
|
+
INNER JOIN calls ON calls.provider_id = providers.ID
|
186
|
+
INNER JOIN phones ON phones.ID = calls.phone_id
|
187
|
+
INNER JOIN agents ON agents.ID = phones.agent_id
|
188
|
+
WHERE (agents. ID IN(4212, 265, 2309))
|
189
|
+
```
|
30
190
|
|
31
|
-
You use those generators in replacement of the original Active Record association class methods.
|
32
191
|
|
33
192
|
## Development
|
34
193
|
|
@@ -11,12 +11,12 @@ module BatchLoaderActiveRecord
|
|
11
11
|
module ClassMethods
|
12
12
|
def belongs_to_lazy(*args)
|
13
13
|
belongs_to(*args).tap do |reflections|
|
14
|
-
|
15
|
-
batch_key = [table_name,
|
16
|
-
define_method(:"#{
|
17
|
-
foreign_key_value = send(
|
14
|
+
reflect = reflections.values.last
|
15
|
+
batch_key = [table_name, reflect.name]
|
16
|
+
define_method(:"#{reflect.name}_lazy") do
|
17
|
+
foreign_key_value = send(reflect.foreign_key) or return nil
|
18
18
|
BatchLoader.for(foreign_key_value).batch(key: batch_key) do |foreign_key_values, loader|
|
19
|
-
|
19
|
+
reflect.klass.where(id: foreign_key_values).each { |instance| loader.call(instance.id, instance) }
|
20
20
|
end
|
21
21
|
end
|
22
22
|
end
|
@@ -24,12 +24,12 @@ module BatchLoaderActiveRecord
|
|
24
24
|
|
25
25
|
def has_one_lazy(*args)
|
26
26
|
has_one(*args).tap do |reflections|
|
27
|
-
|
28
|
-
batch_key = [table_name,
|
29
|
-
define_method(:"#{
|
27
|
+
reflect = reflections.values.last
|
28
|
+
batch_key = [table_name, reflect.name]
|
29
|
+
define_method(:"#{reflect.name}_lazy") do
|
30
30
|
BatchLoader.for(id).batch(key: batch_key) do |model_ids, loader|
|
31
|
-
|
32
|
-
loader.call(instance.public_send(
|
31
|
+
reflect.klass.where(reflect.foreign_key => model_ids).each do |instance|
|
32
|
+
loader.call(instance.public_send(reflect.foreign_key), instance)
|
33
33
|
end
|
34
34
|
end
|
35
35
|
end
|
@@ -38,16 +38,73 @@ module BatchLoaderActiveRecord
|
|
38
38
|
|
39
39
|
def has_many_lazy(*args)
|
40
40
|
has_many(*args).tap do |reflections|
|
41
|
-
|
42
|
-
|
43
|
-
define_method(:"#{
|
41
|
+
reflect = reflections.values.last
|
42
|
+
base_key = [table_name, reflect.name]
|
43
|
+
define_method(:"#{reflect.name}_lazy") do |instance_scope = nil|
|
44
|
+
batch_key = base_key
|
45
|
+
batch_key += [instance_scope.to_sql.hash] unless instance_scope.nil?
|
44
46
|
BatchLoader.for(id).batch(default_value: [], key: batch_key) do |model_ids, loader|
|
45
|
-
|
46
|
-
|
47
|
+
relation = instance_scope || reflect.klass
|
48
|
+
if reflect.through_reflection?
|
49
|
+
instances = self.class.fetch_for_model_ids(model_ids, relation: relation, reflection: reflect)
|
50
|
+
instances.each do |instance|
|
51
|
+
loader.call(instance.public_send(:_instance_id)) { |value| value << instance }
|
52
|
+
end
|
53
|
+
else
|
54
|
+
relation.where(reflect.foreign_key => model_ids).each do |instance|
|
55
|
+
loader.call(instance.public_send(reflect.foreign_key)) { |value| value << instance }
|
56
|
+
end
|
47
57
|
end
|
48
58
|
end
|
49
59
|
end
|
50
60
|
end
|
51
61
|
end
|
62
|
+
|
63
|
+
def fetch_for_model_ids(ids, relation:, reflection:)
|
64
|
+
instance_id_path = "#{reflection.active_record.table_name}.#{reflection.active_record.primary_key}"
|
65
|
+
model_class = reflection.active_record
|
66
|
+
reflections = reflection_chain(reflection)
|
67
|
+
join_strings = [reflection_join(reflections.first, relation)]
|
68
|
+
reflections.each_cons(2) do |previous, current|
|
69
|
+
join_strings << reflection_join(current, previous.active_record)
|
70
|
+
end
|
71
|
+
select_relation = join_strings.reduce(relation) do |select_relation, join_string|
|
72
|
+
select_relation.joins(join_string)
|
73
|
+
end
|
74
|
+
select_relation
|
75
|
+
.where("#{model_class.table_name}.#{model_class.primary_key} IN (?)", ids)
|
76
|
+
.select("#{relation.table_name}.*, #{instance_id_path} AS _instance_id")
|
77
|
+
end
|
78
|
+
|
79
|
+
def reflection_chain(reflection)
|
80
|
+
reflections = [reflection]
|
81
|
+
begin
|
82
|
+
previous = reflection
|
83
|
+
reflection = previous.source_reflection
|
84
|
+
if reflection && reflection != previous
|
85
|
+
reflections << reflection
|
86
|
+
else
|
87
|
+
reflection = nil
|
88
|
+
end
|
89
|
+
end while reflection
|
90
|
+
reflections.reverse
|
91
|
+
end
|
92
|
+
|
93
|
+
def reflection_join(orig_reflection, model_class)
|
94
|
+
reflection = orig_reflection.through_reflection? ? orig_reflection.through_reflection : orig_reflection
|
95
|
+
id_path = id_path_for(reflection, model_class)
|
96
|
+
table_name = reflection.active_record.table_name
|
97
|
+
id_column = reflection.belongs_to? ? reflection.foreign_key : reflection.active_record.primary_key
|
98
|
+
"INNER JOIN #{table_name} ON #{table_name}.#{id_column} = #{id_path}"
|
99
|
+
end
|
100
|
+
|
101
|
+
def id_path_for(reflection, model_class)
|
102
|
+
id_column = if reflection.belongs_to?
|
103
|
+
model_class.primary_key
|
104
|
+
else
|
105
|
+
reflection.foreign_key
|
106
|
+
end
|
107
|
+
"#{model_class.table_name}.#{id_column}"
|
108
|
+
end
|
52
109
|
end
|
53
110
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: batch-loader-active-record
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- mathieul
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2017-11-
|
11
|
+
date: 2017-11-23 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: batch-loader
|