n_1_finder 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Readme.md +74 -0
- data/lib/n_1_finder.rb +111 -0
- data/lib/n_1_finder/adapters/active_record_adapter.rb +49 -0
- data/lib/n_1_finder/adapters/base_adapter.rb +46 -0
- data/lib/n_1_finder/adapters/null_adapter.rb +16 -0
- data/lib/n_1_finder/adapters/sequel_adapter.rb +33 -0
- data/lib/n_1_finder/logger.rb +56 -0
- data/lib/n_1_finder/middleware.rb +13 -0
- data/lib/n_1_finder/n_1_query.rb +38 -0
- data/lib/n_1_finder/query.rb +57 -0
- data/lib/n_1_finder/storage.rb +23 -0
- data/lib/n_1_finder/version.rb +4 -0
- data/spec/n_1_finder/adapters/active_record_adapter_spec.rb +37 -0
- data/spec/n_1_finder/adapters/null_adapter_spec.rb +5 -0
- data/spec/n_1_finder/adapters/sequel_adapter_spec.rb +5 -0
- data/spec/n_1_finder/features/active_record_mysql_spec.rb +12 -0
- data/spec/n_1_finder/features/active_record_postgres_spec.rb +12 -0
- data/spec/n_1_finder/features/active_record_sqlite_spec.rb +12 -0
- data/spec/n_1_finder/features/sequel_mysql_spec.rb +12 -0
- data/spec/n_1_finder/features/sequel_postgres_spec.rb +13 -0
- data/spec/n_1_finder/features/sequel_sqlite_spec.rb +12 -0
- data/spec/n_1_finder/logger_spec.rb +40 -0
- data/spec/n_1_finder/middleware_spec.rb +15 -0
- data/spec/n_1_finder/n_1_query_spec.rb +28 -0
- data/spec/n_1_finder/query_spec.rb +81 -0
- data/spec/n_1_finder/storage_spec.rb +23 -0
- data/spec/n_1_finder_spec.rb +67 -0
- data/spec/secrets.yml.example +9 -0
- data/spec/shared_examples/adapter.rb +37 -0
- data/spec/spec_helper.rb +61 -0
- data/spec/support/n_1_helpers.rb +56 -0
- data/spec/support/orm_helpers.rb +89 -0
- data/spec/support/test_active_record_migration.rb +46 -0
- data/spec/support/test_active_record_models.rb +56 -0
- data/spec/support/test_no_connection_models.rb +57 -0
- data/spec/support/test_sequel_migration.rb +39 -0
- data/spec/support/test_sequel_models.rb +65 -0
- metadata +81 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 3e389a1727607242ce3f686e8db20fcdc33ca3ed
|
4
|
+
data.tar.gz: 49a22b9adf74c1c1f329e1b2970e647659cef94b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1e240024894cf46304884f59d98d9396f33218f5069e6b468cc0104df9d75e5254472639ca7aa5856ce0e2a2a0f829f4852f89aafd82003aa079277cee58bd0c
|
7
|
+
data.tar.gz: 17266b8396fdcb2cbe1fe0d710364efee3154963cdaa9f4dbe19cb63a3c70a6af2f3c1aa87bc9d41124e03824908ae88eefb2262ef5263621440487cc221811d
|
data/Readme.md
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
# N+1 Finder
|
2
|
+
This gem can help you to find N+1 queries.
|
3
|
+
|
4
|
+
It works with `ActiveRecord` and `Sequel`.
|
5
|
+
|
6
|
+
And tested with `postgresql`, `mysql` and `sqlite`.
|
7
|
+
|
8
|
+
## Installation
|
9
|
+
With Gemfile: `gem 'n1_finder', group: :development`
|
10
|
+
|
11
|
+
Without Gemfile: `gem install n1_finder`
|
12
|
+
|
13
|
+
## Configuration
|
14
|
+
### Logger
|
15
|
+
Default is `Logger.new(STDOUT)`
|
16
|
+
|
17
|
+
`N+1 Finder` logger can be any instance of `Logger` class.
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
N1Finder.logger = Logger.new('log/n1.log')
|
21
|
+
```
|
22
|
+
|
23
|
+
### ORM
|
24
|
+
Default is `:active_record` if you have activerecord gem installed.
|
25
|
+
|
26
|
+
Default is `:sequel` if you have sequel gem installed.
|
27
|
+
|
28
|
+
You can set ORM explicitly
|
29
|
+
```ruby
|
30
|
+
N1Finder.orm = :active_record
|
31
|
+
```
|
32
|
+
|
33
|
+
## Using
|
34
|
+
|
35
|
+
### Directly
|
36
|
+
```ruby
|
37
|
+
N1Finder.find do
|
38
|
+
User.all.map { |user| user.comments.to_a }
|
39
|
+
end
|
40
|
+
```
|
41
|
+
|
42
|
+
Log example:
|
43
|
+
```log
|
44
|
+
D, [2016-06-28T11:15:23.019561 #7542] DEBUG -- :
|
45
|
+
N+1 QUERY DETECTED:
|
46
|
+
QUERY: SELECT "comments".* FROM "comments" WHERE "comments"."user_id" = $1, user_id = [id]
|
47
|
+
LINE: /home/andrey/.rvm/rubies/ruby-2.2.1/lib/ruby/2.2.0/pp.rb:187:in `block in pp'
|
48
|
+
QUERIES_COUNT: 2
|
49
|
+
ORIGINAL_QUERIES:
|
50
|
+
SELECT "comments".* FROM "comments" WHERE "comments"."user_id" = $1, user_id = 618
|
51
|
+
SELECT "comments".* FROM "comments" WHERE "comments"."user_id" = $1, user_id = 947
|
52
|
+
```
|
53
|
+
|
54
|
+
### Middleware
|
55
|
+
`N+1 Finder` provides middleware that you can include to any rack application to find N+1 queries in your requests:
|
56
|
+
|
57
|
+
#### Rails
|
58
|
+
```ruby
|
59
|
+
class Application < Rails::Application
|
60
|
+
config.middleware.use(N1Finder::Middleware)
|
61
|
+
```
|
62
|
+
|
63
|
+
#### Grape
|
64
|
+
```ruby
|
65
|
+
class YourAPI < Grape::API
|
66
|
+
use N1Finder::Middleware
|
67
|
+
```
|
68
|
+
|
69
|
+
#### Padrino
|
70
|
+
```ruby
|
71
|
+
class YourAPP < Padrino::Application
|
72
|
+
use N1Finder::Middleware
|
73
|
+
```
|
74
|
+
|
data/lib/n_1_finder.rb
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
require 'n_1_finder/logger'
|
2
|
+
require 'n_1_finder/middleware'
|
3
|
+
require 'n_1_finder/query'
|
4
|
+
require 'n_1_finder/n_1_query'
|
5
|
+
require 'n_1_finder/storage'
|
6
|
+
require 'n_1_finder/adapters/base_adapter'
|
7
|
+
require 'n_1_finder/adapters/active_record_adapter'
|
8
|
+
require 'n_1_finder/adapters/sequel_adapter'
|
9
|
+
require 'n_1_finder/adapters/null_adapter'
|
10
|
+
|
11
|
+
# Main class
|
12
|
+
class N1Finder
|
13
|
+
# Base error
|
14
|
+
# @api private
|
15
|
+
class Invalid < StandardError; end
|
16
|
+
|
17
|
+
# Raised when specifying invalid ORM
|
18
|
+
# @api private
|
19
|
+
class InvalidORM < Invalid; end
|
20
|
+
|
21
|
+
# Raised when specifying invalid logger
|
22
|
+
# @api private
|
23
|
+
class InvalidLogger < Invalid; end
|
24
|
+
|
25
|
+
# Supported ORM adapters
|
26
|
+
ORM_ADAPTERS = {
|
27
|
+
active_record: N1Finder::Adapters::ActiveRecordAdapter,
|
28
|
+
sequel: N1Finder::Adapters::SequelAdapter
|
29
|
+
}.freeze
|
30
|
+
|
31
|
+
class << self
|
32
|
+
# Supported ORM adapters
|
33
|
+
|
34
|
+
# Searches for N+1 queries and logs results
|
35
|
+
#
|
36
|
+
# @yield block
|
37
|
+
#
|
38
|
+
# @return [void] result of block call
|
39
|
+
def find
|
40
|
+
storage = N1Finder::Storage.new
|
41
|
+
result = catch_queries(storage) { yield }
|
42
|
+
n1_queries = N1Finder::N1Query.generate_by(storage.queries)
|
43
|
+
N1Finder::Logger.new.log(n1_queries)
|
44
|
+
|
45
|
+
result
|
46
|
+
end
|
47
|
+
|
48
|
+
# Logger to log N+1 queries
|
49
|
+
#
|
50
|
+
# Defaults to `Logger.new(STDOUT)`
|
51
|
+
#
|
52
|
+
# @return [Logger]
|
53
|
+
def logger
|
54
|
+
@logger ||= ::Logger.new(STDOUT)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Configure logger
|
58
|
+
#
|
59
|
+
# @param [Logger] custom_logger
|
60
|
+
# Must be instance of `Logger`
|
61
|
+
#
|
62
|
+
# @raise [N1Finder::InvalidLogger] If custom_logger is not an instance of `Logger`.
|
63
|
+
#
|
64
|
+
# @return [Logger]
|
65
|
+
def logger=(custom_logger)
|
66
|
+
raise N1Finder::InvalidLogger unless custom_logger.is_a?(::Logger)
|
67
|
+
|
68
|
+
@logger = custom_logger
|
69
|
+
end
|
70
|
+
|
71
|
+
# ORM used in project
|
72
|
+
# Default to :active_record if ActiveRecord defined
|
73
|
+
# Default to :sequel if Sequel defined
|
74
|
+
# Default to nil if ActiveRecord and Sequel are not defined
|
75
|
+
#
|
76
|
+
# @return [Symbol, nil]
|
77
|
+
def orm
|
78
|
+
@orm ||=
|
79
|
+
if defined?(ActiveRecord)
|
80
|
+
:active_record
|
81
|
+
elsif defined?(Sequel)
|
82
|
+
:sequel
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Configure ORM
|
87
|
+
#
|
88
|
+
# @param [Symbol] custom_orm
|
89
|
+
# Must be `:active_record` or `:sequel`
|
90
|
+
#
|
91
|
+
# @raise [N1Finder::InvalidORM] If custom_orm is not in allowed list.
|
92
|
+
#
|
93
|
+
# @return [Symbol]
|
94
|
+
def orm=(custom_orm)
|
95
|
+
raise N1Finder::InvalidORM unless ORM_ADAPTERS.keys.include?(custom_orm)
|
96
|
+
|
97
|
+
@orm = custom_orm
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def catch_queries(storage)
|
103
|
+
adapter = adapter_class.new(storage)
|
104
|
+
adapter.exec(&Proc.new)
|
105
|
+
end
|
106
|
+
|
107
|
+
def adapter_class
|
108
|
+
ORM_ADAPTERS[orm] || N1Finder::Adapters::NullAdapter
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
##
|
2
|
+
# Catches queries when using ActiveRecord
|
3
|
+
#
|
4
|
+
class N1Finder::Adapters::ActiveRecordAdapter < N1Finder::Adapters::BaseAdapter
|
5
|
+
# Method in `database_class` that we observe to find N+1 queries
|
6
|
+
# Any activerecord connection adapter has a `exec_query` method
|
7
|
+
# http://api.rubyonrails.org/classes/ActiveRecord/Result.html
|
8
|
+
MAIN_METHOD = :exec_query
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def set_trap
|
13
|
+
main_method = MAIN_METHOD
|
14
|
+
main_method_alias = MAIN_METHOD_ALIAS
|
15
|
+
current_storage = storage
|
16
|
+
|
17
|
+
database_class.class_eval do
|
18
|
+
alias_method main_method_alias, main_method
|
19
|
+
define_method(main_method) do |*params, &block|
|
20
|
+
sql = params.first
|
21
|
+
sql_params = N1Finder::Adapters::ActiveRecordAdapter.find_sql_params(params)
|
22
|
+
current_storage.add(sql, sql_params, caller)
|
23
|
+
send(main_method_alias, *params, &block)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def database_class
|
29
|
+
@database_class ||= ActiveRecord::Base.connection.class
|
30
|
+
end
|
31
|
+
|
32
|
+
class << self
|
33
|
+
# Searches for activerecord sql params in array
|
34
|
+
#
|
35
|
+
# @param [Array] params
|
36
|
+
#
|
37
|
+
# @return [Hash] sql params
|
38
|
+
def find_sql_params(params)
|
39
|
+
binds = params.find do |param|
|
40
|
+
param.is_a?(Array) && param.first.is_a?(Array) &&
|
41
|
+
param.first.first.is_a?(ActiveRecord::ConnectionAdapters::Column)
|
42
|
+
end || []
|
43
|
+
|
44
|
+
Hash[binds].each_with_object({}) do |(key, value), object|
|
45
|
+
object[key.name] = value
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
##
|
2
|
+
# Combine ORM adapters
|
3
|
+
#
|
4
|
+
module N1Finder::Adapters
|
5
|
+
# Adds common functionality to other adapters
|
6
|
+
class BaseAdapter
|
7
|
+
# An alias that we create for MAIN_METHOD function
|
8
|
+
MAIN_METHOD_ALIAS = :main_method_alias
|
9
|
+
|
10
|
+
# @!attribute [r] storage
|
11
|
+
# Storage that stores queries
|
12
|
+
attr_reader :storage
|
13
|
+
|
14
|
+
def initialize(storage)
|
15
|
+
@storage = storage
|
16
|
+
end
|
17
|
+
|
18
|
+
# Replaces original query execution function (defined in MAIN_METHOD)
|
19
|
+
# with our function that collects all queries and calls original function.
|
20
|
+
# After passed block yileds, replaces our function with origianal and
|
21
|
+
# removes our function.
|
22
|
+
#
|
23
|
+
# @yield passed block
|
24
|
+
#
|
25
|
+
# @return passed block execution result
|
26
|
+
def exec
|
27
|
+
set_trap
|
28
|
+
yield
|
29
|
+
ensure
|
30
|
+
remove_trap
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def remove_trap
|
36
|
+
main_query_method = self.class::MAIN_METHOD
|
37
|
+
main_method_alias = MAIN_METHOD_ALIAS
|
38
|
+
|
39
|
+
database_class.class_eval do
|
40
|
+
remove_method(main_query_method)
|
41
|
+
alias_method main_query_method, main_method_alias
|
42
|
+
remove_method(main_method_alias)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
##
|
2
|
+
# Used when adapter not specified. Null object
|
3
|
+
#
|
4
|
+
class N1Finder::Adapters::NullAdapter < N1Finder::Adapters::BaseAdapter
|
5
|
+
# Don't do anything
|
6
|
+
#
|
7
|
+
# @return [nil]
|
8
|
+
def set_trap
|
9
|
+
end
|
10
|
+
|
11
|
+
# Don't do anything
|
12
|
+
#
|
13
|
+
# @return [nil]
|
14
|
+
def remove_trap
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
##
|
2
|
+
# Catches queries when using Sequel
|
3
|
+
#
|
4
|
+
class N1Finder::Adapters::SequelAdapter < N1Finder::Adapters::BaseAdapter
|
5
|
+
# Method in `Sequel::Model.db.class` that we observe to find N+1 queries
|
6
|
+
# Each sequel adapter has `execute` method
|
7
|
+
# For example:
|
8
|
+
# https://github.com/jeremyevans/sequel/blob/ac925ce9556f33d56f49b84d905d307c6a621716/lib/sequel/adapters/postgres.rb#L171
|
9
|
+
# https://github.com/jeremyevans/sequel/blob/ac925ce9556f33d56f49b84d905d307c6a621716/lib/sequel/adapters/mysql.rb#L352
|
10
|
+
# https://github.com/jeremyevans/sequel/blob/ac925ce9556f33d56f49b84d905d307c6a621716/lib/sequel/adapters/sqlite.rb#L129
|
11
|
+
MAIN_METHOD = :execute
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def set_trap
|
16
|
+
main_query_method = MAIN_METHOD
|
17
|
+
main_method_alias = MAIN_METHOD_ALIAS
|
18
|
+
current_storage = storage
|
19
|
+
|
20
|
+
database_class.class_eval do
|
21
|
+
alias_method main_method_alias, main_query_method
|
22
|
+
define_method(main_query_method) do |*params, &block|
|
23
|
+
sql = params.first
|
24
|
+
current_storage.add(sql, {}, caller)
|
25
|
+
send(main_method_alias, *params, &block)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def database_class
|
31
|
+
@database_class ||= Sequel::Model.db.class
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
##
|
4
|
+
# Logs given N+1 queries
|
5
|
+
#
|
6
|
+
class N1Finder::Logger
|
7
|
+
# Logs N+1 queries
|
8
|
+
#
|
9
|
+
# @param [Array[N1Finder::N1Query]] n1_queries
|
10
|
+
#
|
11
|
+
# @return [void]
|
12
|
+
def log(n1_queries)
|
13
|
+
n1_queries.each do |n1_query|
|
14
|
+
logger.debug("\n" + message(n1_query) + "\n")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def logger
|
21
|
+
@logger ||= N1Finder.logger
|
22
|
+
end
|
23
|
+
|
24
|
+
def message(n1_query)
|
25
|
+
title = formatted('N+1 QUERY DETECTED:')
|
26
|
+
query_title = formatted('QUERY:')
|
27
|
+
line_title = formatted('LINE:')
|
28
|
+
count_title = formatted('QUERIES COUNT:')
|
29
|
+
queries_title = formatted('ORIGINAL QUERIES:')
|
30
|
+
|
31
|
+
<<-MESSAGE.gsub(' ' * 6, '')
|
32
|
+
#{title}
|
33
|
+
#{query_title} #{n1_query.query}
|
34
|
+
#{line_title} #{n1_query.line}
|
35
|
+
#{count_title} #{n1_query.original_queries.count}
|
36
|
+
#{queries_title}
|
37
|
+
#{n1_query.original_queries.join("\n ")}
|
38
|
+
MESSAGE
|
39
|
+
end
|
40
|
+
|
41
|
+
def formatted(string)
|
42
|
+
colorize? ? colored(string) : string
|
43
|
+
end
|
44
|
+
|
45
|
+
def colorize?
|
46
|
+
log_filename = logger.instance_variable_get(:@logdev).filename
|
47
|
+
log_filename.nil? || log_filename == 'STDOUT'
|
48
|
+
end
|
49
|
+
|
50
|
+
def colored(string)
|
51
|
+
color = "\e[33m" # yellow
|
52
|
+
reset = "\e[0m"
|
53
|
+
|
54
|
+
"#{color}#{string}#{reset}"
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
##
|
2
|
+
# Middleware that can be used in any rake application to find N+1 queries
|
3
|
+
#
|
4
|
+
class N1Finder::Middleware
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
end
|
8
|
+
|
9
|
+
# Wrap app call to `N1Finder.find` method
|
10
|
+
def call(env)
|
11
|
+
N1Finder.find { @app.call(env) }
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
##
|
2
|
+
# N+1 queries representation
|
3
|
+
#
|
4
|
+
class N1Finder::N1Query
|
5
|
+
# @!attribute [r] query
|
6
|
+
# Query with masked ids
|
7
|
+
# @!attribute [r] line
|
8
|
+
# Line where queries were executed in application
|
9
|
+
# @!attribute [r] original_queries
|
10
|
+
# All similar (N+1) original queries strings
|
11
|
+
attr_reader :query, :line, :original_queries
|
12
|
+
|
13
|
+
# A new instance of N1Finder::N1Query query initialized by similar queries
|
14
|
+
#
|
15
|
+
# @param [Array<N1Finder::Query>] queries
|
16
|
+
def initialize(queries)
|
17
|
+
@query = queries.first.footprint[:query]
|
18
|
+
@line = queries.first.footprint[:line]
|
19
|
+
@original_queries = queries.map(&:query)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Generates N1Finder::N1Query from array of N1Finder::Query
|
23
|
+
#
|
24
|
+
# @param [Array<N1Finder::Query>] queries
|
25
|
+
#
|
26
|
+
# @return [Array<N1Finder::N1Query>] queries that have N+1 vulnerability
|
27
|
+
def self.generate_by(queries)
|
28
|
+
grouped_queries = queries.group_by(&:footprint)
|
29
|
+
|
30
|
+
n1_grouped_queries = grouped_queries.select do |_, similar_queries|
|
31
|
+
similar_queries.count > 1
|
32
|
+
end
|
33
|
+
|
34
|
+
n1_grouped_queries.map do |_, similar_queries|
|
35
|
+
new(similar_queries)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|