n_1_finder 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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
|