ar_lazy_preload 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []