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.
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