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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 7fc87d482f6405949526e737f312943dd267ed43
4
- data.tar.gz: 4364f28a2e7899eff06c363ca02e0eef1c3d34db
3
+ metadata.gz: c7c24c7d661f0d2449d676892c1d328f8c5147e3
4
+ data.tar.gz: 83d9fa13f7dcab069ed2b1d1f11fc4e1f36834f9
5
5
  SHA512:
6
- metadata.gz: b5303b39f9f88e0b95128b0d6a506944eb5f9bba3683801df644bfde40bc197e47a6f709b470a228ac4f9819cb7601dd3563cde2183afdbfd7402024fec870f2
7
- data.tar.gz: db6035acfdb9d1fd2beff9a9aa9e616bd5107eeeac514b12588ebbaa6480b93215e9ed51260995c1fe685d2fffe8cdf9082fd89fe3eff5109c70096e856845e9
6
+ metadata.gz: 0677e7a74dfe10a238bd431d171d44bbff07b3c8a6f775bfbe9bf595f345ea0fce7c35a494eceb7702e5abb56ffcada2ea426c23097286a77bfb200a0022ebb3
7
+ data.tar.gz: 0e0e3edd2866a7c578567f5171e23fae3356b8469ada0123781d5d7b0276c655af33149baa1da985e8c4d198b8ddf3ad48f961b38609b03ed959ea7dfaeff58b
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- batch-loader-active-record (0.1.0)
4
+ batch-loader-active-record (0.2.0)
5
5
  activerecord (>= 4.2.0, < 5.2.0)
6
6
  activesupport (>= 4.2.0, < 5.2.0)
7
7
  batch-loader (~> 1.2.0)
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
- This is a very simple gem which just contains a mixin to include and give access to class methods for each association kind:
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
- * `belongs_to_lazy`
28
- * `has_one_lazy`
29
- * `has_many_lazy`
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
- assoc = reflections.values.last
15
- batch_key = [table_name, assoc.name]
16
- define_method(:"#{assoc.name}_lazy") do
17
- foreign_key_value = send(assoc.foreign_key) or return nil
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
- assoc.klass.where(id: foreign_key_values).each { |instance| loader.call(instance.id, instance) }
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
- assoc = reflections.values.last
28
- batch_key = [table_name, assoc.name]
29
- define_method(:"#{assoc.name}_lazy") do
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
- assoc.klass.where(assoc.foreign_key => model_ids).each do |instance|
32
- loader.call(instance.public_send(assoc.foreign_key), instance)
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
- assoc = reflections.values.last
42
- batch_key = [table_name, assoc.name]
43
- define_method(:"#{assoc.name}_lazy") do
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
- assoc.klass.where(assoc.foreign_key => model_ids).each do |instance|
46
- loader.call(instance.public_send(assoc.foreign_key)) { |value| value << instance }
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
@@ -1,3 +1,3 @@
1
1
  module BatchLoaderActiveRecord
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  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.1.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-19 00:00:00.000000000 Z
11
+ date: 2017-11-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: batch-loader