predictive_load 0.1.1

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
+ SHA1:
3
+ metadata.gz: c06e8dd628fc0332e30ded32af6042aa499143be
4
+ data.tar.gz: 0cef50d150b563becfd37449cb32f09425f6297b
5
+ SHA512:
6
+ metadata.gz: 8b84b9da822ee49efb981cfb1ecbab8456d48c7c495b4de0ba7689a5611205584b722097b33054eb8e2e6515044519a3c78188138379862d2c1a44b840a0a7fc
7
+ data.tar.gz: 5188551f856d9827928b14a72bb667fab7b78e2868ac16af44c4c8695c2a2f890217dc56d3d8d5a8e6c72c1c67c55f90dd279308e0583287d8936c73e702e90f
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ test/db
2
+ Gemfile.lock
data/.travis.yml ADDED
@@ -0,0 +1,4 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+ - 2.0.0
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+ gem "activerecord", "3.2"
5
+ gem "minitest"
6
+ gem 'sqlite3'
7
+ gem 'rake'
data/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "{}"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright {yyyy} {name of copyright owner}
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
202
+
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ [![Build Status](https://travis-ci.org/eac/resque-durable.png)](https://travis-ci.org/eac/predictive_load)
2
+
3
+ predictive_load
4
+ ===============
5
+
6
+ Observes Active Record collections and notifies when a member loads an association. This allows for:
7
+ * automatically preloading the association in a single query for all members of that collection.
8
+ * N+1 detection logging
9
+
10
+
11
+
12
+ ### Automatic preloading
13
+
14
+
15
+ ```ruby
16
+ ActiveRecord::Relation.collection_observer = PredictiveLoad::Loader
17
+
18
+ Ticket.all.each do |ticket|
19
+ ticket.requester.identities.each { |identity| identity.account }
20
+ end
21
+ ```
22
+
23
+ Produces:
24
+ ```sql
25
+ SELECT `tickets`.* FROM `tickets`
26
+ SELECT `requesters`.* FROM `requesters` WHERE `requesters`.`id` IN (2, 7, 12, 32, 37)
27
+ SELECT `identities`.* FROM `identities` WHERE `identities`.`requester_id` IN (2, 7, 12, 32, 37)
28
+ SELECT `accounts`.* FROM `accounts` WHERE `accounts`.`id` IN (1, 2, 3)
29
+ ```
30
+
31
+ ### N+1 detection logging
32
+
33
+ There is also a log-only version:
34
+ ```ruby
35
+ ActiveRecord::Relation.collection_observer = PredictiveLoad::Watcher
36
+
37
+ Comment.all.each do |comment|
38
+ comment.account
39
+ end
40
+
41
+ ```
42
+
43
+ Produces:
44
+
45
+ ```
46
+ detected n1 call on Comment#account
47
+ expect to prevent 10 queries
48
+ would preload with: SELECT `accounts`.* FROM `accounts` WHERE `accounts`.`id` IN (...)
49
+ +----+-------------+----------+-------+---------------+---------+---------+-------+------+-------+
50
+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
51
+ +----+-------------+----------+-------+---------------+---------+---------+-------+------+-------+
52
+ | 1 | SIMPLE | accounts | const | PRIMARY | PRIMARY | 4 | const | 10 | |
53
+ +----+-------------+----------+-------+---------------+---------+---------+-------+------+-------+
54
+ 1 row in set (0.00 sec)
55
+ would have prevented all 10 queries
56
+
57
+ ```
58
+
59
+ #### Known limitations:
60
+
61
+ * Calling association#size will trigger an N+1 on SELECT COUNT(*). Work around by calling #length, loading all records.
62
+ * Calling first / last will trigger an N+1.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require './test/helper'
2
+ require 'rake'
3
+ require 'rake/testtask'
4
+
5
+ Rake::TestTask.new(:test) do |test|
6
+ test.libs << 'test'
7
+ test.pattern = 'test/*_test.rb'
8
+ test.verbose = true
9
+ end
10
+
11
+ task :default => :test
@@ -0,0 +1,71 @@
1
+ module PredictiveLoad::ActiveRecordCollectionObservation
2
+
3
+ def self.included(base)
4
+ ActiveRecord::Relation.send(:include, RelationObservation)
5
+ ActiveRecord::Base.send(:include, CollectionMember)
6
+ ActiveRecord::Associations::Association.send(:include, AssociationNotification)
7
+ ActiveRecord::Associations::CollectionAssociation.send(:include, CollectionAssociationNotification)
8
+ end
9
+
10
+ module RelationObservation
11
+
12
+ def self.included(base)
13
+ base.class_attribute :collection_observer
14
+ base.alias_method_chain :to_a, :collection_observer
15
+ end
16
+
17
+ def to_a_with_collection_observer
18
+ records = to_a_without_collection_observer
19
+
20
+ if records.size > 1 && collection_observer
21
+ collection_observer.observe(records.dup)
22
+ end
23
+
24
+ records
25
+ end
26
+
27
+ end
28
+
29
+ module CollectionMember
30
+
31
+ attr_accessor :collection_observer
32
+
33
+ end
34
+
35
+ module AssociationNotification
36
+
37
+ def self.included(base)
38
+ base.alias_method_chain :load_target, :notification
39
+ end
40
+
41
+ def load_target_with_notification
42
+ notify_collection_observer if find_target?
43
+
44
+ load_target_without_notification
45
+ end
46
+
47
+ protected
48
+
49
+ def notify_collection_observer
50
+ if @owner.collection_observer
51
+ @owner.collection_observer.loading_association(@owner, @reflection.name)
52
+ end
53
+ end
54
+
55
+ end
56
+
57
+ module CollectionAssociationNotification
58
+
59
+ def self.included(base)
60
+ base.alias_method_chain :load_target, :notification
61
+ end
62
+
63
+ def load_target_with_notification
64
+ notify_collection_observer if find_target?
65
+
66
+ load_target_without_notification
67
+ end
68
+
69
+ end
70
+
71
+ end
@@ -0,0 +1,61 @@
1
+ module PredictiveLoad
2
+ # Predictive loader
3
+ #
4
+ # Usage:
5
+ # ActiveRecord::Relation.collection_observer = LazyLoader
6
+ #
7
+ class Loader
8
+
9
+ def self.observe(records)
10
+ new(records).observe
11
+ end
12
+
13
+ def initialize(records)
14
+ @records = records
15
+ end
16
+
17
+ def observe
18
+ records.each do |record|
19
+ record.collection_observer = self
20
+ end
21
+ end
22
+
23
+ def loading_association(record, association_name)
24
+ if all_records_will_likely_load_association?(association_name)
25
+ preload(association_name)
26
+ end
27
+ end
28
+
29
+ def all_records_will_likely_load_association?(association_name)
30
+ if defined?(Mocha) && association_name.to_s.index('_stub_')
31
+ false
32
+ else
33
+ true
34
+ end
35
+ end
36
+
37
+ protected
38
+
39
+ attr_reader :records
40
+
41
+ def preload(association_name)
42
+ ActiveRecord::Associations::Preloader.new(records_with_association(association_name), [ association_name ]).run
43
+ end
44
+
45
+ def records_with_association(association_name)
46
+ if mixed_collection?
47
+ @records.select { |r| r.class.reflect_on_association(association_name) }
48
+ else
49
+ @records
50
+ end
51
+ end
52
+
53
+ def mixed_collection?
54
+ @mixed_collection ||= begin
55
+ klass = records.first.class
56
+ records.any? { |record| record.class != klass }
57
+ end
58
+ end
59
+
60
+ end
61
+ end
@@ -0,0 +1,40 @@
1
+ require 'active_record/associations/preloader'
2
+
3
+ module PredictiveLoad
4
+ class PreloadLog < ActiveRecord::Associations::Preloader
5
+
6
+ attr_accessor :logger
7
+
8
+ def preload(association)
9
+ grouped_records(association).each do |reflection, klasses|
10
+ klasses.each do |klass, records|
11
+ preloader = preloader_for(reflection).new(klass, records, reflection, options)
12
+
13
+ if preloader.respond_to?(:through_reflection)
14
+ log("encountered :through association for #{association}. Requires loading records to generate query, so skipping for now.")
15
+ next
16
+ end
17
+
18
+ preload_sql = preloader.scoped.where(collection_arel(preloader)).to_sql
19
+
20
+ log("would preload with: #{preload_sql.to_s}")
21
+ klass.connection.explain(preload_sql).each_line do |line|
22
+ log(line)
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ def collection_arel(preloader)
29
+ owners_map = preloader.owners_by_key
30
+ owner_keys = owners_map.keys.compact
31
+ preloader.association_key.in(owner_keys)
32
+ end
33
+
34
+ def log(message)
35
+ ActiveRecord::Base.logger.info("predictive_load: #{message}")
36
+ end
37
+
38
+ end
39
+
40
+ end
@@ -0,0 +1,80 @@
1
+ require 'predictive_load/loader'
2
+ require 'predictive_load/preload_log'
3
+
4
+ module PredictiveLoad
5
+ # Provides N+1 detection / log mode.
6
+ #
7
+ # Usage:
8
+ # ActiveRecord::Relation.collection_observer = PredictiveLoad::Watcher
9
+ #
10
+ # Example output:
11
+ # predictive_load: detected n1 call on Comment#account
12
+ # predictive_load: expect to prevent 1 queries
13
+ # predictive_load: would preload with: SELECT `accounts`.* FROM `accounts` WHERE `accounts`.`id` IN (...)
14
+ # predictive_load: +----+-------------+----------+-------+---------------+---------+---------+-------+------+-------+
15
+ # predictive_load: | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
16
+ # predictive_load: +----+-------------+----------+-------+---------------+---------+---------+-------+------+-------+
17
+ # predictive_load: | 1 | SIMPLE | accounts | const | PRIMARY | PRIMARY | 4 | const | 1 | |
18
+ # predictive_load: +----+-------------+----------+-------+---------------+---------+---------+-------+------+-------+
19
+ # predictive_load: 1 row in set (0.00 sec)
20
+ # predictive_load: would have prevented all 1 queries
21
+ class Watcher < Loader
22
+
23
+ attr_reader :loaded_associations
24
+
25
+ def initialize(records)
26
+ super
27
+ @loaded_associations = {}
28
+ end
29
+
30
+ def loading_association(record, association_name)
31
+ return if !all_records_will_likely_load_association?(association_name)
32
+
33
+ if loaded_associations.key?(association_name)
34
+ log_query_plan(association_name)
35
+ end
36
+
37
+ increment_query_count(association_name)
38
+ end
39
+
40
+ protected
41
+
42
+ def log_query_plan(association_name)
43
+ log("detected n1 call on #{records.first.class.name}##{association_name}")
44
+
45
+ # Detailed logging for first query
46
+ if query_count(association_name) == 1
47
+ log("expect to prevent #{expected_query_count} queries")
48
+ log_preload(association_name)
49
+ end
50
+
51
+ # All records loaded association
52
+ if query_count(association_name) == expected_query_count
53
+ log("would have prevented all #{expected_query_count} queries")
54
+ end
55
+
56
+ end
57
+
58
+ def query_count(association_name)
59
+ loaded_associations[association_name] || 0
60
+ end
61
+
62
+ def increment_query_count(association_name)
63
+ loaded_associations[association_name] ||= 0
64
+ loaded_associations[association_name] += 1
65
+ end
66
+
67
+ def expected_query_count
68
+ records.size - 1
69
+ end
70
+
71
+ def log_preload(association_name)
72
+ PreloadLog.new(records_with_association(association_name), [ association_name ]).run
73
+ end
74
+
75
+ def log(message)
76
+ ActiveRecord::Base.logger.info("predictive_load: #{message}")
77
+ end
78
+
79
+ end
80
+ end
@@ -0,0 +1,2 @@
1
+ module PredictiveLoad
2
+ end
@@ -0,0 +1,16 @@
1
+ # encoding: utf-8
2
+
3
+ Gem::Specification.new do |gem|
4
+ gem.authors = ["Eric Chapweske"]
5
+ gem.email = ["eac@zendesk.com"]
6
+ gem.description = "Predictive loader"
7
+ gem.summary = %q{}
8
+ gem.homepage = ""
9
+ gem.license = "Apache License Version 2.0"
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
14
+ gem.name = "predictive_load"
15
+ gem.version = '0.1.1'
16
+ end
@@ -0,0 +1,44 @@
1
+ require_relative 'helper'
2
+ require 'predictive_load/watcher'
3
+
4
+ describe PredictiveLoad::ActiveRecordCollectionObservation do
5
+
6
+ describe "Relation#to_a" do
7
+ before do
8
+ user1 = User.create!(:name => "Rudolph")
9
+ user2 = User.create!(:name => "Santa")
10
+ end
11
+
12
+ after do
13
+ User.delete_all
14
+ end
15
+
16
+ describe "when a collection observer is specified" do
17
+ before do
18
+ ActiveRecord::Relation.collection_observer = PredictiveLoad::Watcher
19
+ end
20
+
21
+ it "observes the members of that collection" do
22
+ users = User.all
23
+ assert_equal 2, users.size
24
+ assert users.all? { |user| user.collection_observer }
25
+ end
26
+
27
+ end
28
+
29
+ describe "when a collection observer is not specified" do
30
+ before do
31
+ ActiveRecord::Relation.collection_observer = nil
32
+ end
33
+
34
+ it "does not observe the members of that collection" do
35
+ users = User.all
36
+ assert_equal 2, users.size, users.inspect
37
+ assert users.none? { |user| user.collection_observer }
38
+ end
39
+
40
+ end
41
+
42
+ end
43
+
44
+ end
data/test/database.yml ADDED
@@ -0,0 +1,3 @@
1
+ test:
2
+ adapter: sqlite3
3
+ database: test/db
data/test/helper.rb ADDED
@@ -0,0 +1,57 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ require 'minitest'
4
+ require 'minitest/spec'
5
+ require 'minitest/autorun'
6
+ require 'active_record'
7
+ require 'predictive_load'
8
+ require 'predictive_load/active_record_collection_observation'
9
+
10
+ ActiveRecord::Base.class_eval do
11
+ include PredictiveLoad::ActiveRecordCollectionObservation
12
+ end
13
+
14
+ database_config = YAML.load_file(File.join(File.dirname(__FILE__), 'database.yml'))
15
+ ActiveRecord::Base.establish_connection(database_config['test'])
16
+ ActiveRecord::Base.default_timezone = :utc
17
+ require_relative 'schema'
18
+ require_relative 'models'
19
+
20
+ def assert_queries(num = 1)
21
+ ActiveRecord::SQLCounter.log = []
22
+ yield
23
+ ensure
24
+ assert_equal num, ActiveRecord::SQLCounter.log.size, "#{ActiveRecord::SQLCounter.log.size} instead of #{num} queries were executed.#{ActiveRecord::SQLCounter.log.size == 0 ? '' : "\nQueries:\n#{ActiveRecord::SQLCounter.log.join("\n")}"}"
25
+ end
26
+
27
+ # Yanked from ActiveRecord tests
28
+ module ActiveRecord
29
+ class SQLCounter
30
+ cattr_accessor :ignored_sql
31
+ self.ignored_sql = [/^PRAGMA (?!(table_info))/, /^SELECT currval/, /^SELECT CAST/, /^SELECT @@IDENTITY/, /^SELECT @@ROWCOUNT/, /^SAVEPOINT/, /^ROLLBACK TO SAVEPOINT/, /^RELEASE SAVEPOINT/, /^SHOW max_identifier_length/, /^BEGIN/, /^COMMIT/]
32
+
33
+ # FIXME: this needs to be refactored so specific database can add their own
34
+ # ignored SQL. This ignored SQL is for Oracle.
35
+ ignored_sql.concat [/^select .*nextval/i, /^SAVEPOINT/, /^ROLLBACK TO/, /^\s*select .* from all_triggers/im]
36
+
37
+ cattr_accessor :log
38
+ self.log = []
39
+
40
+ attr_reader :ignore
41
+
42
+ def initialize(ignore = self.class.ignored_sql)
43
+ @ignore = ignore
44
+ end
45
+
46
+ def call(name, start, finish, message_id, values)
47
+ sql = values[:sql]
48
+
49
+ # FIXME: this seems bad. we should probably have a better way to indicate
50
+ # the query was cached
51
+ return if 'CACHE' == values[:name] || ignore.any? { |x| x =~ sql }
52
+ self.class.log << sql
53
+ end
54
+ end
55
+
56
+ ActiveSupport::Notifications.subscribe('sql.active_record', SQLCounter.new)
57
+ end
@@ -0,0 +1,147 @@
1
+ require_relative 'helper'
2
+ require 'predictive_load/loader'
3
+
4
+ describe PredictiveLoad::Loader do
5
+
6
+ describe "A collection of records" do
7
+ before do
8
+ ActiveRecord::Relation.collection_observer = PredictiveLoad::Loader
9
+ # trigger schema lookup to avoid messing with query count assertions
10
+ Photo.columns
11
+
12
+ topic = Topic.create!(:title => "Sleigh repairs")
13
+ user1 = User.create!(:name => "Rudolph")
14
+ user2 = User.create!(:name => "Santa")
15
+ user1.emails.create!
16
+ comment1 = topic.comments.create!(:body => "meow", :user => user1)
17
+ comment2 = topic.comments.create!(:body => "Ho Ho ho", :user => user2)
18
+ end
19
+
20
+ after do
21
+ User.delete_all
22
+ Comment.delete_all
23
+ Topic.delete_all
24
+ Photo.delete_all
25
+ Email.delete_all
26
+ end
27
+
28
+ it "supports nested loading" do
29
+ # 3: User, Comment, Topic
30
+ assert_queries(3) do
31
+ User.all.each do |user|
32
+ user.comments.each { |comment| assert comment.topic }
33
+ end
34
+ end
35
+ end
36
+
37
+ describe "belongs_to" do
38
+
39
+ it "automatically preloads" do
40
+ comments = Comment.all
41
+ assert_equal 2, comments.size
42
+ assert_queries(1) do
43
+ comments.each { |comment| assert comment.user.name }
44
+ end
45
+ end
46
+
47
+ end
48
+
49
+ describe "has_one" do
50
+
51
+ it "automatically preloads" do
52
+ users = User.all
53
+ assert_equal 2, users.size
54
+
55
+ assert_queries(1) do
56
+ users.each { |user| user.photo }
57
+ end
58
+ end
59
+
60
+ end
61
+
62
+ describe "has_many :through" do
63
+
64
+ it "automatically preloads" do
65
+ users = User.all
66
+ assert_equal 2, users.size
67
+
68
+ assert_queries(3) do
69
+ users.each do |user|
70
+ user.topics.each do |topic|
71
+ topic.comments.to_a
72
+ end
73
+ end
74
+ end
75
+
76
+ end
77
+ end
78
+
79
+ describe "has_and_belongs_to_many" do
80
+
81
+ it "automatically preloads" do
82
+ users = User.all
83
+ assert_equal 2, users.size
84
+
85
+ assert_queries(1) do
86
+ users.each { |user| user.emails.to_a }
87
+ end
88
+
89
+ end
90
+ end
91
+
92
+ describe "has_many" do
93
+
94
+ it "automatically prelaods" do
95
+ users = User.all
96
+ assert_equal 2, users.size
97
+ assert_queries(1) do
98
+ users.each { |user| user.comments.to_a }
99
+ end
100
+ end
101
+
102
+ it "preloads #length" do
103
+ users = User.all
104
+ assert_equal 2, users.size
105
+ assert_queries(1) do
106
+ users.each { |user| user.comments.length }
107
+ end
108
+ end
109
+
110
+ describe "unsupported behavior" do
111
+ it "does not preload when dynamically scoped" do
112
+ users = User.all
113
+ topic = Topic.first
114
+ assert_queries(2) do
115
+ users.each { |user| user.comments.by_topic(topic).to_a }
116
+ end
117
+ end
118
+
119
+ it "does not preload when staticly scoped" do
120
+ users = User.all
121
+ topic = Topic.first
122
+ assert_queries(2) do
123
+ users.each { |user| user.comments.recent.to_a }
124
+ end
125
+ end
126
+
127
+
128
+ it "does not preload #size" do
129
+ users = User.all
130
+ assert_queries(2) do
131
+ users.each { |user| user.comments.size }
132
+ end
133
+ end
134
+
135
+ it "does not preload first/last" do
136
+ users = User.all
137
+ assert_queries(2) do
138
+ users.each { |user| user.comments.first }
139
+ end
140
+ end
141
+ end
142
+
143
+ end
144
+
145
+ end
146
+
147
+ end
data/test/models.rb ADDED
@@ -0,0 +1,25 @@
1
+ class User < ActiveRecord::Base
2
+ has_many :comments, :dependent => :destroy
3
+ has_many :topics, :through => :comments
4
+ has_one :photo
5
+ has_and_belongs_to_many :emails
6
+ end
7
+
8
+ class Email < ActiveRecord::Base
9
+ end
10
+
11
+ class Topic < ActiveRecord::Base
12
+ has_many :comments, :dependent => :destroy
13
+ end
14
+
15
+ class Comment < ActiveRecord::Base
16
+ belongs_to :user
17
+ belongs_to :topic
18
+
19
+ scope :by_topic, lambda { |topic| where(:topic_id => topic.id) }
20
+ scope :recent, order('updated_at desc')
21
+ end
22
+
23
+ class Photo < ActiveRecord::Base
24
+ belongs_to :user
25
+ end
data/test/schema.rb ADDED
@@ -0,0 +1,37 @@
1
+ ActiveRecord::Schema.define(:version => 1) do
2
+
3
+ drop_table(:users) rescue nil
4
+ drop_table(:emails) rescue nil
5
+ drop_table(:photos) rescue nil
6
+ drop_table(:topics) rescue nil
7
+ drop_table(:comments) rescue nil
8
+ drop_table(:emails_users) rescue nil
9
+
10
+ create_table(:users) do |t|
11
+ t.string :name, :null => false
12
+ end
13
+
14
+ create_table(:photos) do |t|
15
+ t.integer :user_id, :null => false
16
+ end
17
+
18
+ create_table(:emails_users) do |t|
19
+ t.integer :user_id
20
+ t.integer :email_id
21
+ end
22
+
23
+ create_table(:emails) do |t|
24
+ end
25
+
26
+ create_table(:topics) do |t|
27
+ t.string :title, :null => false
28
+ end
29
+
30
+ create_table(:comments) do |t|
31
+ t.string :body, :null => false
32
+ t.integer :topic_id, :null => false
33
+ t.integer :user_id, :null => false
34
+ t.timestamps
35
+ end
36
+
37
+ end
@@ -0,0 +1,79 @@
1
+ require_relative 'helper'
2
+ require 'predictive_load/watcher'
3
+ require 'logger'
4
+
5
+ describe PredictiveLoad::Watcher do
6
+
7
+ describe "A collection of records" do
8
+ before do
9
+ ActiveRecord::Relation.collection_observer = PredictiveLoad::Watcher
10
+
11
+ topic = Topic.create!(:title => "Sleigh repairs")
12
+ user1 = User.create!(:name => "Rudolph")
13
+ user2 = User.create!(:name => "Santa")
14
+ comment1 = topic.comments.create!(:body => "meow", :user => user1)
15
+ comment2 = topic.comments.create!(:body => "Ho Ho ho", :user => user2)
16
+ end
17
+
18
+ after do
19
+ User.delete_all
20
+ Comment.delete_all
21
+ Topic.delete_all
22
+ end
23
+
24
+ it "logs what the loader would have done" do
25
+ users = User.all
26
+ users[0].id = 1
27
+ users[1].id = 2
28
+ message = "predictive_load: detected n1 call on User#comments
29
+ predictive_load: expect to prevent 1 queries
30
+ predictive_load: would preload with: SELECT \"comments\".* FROM \"comments\" WHERE \"comments\".\"user_id\" IN (1, 2)
31
+ predictive_load: 0|0|0|SCAN TABLE comments (~100000 rows)
32
+
33
+ predictive_load: 0|0|0|EXECUTE LIST SUBQUERY 1
34
+
35
+ predictive_load: would have prevented all 1 queries
36
+ "
37
+ timing_pattern = /\d+\.\d+ms/
38
+ message.gsub!(timing_pattern, '')
39
+ assert_log(message, timing_pattern) do
40
+ users.each { |user| user.comments.to_a }
41
+ end
42
+ end
43
+
44
+ it "does not log :through association queries" do
45
+ users = User.all
46
+ message = "predictive_load: detected n1 call on User#topics
47
+ predictive_load: expect to prevent 1 queries
48
+ predictive_load: encountered :through association for topics. Requires loading records to generate query, so skipping for now.
49
+ predictive_load: would have prevented all 1 queries
50
+ "
51
+
52
+ timing_pattern = /\d+\.\d+ms/
53
+ message.gsub!(timing_pattern, '')
54
+ assert_log(message, timing_pattern) do
55
+ users.each { |user| user.topics.to_a }
56
+ end
57
+
58
+ end
59
+
60
+ end
61
+
62
+ def assert_log(message, gsub_pattern)
63
+ original_logger = ActiveRecord::Base.logger
64
+ log = StringIO.new
65
+ logger = Logger.new(log)
66
+ logger.level = Logger::Severity::INFO
67
+ logger.formatter = proc { |severity, datetime, progname, msg| "#{msg}\n" }
68
+ ActiveSupport::LogSubscriber.colorize_logging = false
69
+ ActiveRecord::Base.logger = logger
70
+
71
+ yield
72
+ result = log.string
73
+ result.gsub!(gsub_pattern, '')
74
+ assert_equal message, result
75
+ ensure
76
+ ActiveRecord::Base.logger = original_logger
77
+ end
78
+
79
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: predictive_load
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Eric Chapweske
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-01-07 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Predictive loader
14
+ email:
15
+ - eac@zendesk.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - .gitignore
21
+ - .travis.yml
22
+ - Gemfile
23
+ - LICENSE
24
+ - README.md
25
+ - Rakefile
26
+ - lib/predictive_load.rb
27
+ - lib/predictive_load/active_record_collection_observation.rb
28
+ - lib/predictive_load/loader.rb
29
+ - lib/predictive_load/preload_log.rb
30
+ - lib/predictive_load/watcher.rb
31
+ - predictive_load.gemspec
32
+ - test/active_record_collection_observation_test.rb
33
+ - test/database.yml
34
+ - test/helper.rb
35
+ - test/loader_test.rb
36
+ - test/models.rb
37
+ - test/schema.rb
38
+ - test/watcher_test.rb
39
+ homepage: ''
40
+ licenses:
41
+ - Apache License Version 2.0
42
+ metadata: {}
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - '>='
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - '>='
55
+ - !ruby/object:Gem::Version
56
+ version: '0'
57
+ requirements: []
58
+ rubyforge_project:
59
+ rubygems_version: 2.0.7
60
+ signing_key:
61
+ specification_version: 4
62
+ summary: ''
63
+ test_files:
64
+ - test/active_record_collection_observation_test.rb
65
+ - test/database.yml
66
+ - test/helper.rb
67
+ - test/loader_test.rb
68
+ - test/models.rb
69
+ - test/schema.rb
70
+ - test/watcher_test.rb