jit_preloader 0.0.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: f7e698f4bfc6d6724f1b11f46b954995085525b7
4
+ data.tar.gz: 5e597f8c4289ac459d030d4f1c42f76bbf327d34
5
+ SHA512:
6
+ metadata.gz: 2469c5a0361154980988d9c5bf0aae13842b583051d168656efadf9a21f7fc8379d68c842831cce6e1b4b4d37253fb294ddfe84618f3786f35262459590bf9df
7
+ data.tar.gz: 8127d7c2040f18b21664fc11a5062db501d87e378fdbf91b4d327f86819d2a8df36f6c760c3fab0e6dcf79dcc340c71d5f05db62fe01287a20cd6cf720d53fc3
data/.gitignore ADDED
@@ -0,0 +1,50 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ # Used by dotenv library to load environment variables.
14
+ # .env
15
+
16
+ ## Specific to RubyMotion:
17
+ .dat*
18
+ .repl_history
19
+ build/
20
+ *.bridgesupport
21
+ build-iPhoneOS/
22
+ build-iPhoneSimulator/
23
+
24
+ ## Specific to RubyMotion (use of CocoaPods):
25
+ #
26
+ # We recommend against adding the Pods directory to your .gitignore. However
27
+ # you should judge for yourself, the pros and cons are mentioned at:
28
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
29
+ #
30
+ # vendor/Pods/
31
+
32
+ ## Documentation cache and generated files:
33
+ /.yardoc/
34
+ /_yardoc/
35
+ /doc/
36
+ /rdoc/
37
+
38
+ ## Environment normalization:
39
+ /.bundle/
40
+ /vendor/bundle
41
+ /lib/bundler/man/
42
+
43
+ # for a library or gem, you might want to ignore these files since the code is
44
+ # intended to run in multiple environments; otherwise, check them in:
45
+ # Gemfile.lock
46
+ # .ruby-version
47
+ # .ruby-gemset
48
+
49
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
50
+ .rvmrc
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in jit_preloader.gemspec
4
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,61 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ jit_preloader (0.0.1)
5
+ activerecord (~> 4.2)
6
+ activesupport
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ activemodel (4.2.7.1)
12
+ activesupport (= 4.2.7.1)
13
+ builder (~> 3.1)
14
+ activerecord (4.2.7.1)
15
+ activemodel (= 4.2.7.1)
16
+ activesupport (= 4.2.7.1)
17
+ arel (~> 6.0)
18
+ activesupport (4.2.7.1)
19
+ i18n (~> 0.7)
20
+ json (~> 1.7, >= 1.7.7)
21
+ minitest (~> 5.1)
22
+ thread_safe (~> 0.3, >= 0.3.4)
23
+ tzinfo (~> 1.1)
24
+ arel (6.0.3)
25
+ builder (3.2.2)
26
+ byebug (9.0.6)
27
+ database_cleaner (1.5.3)
28
+ diff-lcs (1.2.5)
29
+ i18n (0.7.0)
30
+ json (1.8.3)
31
+ minitest (5.9.1)
32
+ rake (10.5.0)
33
+ rspec (3.5.0)
34
+ rspec-core (~> 3.5.0)
35
+ rspec-expectations (~> 3.5.0)
36
+ rspec-mocks (~> 3.5.0)
37
+ rspec-core (3.5.4)
38
+ rspec-support (~> 3.5.0)
39
+ rspec-expectations (3.5.0)
40
+ diff-lcs (>= 1.2.0, < 2.0)
41
+ rspec-support (~> 3.5.0)
42
+ rspec-mocks (3.5.0)
43
+ diff-lcs (>= 1.2.0, < 2.0)
44
+ rspec-support (~> 3.5.0)
45
+ rspec-support (3.5.0)
46
+ sqlite3 (1.3.12)
47
+ thread_safe (0.3.5)
48
+ tzinfo (1.2.2)
49
+ thread_safe (~> 0.1)
50
+
51
+ PLATFORMS
52
+ ruby
53
+
54
+ DEPENDENCIES
55
+ bundler (~> 1.7)
56
+ byebug
57
+ database_cleaner
58
+ jit_preloader!
59
+ rake (~> 10.0)
60
+ rspec
61
+ sqlite3
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2016 Kyle d'Oliveira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # JitPreloader
2
+
3
+ N+1 queries are a silent killer for performance. Sometimes they can be noticeable; other times they're just a minor tax. We want a way to remove them.
4
+
5
+ Imagine you have contacts that have many emails, phone numbers, and addresses. You might have code like this:
6
+
7
+ ```ruby
8
+ def do_my_thing(contact)
9
+ contact.emails.each do |email|
10
+ # Do a thing with the email
11
+ end
12
+ contact.phone_numbers.each do |phone_number|
13
+ # Do a thing with the phone number
14
+ end
15
+ end
16
+
17
+ # This will generate two N+1 queries, one for emails and one for phone numbers.
18
+ Contact.all.each do |contact|
19
+ do_my_thing(contact)
20
+ end
21
+ ```
22
+
23
+ Rails solves this with `includes` (or better, `preload`/`eager_load`, as they are what `includes` uses in the background). So to get around this problem in Rails you would do something like this:
24
+
25
+ ```ruby
26
+ Contact.preload(:emails, :phone_numbers).each do |contact|
27
+ do_my_thing(contact)
28
+ end
29
+ ```
30
+
31
+ However this does have some limitations.
32
+
33
+ 1) When doing the `preload`, you have to understand what the code does in order to properly load the associations. When this is a brand new method or a simple method this may be simple, but sometimes it can be difficult or time-consuming to figure this out.
34
+
35
+ 2) Imagine we change the method to also use the `addresses` association:
36
+
37
+ ```ruby
38
+ def do_my_thing(contact)
39
+ contact.emails.each do |email|
40
+ # Do a thing with the email
41
+ end
42
+ contact.phone_numbers.each do |phone_number|
43
+ # Do a thing with the phone number
44
+ end
45
+ contact.addresses.each do |address|
46
+ # Do a thing with the address
47
+ end
48
+ end
49
+ ```
50
+
51
+ All of a sudden we have an N+1 query again. So now you need to go hunt down all of the places were you were preloading and preload the new association.
52
+
53
+ 3) Imagine we change the method to do this:
54
+
55
+ ```ruby
56
+ def do_my_thing(contact)
57
+ contact.emails.each do |email|
58
+ # Do a thing with the email
59
+ end
60
+ end
61
+ ```
62
+
63
+ We don't have an N+1 query here, but now we are preloading the `phone_numbers` association but not doing anything with it. This is still bad, especially when there are a lot of associations on the object.
64
+
65
+ This gem provides a "magic bullet" that can remove most N+1 queries in the application.
66
+
67
+ ## Installation
68
+
69
+ Add this line to your application's Gemfile:
70
+
71
+ ```ruby
72
+ gem 'jit_preloader'
73
+ ```
74
+
75
+ And then execute:
76
+
77
+ $ bundle
78
+
79
+ Or install it yourself as:
80
+
81
+ $ gem install jit_preloader
82
+
83
+ ## Usage
84
+
85
+ This gem provides three features:
86
+
87
+ ### N+1 query tracking
88
+
89
+ This gem will publish an `n_plus_one_query` event via ActiveSupport::Notifications whenever it detects one. This lets you do a variety of useful things. Here are some examples:
90
+
91
+ You could implement some basic tracking. This will let you measure the extent of the N+1 query problems in your app:
92
+ ```ruby
93
+ ActiveSupport::Notifications.subscribe("n_plus_one_query") do |event, data|
94
+ statsd.increment "web.#{Rails.env}.n_plus_one_queries.global"
95
+ end
96
+ ```
97
+
98
+ You could log the N+1 queries. In your development environment, you could throw N+1 queries into the logs along with a stack trace:
99
+ ```ruby
100
+ ActiveSupport::Notifications.subscribe("n_plus_one_query") do |event, data|
101
+ message = "N+1 Query detected: #{data[:association]} on #{data[:source].class}"
102
+ backtrace = caller.select{|r| r.starts_with?(Rails.root.to_s) }
103
+ Rails.logger.debug("\n\n#{message}\n#{backtrace.join("\n")}\n".red)
104
+ end
105
+ ```
106
+
107
+ If you use rspec, you could wrap your specs in an `around(:each)` that throws an exception if an N+1 query is detected. You could even provide a tag that allows tests that have known N+1 queries to still pass:
108
+ ```ruby
109
+ config.around(:each) do |example|
110
+ callback = ->(event, data) do
111
+ unless example.metadata[:known_n_plus_one_query]
112
+ message = "N+1 Query detected: #{data[:source].class} on #{data[:association]}"
113
+ raise QueryError.new(message)
114
+ end
115
+ end
116
+ ActiveSupport::Notifications.subscribed(callback, "n_plus_one_query") do
117
+ example.run
118
+ end
119
+ end
120
+ ```
121
+
122
+ ### Jit preloading on a case-by-case basis
123
+
124
+ There is now a `jit_preload` and `jit_preload!` method on ActiveRecord::Relation objects. This means instead of using `includes`, `preload` or `eager_load` with the association you want to load, you can simply just use `jit_preload`
125
+
126
+ ```ruby
127
+ # old
128
+ Contact.preload(:addresses, :email_addresses).each do |contact|
129
+ contact.addresses.to_a
130
+ contact.email_addresses.to_a
131
+ end
132
+
133
+ # new
134
+ Contact.jit_preload.each do |contact|
135
+ contact.addresses.to_a
136
+ contact.email_addresses.to_a
137
+ end
138
+ ```
139
+
140
+ ### Jit preloading globally across your application
141
+
142
+ The JitPreloader can be globally enabled, in which case most N+1 queries in your app should just disappear. It is off by default.
143
+
144
+ ```ruby
145
+ # Can be true or false
146
+ JitPreloader.globally_enabled = true
147
+
148
+ # Can also be given anything that responds to `call`.
149
+ # You could build a kill switch with Redis (or whatever you'd like)
150
+ # so that you can turn it on or off dynamically.
151
+ JitPreloader.globally_enabled = ->{ $redis.get('always_jit_preload') == 'on' }
152
+
153
+ # When enabled globally, this would not generate an N+1 query.
154
+ Contact.all.each do |contact|
155
+ contact.emails.each do |email|
156
+ # do something
157
+ end
158
+ end
159
+ ```
160
+
161
+ ## What it doesn't solve
162
+
163
+ This is mostly a magic bullet, but it doesn't solve all database-related problems. If you reload an association, or call a query or aggregate function on the association, it will not remove those extra queries. These problems cannot be solved by using Rails' `preload` so it cannot be solved with the Jit Preloader.
164
+
165
+ ```ruby
166
+ Contact.all.each do |contact|
167
+ contact.emails.reload # Reloading the association
168
+ contact.phone_numbers.max("LENGTH(number)") # Aggregate functions on the association
169
+ contact.addresses.where(billing: true).to_a # Querying the association
170
+ end
171
+ ```
172
+
173
+ ## Consequences
174
+
175
+ 1) This gem introduces more Magic. This is fine, but you should really understand what is going on under the hood. You should understand what makes an N+1 query happen and what this gem is doing to help address it.
176
+
177
+ 2) We may do more work than you require. If you have turned the preloader on globally but you only want to access a single record's association, it will load the association for the entire collection you were looking at.
178
+
179
+ 3) Each result set will have a JitPreloader setup on it, and the preloader will have a reference to all of the other objects in a result set. This means that so long as one object of that result set exists in memory, the others will not be cleaned up by the garbage collector. This shouldn't have much impact, but it's good to be aware of it.
180
+
181
+ ## Contributing
182
+
183
+ 1. Fork it ( https://github.com/[my-github-username]/jit_preloader/fork )
184
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
185
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
186
+ 4. Push to the branch (`git push origin my-new-feature`)
187
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'jit_preloader/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "jit_preloader"
8
+ spec.version = JitPreloader::VERSION
9
+ spec.authors = ["Kyle d'Oliveira"]
10
+ spec.email = ["kyle.doliveira@clio.com"]
11
+ spec.summary = %q{Tool to understand N+1 queries and to remove them}
12
+ spec.description = %q{The JitPreloader has the ability to send notifications when N+1 queries occur to help guage how problematic they are for your code base and a way to remove all of the commons explicitly or automatically}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "activerecord", "~> 4.2"
22
+ spec.add_dependency "activesupport"
23
+
24
+ spec.add_development_dependency "bundler", "~> 1.7"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency "rspec"
27
+ spec.add_development_dependency "database_cleaner"
28
+ spec.add_development_dependency "sqlite3"
29
+ spec.add_development_dependency "byebug"
30
+ end
@@ -0,0 +1,30 @@
1
+ class ActiveRecord::Associations::CollectionAssociation
2
+
3
+ def load_target_with_jit
4
+ was_loaded = loaded?
5
+
6
+ if !loaded? && owner.persisted? && owner.jit_preloader
7
+ owner.jit_preloader.jit_preload(reflection.name)
8
+ end
9
+
10
+ jit_loaded = loaded?
11
+
12
+ load_target_without_jit.tap do |records|
13
+ # We should not act on non-persisted objects, or ones that are already loaded.
14
+ if owner.persisted? && !was_loaded
15
+ # If we went through a JIT preload, then we will have attached another JitPreloader elsewhere.
16
+ JitPreloader::Preloader.attach(records) if records.any? && !jit_loaded && JitPreloader.globally_enabled?
17
+
18
+ # If the records were not pre_loaded
19
+ records.each{ |record| record.jit_n_plus_one_tracking = true }
20
+
21
+ if !jit_loaded && owner.jit_n_plus_one_tracking
22
+ ActiveSupport::Notifications.publish("n_plus_one_query",
23
+ source: owner, association: reflection.name)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ alias_method_chain :load_target, :jit
29
+
30
+ end
@@ -0,0 +1,34 @@
1
+ class ActiveRecord::Associations::Preloader::CollectionAssociation
2
+ private
3
+ # A monkey patch to ActiveRecord. The old method looked like the snippet
4
+ # below. Our changes here are that we remove records that are already
5
+ # part of the target, then attach all of the records to a new jit preloader.
6
+ #
7
+ # def preload(preloader)
8
+ # associated_records_by_owner(preloader).each do |owner, records|
9
+ # association = owner.association(reflection.name)
10
+ # association.loaded!
11
+ # association.target.concat(records)
12
+ # records.each { |record| association.set_inverse_instance(record) }
13
+ # end
14
+ # end
15
+
16
+ def preload(preloader)
17
+ return unless reflection.scope.nil? || reflection.scope.arity == 0
18
+ all_records = []
19
+ associated_records_by_owner(preloader).each do |owner, records|
20
+ association = owner.association(reflection.name)
21
+ association.loaded!
22
+ # It is possible that some of the records are loaded already.
23
+ # We don't want to duplicate them, but we also want to preserve
24
+ # the original copy so that we don't blow away in-memory changes.
25
+ new_records = association.target.any? ? records - association.target : records
26
+
27
+ association.target.concat(new_records)
28
+ new_records.each { |record| association.set_inverse_instance(record) }
29
+
30
+ all_records.concat(records) if owner.jit_preloader || JitPreloader.globally_enabled?
31
+ end
32
+ JitPreloader::Preloader.attach(all_records) if all_records.any?
33
+ end
34
+ end
@@ -0,0 +1,29 @@
1
+ class ActiveRecord::Associations::Preloader::SingularAssociation
2
+ private
3
+ # A monkey patch to ActiveRecord. The old method looked like the snippet
4
+ # below. Our changes here are that we don't assign the record if the
5
+ # target has already been set, and we attach all of the records to a new
6
+ # jit preloader.
7
+ #
8
+ # def preload(preloader)
9
+ # associated_records_by_owner(preloader).each do |owner, associated_records|
10
+ # record = associated_records.first
11
+ # association = owner.association(reflection.name)
12
+ # association.target = record
13
+ # end
14
+ # end
15
+
16
+ def preload(preloader)
17
+ return unless reflection.scope.nil? || reflection.scope.arity == 0
18
+ all_records = []
19
+
20
+ associated_records_by_owner(preloader).each do |owner, associated_records|
21
+ record = associated_records.first
22
+
23
+ association = owner.association(reflection.name)
24
+ association.target ||= record
25
+ all_records.push(record) if record && (owner.jit_preloader || JitPreloader.globally_enabled?)
26
+ end
27
+ JitPreloader::Preloader.attach(all_records) if all_records.any?
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ class ActiveRecord::Associations::SingularAssociation
2
+
3
+ def load_target_with_jit
4
+ was_loaded = loaded?
5
+
6
+ if !loaded? && owner.persisted? && owner.jit_preloader
7
+ owner.jit_preloader.jit_preload(reflection.name)
8
+ end
9
+
10
+ jit_loaded = loaded?
11
+
12
+ load_target_without_jit.tap do |record|
13
+ if owner.persisted? && !was_loaded
14
+ # If the owner doesn't track N+1 queries, then we don't need to worry about
15
+ # tracking it on the record. This is because you can do something like:
16
+ # model.foo.bar (where foo and bar are singular associations) and that isn't
17
+ # always an N+1 query.
18
+ record.jit_n_plus_one_tracking ||= owner.jit_n_plus_one_tracking if record
19
+
20
+ if !jit_loaded && owner.jit_n_plus_one_tracking
21
+ ActiveSupport::Notifications.publish("n_plus_one_query",
22
+ source: owner, association: reflection.name)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ alias_method_chain :load_target, :jit
28
+
29
+ end
@@ -0,0 +1,15 @@
1
+ module JitPreloadExtension
2
+
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ attr_accessor :jit_preloader
7
+ attr_accessor :jit_n_plus_one_tracking
8
+ end
9
+
10
+ class_methods do
11
+ delegate :jit_preload, to: :all
12
+ end
13
+ end
14
+
15
+ ActiveRecord::Base.send(:include, JitPreloadExtension)
@@ -0,0 +1,16 @@
1
+ module ActiveRecord::QueryMethods
2
+
3
+ def jit_preload(*args)
4
+ spawn.jit_preload!(*args)
5
+ end
6
+
7
+ def jit_preload!(*args)
8
+ @jit_preload = true
9
+ self
10
+ end
11
+
12
+ def jit_preload?
13
+ @jit_preload
14
+ end
15
+
16
+ end
@@ -0,0 +1,26 @@
1
+ class ActiveRecord::Relation
2
+
3
+ def calculate_with_jit(*args)
4
+ if respond_to?(:proxy_association) && proxy_association.owner && proxy_association.owner.jit_n_plus_one_tracking
5
+ ActiveSupport::Notifications.publish("n_plus_one_query",
6
+ source: proxy_association.owner,
7
+ association: "#{proxy_association.reflection.name}.#{args.first}")
8
+ end
9
+ calculate_without_jit(*args)
10
+ end
11
+
12
+ alias_method_chain :calculate, :jit
13
+
14
+ def exec_queries_with_jit
15
+ exec_queries_without_jit.tap do |records|
16
+ if limit_value != 1
17
+ records.each{ |record| record.jit_n_plus_one_tracking = true }
18
+ if jit_preload? || JitPreloader.globally_enabled?
19
+ JitPreloader::Preloader.attach(records)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ alias_method_chain :exec_queries, :jit
25
+
26
+ end
@@ -0,0 +1,23 @@
1
+ module JitPreloader
2
+ class Preloader < ActiveRecord::Associations::Preloader
3
+
4
+ attr_accessor :records
5
+
6
+ def self.attach(records)
7
+ new.tap do |loader|
8
+ loader.records = records
9
+ records.each do |record|
10
+ record.jit_preloader = loader
11
+ end
12
+ end
13
+ end
14
+
15
+ def jit_preload(association)
16
+ # It is possible that the records array has multiple different classes (think single table inheritance).
17
+ # Thus, it is possible that some of the records don't have an association.
18
+ records_with_association = records.reject{|r| r.class.reflect_on_association(association).nil? }
19
+ preload records_with_association, association
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ module JitPreloader
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,30 @@
1
+ require 'active_support/concern'
2
+ require 'active_support/core_ext/module/delegation'
3
+ require 'active_support/notifications'
4
+ require 'active_record'
5
+
6
+ require "jit_preloader/version"
7
+ require 'jit_preloader/active_record/base'
8
+ require 'jit_preloader/active_record/query_methods'
9
+ require 'jit_preloader/active_record/relation'
10
+ require 'jit_preloader/active_record/associations/collection_association'
11
+ require 'jit_preloader/active_record/associations/singular_association'
12
+ require 'jit_preloader/active_record/associations/preloader/collection_association'
13
+ require 'jit_preloader/active_record/associations/preloader/singular_association'
14
+ require 'jit_preloader/preloader'
15
+
16
+ module JitPreloader
17
+
18
+ def self.globally_enabled=(value)
19
+ @enabled = value
20
+ end
21
+
22
+ def self.globally_enabled?
23
+ if @enabled && @enabled.respond_to?(:call)
24
+ @enabled.call
25
+ else
26
+ @enabled
27
+ end
28
+ end
29
+
30
+ end
@@ -0,0 +1,144 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe JitPreloader::Preloader do
4
+
5
+ let!(:contact1) do
6
+ Contact.create(
7
+ name: "Only Addresses",
8
+ addresses: [
9
+ Address.new(street: "123 Fake st", country: canada),
10
+ Address.new(street: "21 Jump st", country: usa)
11
+ ]
12
+ )
13
+ end
14
+
15
+ let!(:contact2) do
16
+ Contact.create(
17
+ name: "Only Emails",
18
+ email_address: EmailAddress.new(address: "woot@woot.com"),
19
+ )
20
+ end
21
+
22
+ let!(:contact3) do
23
+ Contact.create(
24
+ name: "Both!",
25
+ addresses: [
26
+ Address.new(street: "1 First st", country: canada),
27
+ Address.new(street: "10 Tenth Ave", country: usa)
28
+ ],
29
+ email_address: EmailAddress.new(address: "woot@woot.com"),
30
+ )
31
+ end
32
+
33
+ let(:canada) { Country.create(name: "Canada") }
34
+ let(:usa) { Country.create(name: "U.S.A") }
35
+
36
+ let(:source_map) { Hash.new{|h,k| h[k]= Array.new } }
37
+ let(:callback) do
38
+ ->(event, data){ source_map[data[:source]] << data[:association] }
39
+ end
40
+
41
+ context "when the preloader is globally enabled" do
42
+ around do |example|
43
+ JitPreloader.globally_enabled = true
44
+ example.run
45
+ JitPreloader.globally_enabled = false
46
+ end
47
+ context "when grabbing all of the address'es contries and email addresses" do
48
+ it "doesn't generate an N+1 query ntoification" do
49
+ ActiveSupport::Notifications.subscribed(callback, "n_plus_one_query") do
50
+ Contact.all.collect{|c| c.addresses.collect(&:country); c.email_address }
51
+ end
52
+ expect(source_map).to eql({})
53
+ end
54
+ end
55
+
56
+ context "when we perform aggregate functions on the data" do
57
+ it "generates N+1 query notifications for each one" do
58
+ ActiveSupport::Notifications.subscribed(callback, "n_plus_one_query") do
59
+ Contact.all.each{|c| c.addresses.count; c.addresses.sum(:id) }
60
+ end
61
+ contact_queries = [contact1,contact2, contact3].product([["addresses.count", "addresses.sum"]])
62
+ expect(source_map).to eql(Hash[contact_queries])
63
+ end
64
+ end
65
+ end
66
+
67
+ context "when the preloader is not globally enabled" do
68
+ context "when we perform aggregate functions on the data" do
69
+ it "generates N+1 query notifications for each one" do
70
+ ActiveSupport::Notifications.subscribed(callback, "n_plus_one_query") do
71
+ Contact.all.each{|c| c.addresses.count; c.addresses.sum(:id) }
72
+ end
73
+ contact_queries = [contact1,contact2, contact3].product([["addresses.count", "addresses.sum"]])
74
+ expect(source_map).to eql(Hash[contact_queries])
75
+ end
76
+ end
77
+
78
+ context "when explicitly finding a contact" do
79
+ it "generates N+1 query notifications for the country" do
80
+ ActiveSupport::Notifications.subscribed(callback, "n_plus_one_query") do
81
+ Contact.find(contact1.id).tap{|c| c.addresses.collect(&:country); c.email_address }
82
+ end
83
+ address_queries = Address.where(contact_id: 1).product([[:country]])
84
+ expect(source_map).to eql(Hash[address_queries])
85
+ end
86
+ end
87
+
88
+ context "when explicitly finding multiple contacts" do
89
+ it "generates N+1 query notifications for the country" do
90
+ ActiveSupport::Notifications.subscribed(callback, "n_plus_one_query") do
91
+ Contact.find(contact1.id, contact2.id).each{|c| c.addresses.collect(&:country); c.email_address }
92
+ end
93
+ contact_queries = [contact1,contact2].product([[:addresses, :email_address]])
94
+ address_queries = Address.where(contact_id: contact1.id).product([[:country]])
95
+
96
+ expect(source_map).to eql(Hash[address_queries.concat(contact_queries)])
97
+ end
98
+ end
99
+
100
+ context "when grabbing the email address and address's country of the first contact" do
101
+ it "generates N+1 query notifications for the country" do
102
+ ActiveSupport::Notifications.subscribed(callback, "n_plus_one_query") do
103
+ Contact.first.tap{|c| c.addresses.collect(&:country); c.email_address }
104
+ end
105
+
106
+ address_queries = Address.where(contact_id: contact1.id).product([[:country]])
107
+
108
+ expect(source_map).to eql(Hash[address_queries])
109
+ end
110
+ end
111
+
112
+ context "when grabbing all of the address'es contries and email addresses" do
113
+ it "generates an N+1 query for each association on the contacts" do
114
+ ActiveSupport::Notifications.subscribed(callback, "n_plus_one_query") do
115
+ Contact.all.each{|c| c.addresses.collect(&:country); c.email_address }
116
+ end
117
+ contact_queries = [contact1,contact2,contact3].product([[:addresses, :email_address]])
118
+ address_queries = Address.all.product([[:country]])
119
+ expect(source_map).to eql(Hash[address_queries.concat(contact_queries)])
120
+ end
121
+
122
+ context "and we use regular preload for addresses" do
123
+ it "generates an N+1 query for only the email addresses on the contacts" do
124
+ ActiveSupport::Notifications.subscribed(callback, "n_plus_one_query") do
125
+ Contact.preload(:addresses).each{|c| c.addresses.collect(&:country); c.email_address }
126
+ end
127
+ contact_queries = [contact1,contact2,contact3].product([[:email_address]])
128
+ address_queries = Address.all.product([[:country]])
129
+ expect(source_map).to eql(Hash[address_queries.concat(contact_queries)])
130
+ end
131
+ end
132
+
133
+ context "and we use jit preload" do
134
+ it "generates no n+1 queries" do
135
+ ActiveSupport::Notifications.subscribed(callback, "n_plus_one_query") do
136
+ Contact.jit_preload.each{|c| c.addresses.collect(&:country); c.email_address }
137
+ end
138
+ expect(source_map).to eql({})
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ end
@@ -0,0 +1,51 @@
1
+ require 'bundler/setup'
2
+ Bundler.setup
3
+
4
+ require 'jit_preloader'
5
+ require 'support/database'
6
+ require 'support/models'
7
+ require 'byebug'
8
+ require 'database_cleaner'
9
+
10
+ DatabaseCleaner.strategy = :transaction
11
+
12
+ RSpec.configure do |config|
13
+ config.before(:suite) do
14
+ Database.connect!
15
+ Database.build!
16
+ end
17
+ config.before do
18
+ DatabaseCleaner.start
19
+ end
20
+
21
+ config.after do
22
+ DatabaseCleaner.clean
23
+ end
24
+
25
+ # rspec-expectations config goes here. You can use an alternate
26
+ # assertion/expectation library such as wrong or the stdlib/minitest
27
+ # assertions if you prefer.
28
+ config.expect_with :rspec do |expectations|
29
+ # This option will default to `true` in RSpec 4. It makes the `description`
30
+ # and `failure_message` of custom matchers include text for helper methods
31
+ # defined using `chain`, e.g.:
32
+ # be_bigger_than(2).and_smaller_than(4).description
33
+ # # => "be bigger than 2 and smaller than 4"
34
+ # ...rather than:
35
+ # # => "be bigger than 2"
36
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
37
+ end
38
+
39
+ # rspec-mocks config goes here. You can use an alternate test double
40
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
41
+ config.mock_with :rspec do |mocks|
42
+ # Prevents you from mocking or stubbing a method that does not exist on
43
+ # a real object. This is generally recommended, and will default to
44
+ # `true` in RSpec 4.
45
+ mocks.verify_partial_doubles = true
46
+ end
47
+
48
+ config.disable_monkey_patching!
49
+
50
+ config.order = :random
51
+ end
@@ -0,0 +1,21 @@
1
+ class Database
2
+ def self.tables
3
+ [
4
+ "CREATE TABLE contacts (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(255))",
5
+ "CREATE TABLE addresses (id INTEGER NOT NULL PRIMARY KEY, contact_id INTEGER NOT NULL, country_id INTEGER NOT NULL, street VARCHAR(255))",
6
+ "CREATE TABLE email_addresses (id INTEGER NOT NULL PRIMARY KEY, contact_id INTEGER NOT NULL, address VARCHAR(255))",
7
+ "CREATE TABLE countries (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(255))",
8
+ ]
9
+ end
10
+
11
+ def self.build!
12
+ tables.each do |table|
13
+ ActiveRecord::Base.connection.execute(table)
14
+ end
15
+ end
16
+
17
+ def self.connect!
18
+ ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: ":memory:"
19
+ end
20
+
21
+ end
@@ -0,0 +1,17 @@
1
+ class Contact < ActiveRecord::Base
2
+ has_many :addresses
3
+ has_one :email_address
4
+ end
5
+
6
+ class Address < ActiveRecord::Base
7
+ belongs_to :contact
8
+ belongs_to :country
9
+ end
10
+
11
+ class EmailAddress < ActiveRecord::Base
12
+ belongs_to :contact
13
+ end
14
+
15
+ class Country < ActiveRecord::Base
16
+ has_many :addresses
17
+ end
metadata ADDED
@@ -0,0 +1,184 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jit_preloader
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Kyle d'Oliveira
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-10-31 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
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: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.7'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.7'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '10.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '10.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
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: sqlite3
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
+ - !ruby/object:Gem::Dependency
112
+ name: byebug
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description: The JitPreloader has the ability to send notifications when N+1 queries
126
+ occur to help guage how problematic they are for your code base and a way to remove
127
+ all of the commons explicitly or automatically
128
+ email:
129
+ - kyle.doliveira@clio.com
130
+ executables: []
131
+ extensions: []
132
+ extra_rdoc_files: []
133
+ files:
134
+ - ".gitignore"
135
+ - ".rspec"
136
+ - Gemfile
137
+ - Gemfile.lock
138
+ - LICENSE
139
+ - README.md
140
+ - Rakefile
141
+ - jit_preloader.gemspec
142
+ - lib/jit_preloader.rb
143
+ - lib/jit_preloader/active_record/associations/collection_association.rb
144
+ - lib/jit_preloader/active_record/associations/preloader/collection_association.rb
145
+ - lib/jit_preloader/active_record/associations/preloader/singular_association.rb
146
+ - lib/jit_preloader/active_record/associations/singular_association.rb
147
+ - lib/jit_preloader/active_record/base.rb
148
+ - lib/jit_preloader/active_record/query_methods.rb
149
+ - lib/jit_preloader/active_record/relation.rb
150
+ - lib/jit_preloader/preloader.rb
151
+ - lib/jit_preloader/version.rb
152
+ - spec/lib/jit_preloader/preloader_spec.rb
153
+ - spec/spec_helper.rb
154
+ - spec/support/database.rb
155
+ - spec/support/models.rb
156
+ homepage: ''
157
+ licenses:
158
+ - MIT
159
+ metadata: {}
160
+ post_install_message:
161
+ rdoc_options: []
162
+ require_paths:
163
+ - lib
164
+ required_ruby_version: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: '0'
169
+ required_rubygems_version: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ requirements: []
175
+ rubyforge_project:
176
+ rubygems_version: 2.4.3
177
+ signing_key:
178
+ specification_version: 4
179
+ summary: Tool to understand N+1 queries and to remove them
180
+ test_files:
181
+ - spec/lib/jit_preloader/preloader_spec.rb
182
+ - spec/spec_helper.rb
183
+ - spec/support/database.rb
184
+ - spec/support/models.rb