n_1_finder 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/Readme.md +74 -0
  3. data/lib/n_1_finder.rb +111 -0
  4. data/lib/n_1_finder/adapters/active_record_adapter.rb +49 -0
  5. data/lib/n_1_finder/adapters/base_adapter.rb +46 -0
  6. data/lib/n_1_finder/adapters/null_adapter.rb +16 -0
  7. data/lib/n_1_finder/adapters/sequel_adapter.rb +33 -0
  8. data/lib/n_1_finder/logger.rb +56 -0
  9. data/lib/n_1_finder/middleware.rb +13 -0
  10. data/lib/n_1_finder/n_1_query.rb +38 -0
  11. data/lib/n_1_finder/query.rb +57 -0
  12. data/lib/n_1_finder/storage.rb +23 -0
  13. data/lib/n_1_finder/version.rb +4 -0
  14. data/spec/n_1_finder/adapters/active_record_adapter_spec.rb +37 -0
  15. data/spec/n_1_finder/adapters/null_adapter_spec.rb +5 -0
  16. data/spec/n_1_finder/adapters/sequel_adapter_spec.rb +5 -0
  17. data/spec/n_1_finder/features/active_record_mysql_spec.rb +12 -0
  18. data/spec/n_1_finder/features/active_record_postgres_spec.rb +12 -0
  19. data/spec/n_1_finder/features/active_record_sqlite_spec.rb +12 -0
  20. data/spec/n_1_finder/features/sequel_mysql_spec.rb +12 -0
  21. data/spec/n_1_finder/features/sequel_postgres_spec.rb +13 -0
  22. data/spec/n_1_finder/features/sequel_sqlite_spec.rb +12 -0
  23. data/spec/n_1_finder/logger_spec.rb +40 -0
  24. data/spec/n_1_finder/middleware_spec.rb +15 -0
  25. data/spec/n_1_finder/n_1_query_spec.rb +28 -0
  26. data/spec/n_1_finder/query_spec.rb +81 -0
  27. data/spec/n_1_finder/storage_spec.rb +23 -0
  28. data/spec/n_1_finder_spec.rb +67 -0
  29. data/spec/secrets.yml.example +9 -0
  30. data/spec/shared_examples/adapter.rb +37 -0
  31. data/spec/spec_helper.rb +61 -0
  32. data/spec/support/n_1_helpers.rb +56 -0
  33. data/spec/support/orm_helpers.rb +89 -0
  34. data/spec/support/test_active_record_migration.rb +46 -0
  35. data/spec/support/test_active_record_models.rb +56 -0
  36. data/spec/support/test_no_connection_models.rb +57 -0
  37. data/spec/support/test_sequel_migration.rb +39 -0
  38. data/spec/support/test_sequel_models.rb +65 -0
  39. 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