batch-loader-active-record 0.3.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 21895ec7eb240edbb662157510b7c1f2e0e09ece
4
- data.tar.gz: 73a9d404f37d37432b74dc34156614f591cfd4c5
3
+ metadata.gz: 20d915e2884c4153776b3acac5c148360e66209b
4
+ data.tar.gz: 7580228d4074580c688d5561ad4a3c69e599738e
5
5
  SHA512:
6
- metadata.gz: 5917a41861fd8b5bced3f6b22f5d981028615fb7e6a4a0f674fde403261d6d29da4dbb8d2327a873cae3eb750f5ffee034d819507ca004e67b44d3d4f2b9ebeb
7
- data.tar.gz: d03ff1d186f23a10d3f65766ab8ac57555e91b0bd2e7647751e3d3f0ece000c221a5313050abc2a9633f79a460056e4b2564e023053bd5fb9ce824a3fc85404b
6
+ metadata.gz: 923e068adecf32ee7df60441628672ec324898ba36d5c29f866042d8ec4e6af692b19a0fa9b8c483dfdeee04066d7cf455f22c279023dd10adc997a8fa867e40
7
+ data.tar.gz: f505bc5a8cfff63725a04b756f8f022c6bc95543ae734a6c8fe49e86169fb6ec18655122954cef56b5724b4ae5fe8a3079bbebc13e10bc5732bcd6cfc2fec8d7
@@ -2,7 +2,11 @@
2
2
 
3
3
  Unreleased
4
4
 
5
- * nothing yet
5
+ * none
6
+
7
+ v0.3.1
8
+
9
+ * allow to decouple declaring the assocation with Active Record DSL and generate a lazy association accessor with `association_accessor`
6
10
 
7
11
  v0.3.0
8
12
 
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- batch-loader-active-record (0.3.0)
4
+ batch-loader-active-record (0.3.1)
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
@@ -12,19 +12,25 @@ It is not intended to be used for all associations though, but only where necess
12
12
 
13
13
  ## Description
14
14
 
15
- This is a very simple gem which is basically a mixin containg replacement macros for the 3 active record association macros (refer to the [CHANGELOG](https://github.com/mathieul/batch-loader-active-record/blob/master/CHANGELOG.md) to know what is supported and what is not):
15
+ This gem has a very simple implementation and delegates all batch loading responsibilities (used to avoid N+1 calls to the database) to the [batch-loader gem](https://github.com/exAspArk/batch-loader). It allows to generate a lazy association accessor with a simple statement: `association_accessor :association_name`.
16
+
17
+ Note that it doesn't yet support all cases handled by Active Record associations, refer to the [CHANGELOG](https://github.com/mathieul/batch-loader-active-record/blob/master/CHANGELOG.md) to know what is supported and what is not.
18
+
19
+ It is also possible to use one of the macros below in replacement of the original Active Record macro to both declare the association and trigger a lazy association accessort in a single statement.
16
20
 
17
21
  * `belongs_to_lazy`
18
22
  * `has_one_lazy`
19
23
  * `has_many_lazy`
20
24
 
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).
25
+ As soon as your lazy association accessor needs to do more than fetch all records of an association (using a scope or not), you're going to want to directly use the batch-loader gem. For more details on N+1 queries read the [batch-loader gem README](https://github.com/exAspArk/batch-loader/#why).
22
26
 
23
27
  For example let's imagine a post which can have many comments:
24
28
 
25
29
  ```ruby
26
30
  class Post < ActiveRecord::Base
27
- has_many_lazy :comments
31
+ include BatchLoaderActiveRecord
32
+ has_many :comments
33
+ association_accessor :comments
28
34
  end
29
35
 
30
36
  class Comment < ActiveRecord::Base
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "batch_loader_active_record/version"
4
3
  require "batch-loader"
4
+ require "batch_loader_active_record/version"
5
+ require "batch_loader_active_record/association_manager"
5
6
 
6
7
  module BatchLoaderActiveRecord
7
8
  def self.included(base)
@@ -11,130 +12,42 @@ module BatchLoaderActiveRecord
11
12
  module ClassMethods
12
13
  def belongs_to_lazy(*args)
13
14
  belongs_to(*args).tap do |reflections|
14
- reflect = reflections.values.last
15
- assert_not_polymorphic(reflect)
16
- batch_key = [table_name, reflect.name]
17
- define_method(:"#{reflect.name}_lazy") do
18
- assoc_scope = if reflect.scope.nil?
19
- reflect.klass
20
- else
21
- reflect.klass.instance_eval(&reflect.scope)
22
- end
23
- foreign_key_value = send(reflect.foreign_key) or return nil
24
- BatchLoader.for(foreign_key_value).batch(key: batch_key) do |foreign_key_values, loader|
25
- assoc_scope.where(id: foreign_key_values).each { |instance| loader.call(instance.id, instance) }
26
- end
15
+ manager = AssociationManager.new(model: self, reflection: reflections.values.last)
16
+ define_method(manager.accessor_name) { manager.belongs_to_batch_loader(self) }
17
+ end
18
+ end
19
+
20
+ def association_accessor(name)
21
+ reflection = reflect_on_association(name) or raise "Can't find association #{name.inspect}"
22
+ manager = AssociationManager.new(model: self, reflection: reflection)
23
+ case reflection.macro
24
+ when :belongs_to
25
+ define_method(manager.accessor_name) { manager.belongs_to_batch_loader(self) }
26
+ when :has_one
27
+ define_method(manager.accessor_name) { manager.has_one_to_batch_loader(self) }
28
+ when :has_many
29
+ define_method(manager.accessor_name) do |instance_scope = nil|
30
+ manager.has_many_to_batch_loader(self, instance_scope)
27
31
  end
32
+ else
33
+ raise NotImplementedError, "association kind #{reflection.macro.inspect} is not yet supported"
28
34
  end
29
35
  end
30
36
 
31
37
  def has_one_lazy(*args)
32
38
  has_one(*args).tap do |reflections|
33
- reflect = reflections.values.last
34
- assert_not_polymorphic(reflect)
35
- batch_key = [table_name, reflect.name]
36
- define_method(:"#{reflect.name}_lazy") do
37
- assoc_scope = if reflect.scope.nil?
38
- reflect.klass
39
- else
40
- reflect.klass.instance_eval(&reflect.scope)
41
- end
42
- BatchLoader.for(id).batch(key: batch_key) do |model_ids, loader|
43
- assoc_scope.where(reflect.foreign_key => model_ids).each do |instance|
44
- loader.call(instance.public_send(reflect.foreign_key), instance)
45
- end
46
- end
47
- end
39
+ manager = AssociationManager.new(model: self, reflection: reflections.values.last)
40
+ define_method(manager.accessor_name) { manager.has_one_to_batch_loader(self) }
48
41
  end
49
42
  end
50
43
 
51
44
  def has_many_lazy(*args)
52
45
  has_many(*args).tap do |reflections|
53
- reflect = reflections.values.last
54
- assert_not_polymorphic(reflect)
55
- base_key = [table_name, reflect.name]
56
- define_method(:"#{reflect.name}_lazy") do |instance_scope = nil|
57
- batch_key = base_key
58
- batch_key += [instance_scope.to_sql.hash] unless instance_scope.nil?
59
- assoc_scope = if reflect.scope.nil?
60
- reflect.klass
61
- else
62
- reflect.klass.instance_eval(&reflect.scope)
63
- end
64
- BatchLoader.for(id).batch(default_value: [], key: batch_key) do |model_ids, loader|
65
- relation = if instance_scope.nil?
66
- assoc_scope
67
- else
68
- assoc_scope.instance_eval { instance_scope }
69
- end
70
- if reflect.through_reflection?
71
- instances = self.class.fetch_for_model_ids(model_ids, relation: relation, reflection: reflect)
72
- instances.each do |instance|
73
- loader.call(instance.public_send(:_instance_id)) { |value| value << instance }
74
- end
75
- else
76
- relation.where(reflect.foreign_key => model_ids).each do |instance|
77
- loader.call(instance.public_send(reflect.foreign_key)) { |value| value << instance }
78
- end
79
- end
80
- end
81
- end
82
- end
83
- end
84
-
85
- def fetch_for_model_ids(ids, relation:, reflection:)
86
- instance_id_path = "#{reflection.active_record.table_name}.#{reflection.active_record.primary_key}"
87
- model_class = reflection.active_record
88
- reflections = reflection_chain(reflection)
89
- join_strings = [reflection_join(reflections.first, relation)]
90
- reflections.each_cons(2) do |previous, current|
91
- join_strings << reflection_join(current, previous.active_record)
92
- end
93
- select_relation = join_strings.reduce(relation) do |select_relation, join_string|
94
- select_relation.joins(join_string)
95
- end
96
- select_relation
97
- .where("#{model_class.table_name}.#{model_class.primary_key} IN (?)", ids)
98
- .select("#{relation.table_name}.*, #{instance_id_path} AS _instance_id")
99
- end
100
-
101
- private
102
-
103
- def assert_not_polymorphic(reflection)
104
- if reflection.polymorphic? || reflection.options.has_key?(:as) || reflection.options.has_key?(:source_type)
105
- raise NotImplementedError, "polymorphic associations are not yet supported (#{reflection.name})"
106
- end
107
- end
108
-
109
- def reflection_chain(reflection)
110
- reflections = [reflection]
111
- begin
112
- previous = reflection
113
- reflection = previous.source_reflection
114
- if reflection && reflection != previous
115
- reflections << reflection
116
- else
117
- reflection = nil
46
+ manager = AssociationManager.new(model: self, reflection: reflections.values.last)
47
+ define_method(manager.accessor_name) do |instance_scope = nil|
48
+ manager.has_many_to_batch_loader(self, instance_scope)
118
49
  end
119
- end while reflection
120
- reflections.reverse
121
- end
122
-
123
- def reflection_join(orig_reflection, model_class)
124
- reflection = orig_reflection.through_reflection? ? orig_reflection.through_reflection : orig_reflection
125
- id_path = id_path_for(reflection, model_class)
126
- table_name = reflection.active_record.table_name
127
- id_column = reflection.belongs_to? ? reflection.foreign_key : reflection.active_record.primary_key
128
- "INNER JOIN #{table_name} ON #{table_name}.#{id_column} = #{id_path}"
129
- end
130
-
131
- def id_path_for(reflection, model_class)
132
- id_column = if reflection.belongs_to?
133
- model_class.primary_key
134
- else
135
- reflection.foreign_key
136
50
  end
137
- "#{model_class.table_name}.#{id_column}"
138
51
  end
139
52
  end
140
53
  end
@@ -0,0 +1,119 @@
1
+ module BatchLoaderActiveRecord
2
+ class AssociationManager
3
+ attr_reader :model, :reflection
4
+
5
+ def initialize(model:, reflection:)
6
+ @model = model
7
+ @reflection = reflection
8
+ assert_not_polymorphic
9
+ end
10
+
11
+ def accessor_name
12
+ :"#{reflection.name}_lazy"
13
+ end
14
+
15
+ def belongs_to_batch_loader(instance)
16
+ foreign_key_value = instance.send(reflection.foreign_key) or return nil
17
+ BatchLoader.for(foreign_key_value).batch(key: batch_key) do |foreign_key_values, loader|
18
+ target_scope.where(id: foreign_key_values).each { |instance| loader.call(instance.id, instance) }
19
+ end
20
+ end
21
+
22
+ def has_one_to_batch_loader(instance)
23
+ BatchLoader.for(instance.id).batch(key: batch_key) do |model_ids, loader|
24
+ target_scope.where(reflection.foreign_key => model_ids).each do |instance|
25
+ loader.call(instance.public_send(reflection.foreign_key), instance)
26
+ end
27
+ end
28
+ end
29
+
30
+ def has_many_to_batch_loader(instance, instance_scope)
31
+ custom_key = batch_key
32
+ custom_key += [instance_scope.to_sql.hash] unless instance_scope.nil?
33
+ BatchLoader.for(instance.id).batch(default_value: [], key: custom_key) do |model_ids, loader|
34
+ relation = if instance_scope.nil?
35
+ target_scope
36
+ else
37
+ target_scope.instance_eval { instance_scope }
38
+ end
39
+ if reflection.through_reflection?
40
+ instances = fetch_for_model_ids(model_ids, relation: relation)
41
+ instances.each do |instance|
42
+ loader.call(instance.public_send(:_instance_id)) { |value| value << instance }
43
+ end
44
+ else
45
+ relation.where(reflection.foreign_key => model_ids).each do |instance|
46
+ loader.call(instance.public_send(reflection.foreign_key)) { |value| value << instance }
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def target_scope
55
+ @target_scope ||= if reflection.scope.nil?
56
+ reflection.klass
57
+ else
58
+ reflection.klass.instance_eval(&reflection.scope)
59
+ end
60
+ end
61
+
62
+ def batch_key
63
+ @batch_key ||= [model.table_name, reflection.name].freeze
64
+ end
65
+
66
+ def assert_not_polymorphic
67
+ if reflection.polymorphic? || reflection.options.has_key?(:as) || reflection.options.has_key?(:source_type)
68
+ raise NotImplementedError, "polymorphic associations are not yet supported (#{reflection.name})"
69
+ end
70
+ end
71
+
72
+ def fetch_for_model_ids(ids, relation:)
73
+ instance_id_path = "#{reflection.active_record.table_name}.#{reflection.active_record.primary_key}"
74
+ model_class = reflection.active_record
75
+ reflections = reflection_chain(reflection)
76
+ join_strings = [reflection_join(reflections.first, relation)]
77
+ reflections.each_cons(2) do |previous, current|
78
+ join_strings << reflection_join(current, previous.active_record)
79
+ end
80
+ select_relation = join_strings.reduce(relation) do |select_relation, join_string|
81
+ select_relation.joins(join_string)
82
+ end
83
+ select_relation
84
+ .where("#{model_class.table_name}.#{model_class.primary_key} IN (?)", ids)
85
+ .select("#{relation.table_name}.*, #{instance_id_path} AS _instance_id")
86
+ end
87
+
88
+ def reflection_chain(reflection)
89
+ reflections = [reflection]
90
+ begin
91
+ previous = reflection
92
+ reflection = previous.source_reflection
93
+ if reflection && reflection != previous
94
+ reflections << reflection
95
+ else
96
+ reflection = nil
97
+ end
98
+ end while reflection
99
+ reflections.reverse
100
+ end
101
+
102
+ def reflection_join(orig_reflection, model_class)
103
+ reflection = orig_reflection.through_reflection? ? orig_reflection.through_reflection : orig_reflection
104
+ id_path = id_path_for(reflection, model_class)
105
+ table_name = reflection.active_record.table_name
106
+ id_column = reflection.belongs_to? ? reflection.foreign_key : reflection.active_record.primary_key
107
+ "INNER JOIN #{table_name} ON #{table_name}.#{id_column} = #{id_path}"
108
+ end
109
+
110
+ def id_path_for(reflection, model_class)
111
+ id_column = if reflection.belongs_to?
112
+ model_class.primary_key
113
+ else
114
+ reflection.foreign_key
115
+ end
116
+ "#{model_class.table_name}.#{id_column}"
117
+ end
118
+ end
119
+ end
@@ -1,3 +1,3 @@
1
1
  module BatchLoaderActiveRecord
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
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.3.0
4
+ version: 0.3.1
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-24 00:00:00.000000000 Z
11
+ date: 2017-11-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: batch-loader
@@ -157,6 +157,7 @@ files:
157
157
  - bin/setup
158
158
  - lib/batch-loader-active-record.rb
159
159
  - lib/batch_loader_active_record.rb
160
+ - lib/batch_loader_active_record/association_manager.rb
160
161
  - lib/batch_loader_active_record/version.rb
161
162
  homepage: https://github.com/mathieul/batch-loader-active-record
162
163
  licenses: