ar_lazy_preload 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 18eb45f9e1c299f719a7bdde76d9da3c9de0175919ad71556c4dfee750405d35
4
+ data.tar.gz: cec719881cff10f3687c8a22461d0b27acf9ab064104cbb5fb380eb6bd836701
5
+ SHA512:
6
+ metadata.gz: 71d13fa6015289abc893816b695e920fb59c42d96691d5cd86c3ce41c2c5e4bb34e5c6ea343094aabd39136e4e01ef1e88cd1f5e50bf97968f074844b61b3d87
7
+ data.tar.gz: 5e4e2f0f95b42796d7f165afb7009db76fbbcfa083e2552c2b7c90e7bc9442445d533cdfd498dc8a39b4734e8c0e50641ea8d77f48a429a85568fb0421e72c3c
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2018 DmitryTsepelev
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ [![Build Status](https://travis-ci.org/DmitryTsepelev/ar_lazy_preload.svg?branch=master)](https://travis-ci.org/DmitryTsepelev/ar_lazy_preload)
2
+ [![Maintainability](https://api.codeclimate.com/v1/badges/00d04595661820dfba80/maintainability)](https://codeclimate.com/github/DmitryTsepelev/ar_lazy_preload/maintainability)
3
+ [![Coverage Status](https://coveralls.io/repos/github/DmitryTsepelev/ar_lazy_preload/badge.svg?branch=master)](https://coveralls.io/github/DmitryTsepelev/ar_lazy_preload?branch=master)
4
+
5
+ # ArLazyPreload
6
+
7
+ Lazy loading associations for the ActiveRecord models. `#includes`, `#eager_load` and `#preload` are built-in methods to avoid N+1 problem, but sometimes when DB request is made we don't know what associations we are going to need later (for instance when your API allows client to define a list of loaded associations dynamically). The only possible solution for such cases is to load _all_ the associations we might need, but it can be a huge overhead.
8
+
9
+ This gem allows to set up _lazy_ preloading for associations - it won't load anything until association is called for a first time, but when it happens - it loads all the associated records for all records from the initial relation in a single query.
10
+
11
+ For example, if we define a following relation
12
+
13
+ ```ruby
14
+ users = User.lazy_preload(:posts).limit(10)
15
+ ```
16
+
17
+ and use it in a following way
18
+
19
+ ```ruby
20
+ users.map(&:first_name)
21
+ ```
22
+
23
+ there will be one query because we've never accessed posts:
24
+
25
+ ```sql
26
+ SELECT * FROM users LIMIT 10
27
+ ```
28
+
29
+ Hovever, when we try to load posts
30
+
31
+ ```ruby
32
+ users.map(&:posts)
33
+ ```
34
+
35
+ there will be one more request for posts:
36
+
37
+ ```sql
38
+ SELECT * FROM posts WHERE user_id in (...)
39
+ ```
40
+
41
+ ## Installation
42
+
43
+ Add this line to your application's Gemfile, and you're all set:
44
+
45
+ ```ruby
46
+ gem "ar_lazy_preload"
47
+ ```
48
+
49
+ ## License
50
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "rubocop/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: [:rubocop, :spec]
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ar_lazy_preload/ext/base"
4
+ require "ar_lazy_preload/ext/relation"
5
+ require "ar_lazy_preload/ext/association"
6
+ require "ar_lazy_preload/ext/merger"
7
+ require "ar_lazy_preload/ext/association_relation"
8
+
9
+ module ArLazyPreload
10
+ ActiveRecord::Base.include(ArLazyPreload::Base)
11
+
12
+ ActiveRecord::Relation.prepend(ArLazyPreload::Relation)
13
+ ActiveRecord::AssociationRelation.prepend(ArLazyPreload::AssociationRelation)
14
+ ActiveRecord::Relation::Merger.prepend(ArLazyPreload::Merger)
15
+
16
+ [
17
+ ActiveRecord::Associations::CollectionAssociation,
18
+ ActiveRecord::Associations::Association
19
+ ].each { |klass| klass.prepend(ArLazyPreload::Association) }
20
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ar_lazy_preload/association_tree_builder"
4
+
5
+ module ArLazyPreload
6
+ # This class is responsible for building context for associated records. Given a list of records
7
+ # belonging to the same context and association name it will create and attach a new context to
8
+ # the associated records based on the parent association tree.
9
+ class AssociatedContextBuilder
10
+ attr_reader :parent_context, :association_name
11
+
12
+ def initialize(parent_context:, association_name:)
13
+ @parent_context = parent_context
14
+ @association_name = association_name
15
+ end
16
+
17
+ delegate :records, :association_tree, :model, to: :parent_context
18
+
19
+ # Takes all the associated records for the records, attached to the :parent_context and creates
20
+ # a preloading context for them
21
+ def perform
22
+ return if child_association_tree.blank? || associated_records.blank?
23
+
24
+ Context.new(
25
+ model: reflection.klass,
26
+ records: associated_records,
27
+ association_tree: child_association_tree
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def child_association_tree
34
+ @child_association_tree ||= association_tree_builder.subtree_for(association_name)
35
+ end
36
+
37
+ def association_tree_builder
38
+ @association_tree_builder ||= AssociationTreeBuilder.new(association_tree)
39
+ end
40
+
41
+ def associated_records
42
+ @associated_records ||=
43
+ if reflection.collection?
44
+ record_associations.map(&:target).flatten
45
+ else
46
+ record_associations
47
+ end
48
+ end
49
+
50
+ def reflection
51
+ @reflection = model.reflect_on_association(association_name)
52
+ end
53
+
54
+ def record_associations
55
+ @record_associations ||= records.map { |record| record.send(association_name) }
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArLazyPreload
4
+ # This class is responsible for building association subtrees from a given association tree
5
+ # For instance, given a following tree `[:users, { users: :comments }]`,
6
+ # #subtree_for will build a subtree `[:comments]` when :users argument is passed
7
+ class AssociationTreeBuilder
8
+ attr_reader :association_tree
9
+
10
+ def initialize(association_tree)
11
+ @association_tree = association_tree.select { |node| node.is_a?(Hash) }
12
+ end
13
+
14
+ def subtree_for(association)
15
+ subtree_cache[association]
16
+ end
17
+
18
+ private
19
+
20
+ def subtree_cache
21
+ @subtree_cache ||= Hash.new do |hash, association|
22
+ hash[association] = association_tree.map { |node| node[association] }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ar_lazy_preload/associated_context_builder"
4
+
5
+ module ArLazyPreload
6
+ # This class is responsible for holding a connection between a list of ActiveRecord::Base objects
7
+ # which have been loaded by the same instance of ActiveRecord::Relation. It also contains a tree
8
+ # of associations, which were requested to be loaded lazily.
9
+ # Calling #preload_association method will cause loading of ALL associated objects for EACH
10
+ # ecord when requested association is found in the association tree.
11
+ class Context
12
+ attr_reader :model, :records, :association_tree
13
+
14
+ # :model - ActiveRecord class which records belong to
15
+ # :records - array of ActiveRecord instances
16
+ # :association_tree - list of symbols or hashes representing a tree of preloadable associations
17
+ def initialize(model:, records:, association_tree:)
18
+ @model = model
19
+ @records = records.compact
20
+ @association_tree = association_tree
21
+
22
+ @records.each { |record| record.lazy_preload_context = self }
23
+ end
24
+
25
+ # This method checks if the association is present in the association_tree and preloads for all
26
+ # objects in the context it if needed.
27
+ def try_preload_lazily(association_name)
28
+ return unless association_needs_preload?(association_name)
29
+
30
+ preloader.preload(records, association_name)
31
+
32
+ AssociatedContextBuilder.new(
33
+ parent_context: self,
34
+ association_name: association_name
35
+ ).perform
36
+ end
37
+
38
+ private
39
+
40
+ def association_needs_preload?(association_name)
41
+ association_tree.any? do |node|
42
+ if node.is_a?(Symbol)
43
+ node == association_name
44
+ elsif node.is_a?(Hash)
45
+ node.key?(association_name)
46
+ end
47
+ end
48
+ end
49
+
50
+ def preloader
51
+ @preloader ||= ActiveRecord::Associations::Preloader.new
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArLazyPreload
4
+ # ActiveRecord::Association patch with a hook for lazy preloading
5
+ module Association
6
+ def load_target
7
+ owner.try_preload_lazily(association_name)
8
+ super
9
+ end
10
+
11
+ private
12
+
13
+ def association_name
14
+ @association_name ||= reflection.name
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArLazyPreload
4
+ # ActiveRecord::AssociationRelation patch for setting up lazy_preload_values based on
5
+ # owner context
6
+ module AssociationRelation
7
+ def initialize(*args)
8
+ super(*args)
9
+
10
+ context = owner.lazy_preload_context
11
+ return if context.blank?
12
+
13
+ association_tree_builder = AssociationTreeBuilder.new(context.association_tree)
14
+ subtree = association_tree_builder.subtree_for(reflection.name)
15
+
16
+ lazy_preload!(subtree)
17
+ end
18
+
19
+ delegate :owner, :reflection, to: :proxy_association
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArLazyPreload
4
+ # ActiveRecord::Base patch with lazy preloading support
5
+ module Base
6
+ def self.included(base)
7
+ base.class.delegate :lazy_preload, to: :all
8
+ end
9
+
10
+ attr_accessor :lazy_preload_context
11
+
12
+ # When context has been set, this method would cause preloading association with a given name
13
+ def try_preload_lazily(association_name)
14
+ lazy_preload_context.try_preload_lazily(association_name) if lazy_preload_context.present?
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArLazyPreload
4
+ # ActiveRecord::Relation::Merger patch implementing merge functionality
5
+ # for lazy preloadable relations
6
+ module Merger
7
+ # Enhanced #merge implements merging lazy_preload_values
8
+ def merge
9
+ result = super
10
+
11
+ if other.lazy_preload_values
12
+ if other.klass == relation.klass
13
+ merge_lazy_preloads
14
+ else
15
+ reflect_and_merge_lazy_preloads
16
+ end
17
+ end
18
+
19
+ result
20
+ end
21
+
22
+ private
23
+
24
+ def merge_lazy_preloads
25
+ relation.lazy_preload!(*other.lazy_preload_values)
26
+ end
27
+
28
+ def reflect_and_merge_lazy_preloads
29
+ reflection = relation.klass.reflect_on_all_associations.find do |r|
30
+ r.class_name == other.klass.name
31
+ end
32
+ return unless reflection
33
+
34
+ relation.lazy_preload!(reflection.name => other.lazy_preload_values)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ar_lazy_preload/context"
4
+
5
+ module ArLazyPreload
6
+ # ActiveRecord::Relation patch with lazy preloading support
7
+ module Relation
8
+ # Enhanced #load method will check if association has not been loaded yet and add a context
9
+ # for lazy preloading to loaded each record
10
+ def load
11
+ need_context = !loaded?
12
+ old_load_result = super
13
+ setup_lazy_preload_context if need_context
14
+ old_load_result
15
+ end
16
+
17
+ # Specify relationships to be loaded lazily when association is loaded for the first time. For
18
+ # example:
19
+ #
20
+ # users = User.lazy_preload(:posts)
21
+ # users.each do |user|
22
+ # user.first_name
23
+ # end
24
+ #
25
+ # will cause only one SQL request to load users, while
26
+ #
27
+ # users = User.lazy_preload(:posts)
28
+ # users.each do |user|
29
+ # user.posts.map(&:id)
30
+ # end
31
+ #
32
+ # will make an additional query.
33
+ def lazy_preload(*args)
34
+ check_if_method_has_arguments!(:lazy_preload, args)
35
+ spawn.lazy_preload!(*args)
36
+ end
37
+
38
+ def lazy_preload!(*args)
39
+ args.reject!(&:blank?)
40
+ args.flatten!
41
+ self.lazy_preload_values += args
42
+ self
43
+ end
44
+
45
+ def lazy_preload_values
46
+ @lazy_preload_values ||= []
47
+ end
48
+
49
+ private
50
+
51
+ attr_writer :lazy_preload_values
52
+
53
+ def setup_lazy_preload_context
54
+ return if lazy_preload_values.blank? || @records.blank?
55
+
56
+ ArLazyPreload::Context.new(
57
+ model: model,
58
+ records: @records,
59
+ association_tree: lazy_preload_values
60
+ )
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ArLazyPreload
4
+ VERSION = "0.1.0"
5
+ end
metadata ADDED
@@ -0,0 +1,155 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ar_lazy_preload
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - DmitryTsepelev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-07-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec-rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: db-query-matchers
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: coveralls
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: database_cleaner
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: factory_bot
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: lazy_preload implementation for ActiveRecord models
112
+ email:
113
+ - dmitry.a.tsepelev@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - MIT-LICENSE
119
+ - README.md
120
+ - Rakefile
121
+ - lib/ar_lazy_preload.rb
122
+ - lib/ar_lazy_preload/associated_context_builder.rb
123
+ - lib/ar_lazy_preload/association_tree_builder.rb
124
+ - lib/ar_lazy_preload/context.rb
125
+ - lib/ar_lazy_preload/ext/association.rb
126
+ - lib/ar_lazy_preload/ext/association_relation.rb
127
+ - lib/ar_lazy_preload/ext/base.rb
128
+ - lib/ar_lazy_preload/ext/merger.rb
129
+ - lib/ar_lazy_preload/ext/relation.rb
130
+ - lib/ar_lazy_preload/version.rb
131
+ homepage: https://github.com/DmitryTsepelev/ar_lazy_preload
132
+ licenses:
133
+ - MIT
134
+ metadata: {}
135
+ post_install_message:
136
+ rdoc_options: []
137
+ require_paths:
138
+ - lib
139
+ required_ruby_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '2.3'
144
+ required_rubygems_version: !ruby/object:Gem::Requirement
145
+ requirements:
146
+ - - ">="
147
+ - !ruby/object:Gem::Version
148
+ version: '0'
149
+ requirements: []
150
+ rubyforge_project:
151
+ rubygems_version: 2.7.6
152
+ signing_key:
153
+ specification_version: 4
154
+ summary: lazy_preload implementation for ActiveRecord models
155
+ test_files: []