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
@@ -0,0 +1,57 @@
1
+ ##
2
+ # Query representation
3
+ #
4
+ class N1Finder::Query
5
+ # Regular expression to find numeric ids in queries and in params
6
+ ID = /(?<==\s)\d+/
7
+
8
+ # Regular expression to find UUIDs in queries and in params
9
+ UUID = /(?<==\s)(['"])\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\1/
10
+
11
+ # @!attribute [r] query
12
+ # Original query string combined with params
13
+ # @!attribute [backtrace] line
14
+ # Backtrace up to function where we cought this query
15
+ attr_reader :query, :backtrace
16
+
17
+ # @param [String] query
18
+ # @param [Hash] params
19
+ # @param [Array<String>] backtrace
20
+ def initialize(query, params, backtrace)
21
+ @query = self.class.query_with_params(query, params)
22
+ @backtrace = backtrace
23
+ end
24
+
25
+ # Generates query footprint
26
+ # Footprint consists of
27
+ # - query with masked ids
28
+ # - line of code where this query was executed
29
+ #
30
+ # @return [ {query, line} => String ]
31
+ def footprint
32
+ @footprint ||= { query: query_footprint, line: application_line }
33
+ end
34
+
35
+ # Combines query and its params to readable format
36
+ #
37
+ # @param [String] query
38
+ # @param [Hash] params
39
+ #
40
+ # @return [String]
41
+ def self.query_with_params(query, params)
42
+ params.map do |key, value|
43
+ value = "'#{value}'" if value.is_a?(String)
44
+ "#{key} = #{value}"
45
+ end.unshift(query).join(', ')
46
+ end
47
+
48
+ private
49
+
50
+ def query_footprint
51
+ @query_footprint ||= query.gsub(UUID, '[uuid]').gsub(ID, '[id]')
52
+ end
53
+
54
+ def application_line
55
+ @application_line ||= backtrace.find { |line| !line.include?('/gems/') }
56
+ end
57
+ end
@@ -0,0 +1,23 @@
1
+ ##
2
+ # Storage for queries
3
+ #
4
+ class N1Finder::Storage
5
+ # Stored queries. Default is `[]`.
6
+ #
7
+ # @return [Array[N1Finder::Query]]
8
+ attr_reader :queries
9
+
10
+ def initialize
11
+ @queries = []
12
+ end
13
+
14
+ # Adds query to storage
15
+ #
16
+ # @param [String] query the sql query
17
+ # @param [Array[String]] backtrace the backtrace up to query execution
18
+ #
19
+ # @return [void]
20
+ def add(query, params, backtrace)
21
+ queries << N1Finder::Query.new(query, params, backtrace)
22
+ end
23
+ end
@@ -0,0 +1,4 @@
1
+ class N1Finder
2
+ # Gem version
3
+ VERSION = '0.0.2'.freeze
4
+ end
@@ -0,0 +1,37 @@
1
+ require 'shared_examples/adapter'
2
+
3
+ RSpec.describe N1Finder::Adapters::ActiveRecordAdapter do
4
+ it_behaves_like 'adapter'
5
+
6
+ describe '#find_sql_params' do
7
+ subject { described_class.find_sql_params(params) }
8
+
9
+ context 'with empty params' do
10
+ let(:params) { [] }
11
+ it 'returns empty hash' do
12
+ expect(subject).to eq({})
13
+ end
14
+ end
15
+
16
+ context 'without sql params' do
17
+ let(:params) { ['some sql', { some: 'hash' }, [%w(some array)]] }
18
+ it 'returns empty hash' do
19
+ expect(subject).to eq({})
20
+ end
21
+ end
22
+
23
+ context 'without sql params' do
24
+ let(:params) do
25
+ [
26
+ [
27
+ [ActiveRecord::ConnectionAdapters::Column.new(:first_name, nil, nil), 'jane'],
28
+ [ActiveRecord::ConnectionAdapters::Column.new(:last_name, nil, nil), 'doe']
29
+ ]
30
+ ]
31
+ end
32
+ it 'returns empty hash' do
33
+ expect(subject).to eq(first_name: 'jane', last_name: 'doe')
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ require 'shared_examples/adapter'
2
+
3
+ RSpec.describe N1Finder::Adapters::NullAdapter do
4
+ it_behaves_like 'adapter'
5
+ end
@@ -0,0 +1,5 @@
1
+ require 'shared_examples/adapter'
2
+
3
+ RSpec.describe N1Finder::Adapters::SequelAdapter do
4
+ it_behaves_like 'adapter'
5
+ end
@@ -0,0 +1,12 @@
1
+ RSpec.describe N1Finder do
2
+ describe 'Using activerecord and mysql', type: :active_record_mysql do
3
+ before { populate_database }
4
+
5
+ subject { described_class.find(&n_1_heavy_block) }
6
+
7
+ it 'logs all N+1 queries' do
8
+ expect(described_class.logger).to receive(:debug).exactly(3).times
9
+ subject
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ RSpec.describe N1Finder do
2
+ describe 'Using activerecord and postgresql', type: :active_record_pg do
3
+ before { populate_database }
4
+
5
+ subject { described_class.find(&n_1_heavy_block) }
6
+
7
+ it 'logs all N+1 queries' do
8
+ expect(described_class.logger).to receive(:debug).exactly(3).times
9
+ subject
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ RSpec.describe N1Finder do
2
+ describe 'Using activerecord and sqlite', type: :active_record_sqlite do
3
+ before { populate_database }
4
+
5
+ subject { described_class.find(&n_1_heavy_block) }
6
+
7
+ it 'logs all N+1 queries' do
8
+ expect(described_class.logger).to receive(:debug).exactly(3).times
9
+ subject
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ RSpec.describe N1Finder do
2
+ describe 'Using sequel and mysql', type: :sequel_mysql do
3
+ before { populate_database }
4
+
5
+ subject { described_class.find(&n_1_heavy_block) }
6
+
7
+ it 'logs all N+1 queries' do
8
+ expect(described_class.logger).to receive(:debug).exactly(3).times
9
+ subject
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ RSpec.describe N1Finder do
2
+ describe 'Using sequel and postgres', type: :sequel_pg do
3
+ before { populate_database }
4
+
5
+ subject { described_class.find(&n_1_heavy_block) }
6
+
7
+ it 'logs all N+1 queries' do
8
+ expect(described_class.logger).to receive(:debug).exactly(3).times
9
+
10
+ subject
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ RSpec.describe N1Finder do
2
+ describe 'Using sequel and sqlite', type: :sequel_sqlite do
3
+ before { populate_database }
4
+
5
+ subject { described_class.find(&n_1_heavy_block) }
6
+
7
+ it 'logs all N+1 queries' do
8
+ expect(described_class.logger).to receive(:debug).exactly(3).times
9
+ subject
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,40 @@
1
+ RSpec.describe N1Finder::Logger do
2
+ let(:service) { described_class.new }
3
+ let(:n1_queries) do
4
+ [
5
+ instance_double(
6
+ N1Finder::N1Query,
7
+ line: 'line',
8
+ query: 'query',
9
+ original_queries: %w(one two)
10
+ )
11
+ ]
12
+ end
13
+
14
+ let(:logger) { Logger.new(STDOUT) }
15
+ before { allow(service).to receive(:logger).and_return logger }
16
+
17
+ describe '#log' do
18
+ subject { service.log(n1_queries) }
19
+
20
+ it 'logs line' do
21
+ expect(logger).to receive(:debug).once.with(include('line'))
22
+ subject
23
+ end
24
+
25
+ it 'logs query footprint' do
26
+ expect(logger).to receive(:debug).once.with(include('query'))
27
+ subject
28
+ end
29
+
30
+ it 'logs original_queries' do
31
+ expect(logger).to receive(:debug).once.with(include('one') && include('two'))
32
+ subject
33
+ end
34
+
35
+ it 'logs similar queries count' do
36
+ expect(logger).to receive(:debug).once.with(include(2.to_s))
37
+ subject
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ RSpec.describe N1Finder::Middleware do
2
+ let(:app) { ->(env) { env } }
3
+ let(:env) { 'OK' }
4
+ let(:service) { described_class.new(app) }
5
+
6
+ describe '#call' do
7
+ subject { service.call(env) }
8
+
9
+ it 'wraps app call into N1Finder.find method', type: :no_connection do
10
+ expect(N1Finder).to receive(:find).and_call_original
11
+
12
+ expect(subject).to eq 'OK'
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,28 @@
1
+ RSpec.describe N1Finder::N1Query do
2
+ describe 'self.generate_by' do
3
+ let(:queries) do
4
+ [
5
+ N1Finder::Query.new('SELECT * FROM posts WHERE id = ?', { id: 1 }, ['line_1']),
6
+ N1Finder::Query.new('SELECT * FROM users WHERE id = ?', { id: 1 }, ['line_2']),
7
+ N1Finder::Query.new('SELECT * FROM users WHERE id = ?', { id: 1 }, ['line_1']),
8
+ N1Finder::Query.new('SELECT * FROM posts WHERE id = ?', { id: 2 }, ['line_1'])
9
+ ]
10
+ end
11
+
12
+ subject { described_class.generate_by(queries) }
13
+
14
+ it 'returns n+1 queries' do
15
+ expect(subject).to be_an Array
16
+ expect(subject.count).to eq 1
17
+ expect(subject.first).to be_an described_class
18
+ end
19
+
20
+ it 'sets attributes to n+1 queries' do
21
+ n1_query = subject.first
22
+ expect(n1_query.query).to eq 'SELECT * FROM posts WHERE id = ?, id = [id]'
23
+ expect(n1_query.line).to eq 'line_1'
24
+ expect(n1_query.original_queries[0]).to eq 'SELECT * FROM posts WHERE id = ?, id = 1'
25
+ expect(n1_query.original_queries[1]).to eq 'SELECT * FROM posts WHERE id = ?, id = 2'
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,81 @@
1
+ require 'securerandom'
2
+
3
+ RSpec.describe N1Finder::Query do
4
+ let(:service) { described_class.new(query, params, backtrace) }
5
+ let(:query) { nil }
6
+ let(:params) { {} }
7
+ let(:backtrace) { nil }
8
+ let(:id) { rand(1..100_000) }
9
+ let(:uuid) { SecureRandom.uuid }
10
+
11
+ describe '#query_footprint' do
12
+ subject { service.send(:query_footprint) }
13
+
14
+ context 'with id' do
15
+ let(:query) { "id = #{id}" }
16
+ it 'replaces id to mask' do
17
+ expect(subject).to eq 'id = [id]'
18
+ end
19
+ end
20
+
21
+ context 'with uuid' do
22
+ let(:query) { "id = '#{SecureRandom.uuid}'" }
23
+ it 'replaces id to mask' do
24
+ expect(subject).to eq 'id = [uuid]'
25
+ end
26
+ end
27
+
28
+ context 'without id or uuid' do
29
+ let(:query) { 'SELECT * FROM posts' }
30
+ it 'returns query without changes' do
31
+ expect(subject).to eq query
32
+ end
33
+ end
34
+
35
+ context 'with both id and uuid' do
36
+ let(:query) { "id = '#{SecureRandom.uuid}' AND author_id = #{id}" }
37
+ it 'replaces uuid and id to mask' do
38
+ expect(subject).to eq 'id = [uuid] AND author_id = [id]'
39
+ end
40
+ end
41
+
42
+ context 'with number in param' do
43
+ let(:query) { "id1 = #{id}" }
44
+ it 'not changes param name' do
45
+ expect(subject).to eq 'id1 = [id]'
46
+ end
47
+ end
48
+ end
49
+
50
+ describe '#application_line' do
51
+ subject { service.send(:application_line) }
52
+ let(:backtrace) do
53
+ [
54
+ '../gems/..',
55
+ '../app/file1.rb/..',
56
+ '../app/file2.rb/..',
57
+ '../gems/..'
58
+ ]
59
+ end
60
+ it 'finds latest app backtrace line' do
61
+ expect(subject).to eq '../app/file1.rb/..'
62
+ end
63
+ end
64
+
65
+ describe '#footprint' do
66
+ subject { service.footprint }
67
+ let(:query) { "SELECT * FROM posts WHERE id = #{id}" }
68
+ let(:backtrace) do
69
+ [
70
+ '../gems/..',
71
+ '../app/file1.rb/..',
72
+ '../app/file2.rb/..',
73
+ '../gems/..'
74
+ ]
75
+ end
76
+ it 'returns array of query footprint and application line' do
77
+ expect(subject[:query]).to eq 'SELECT * FROM posts WHERE id = [id]'
78
+ expect(subject[:line]).to eq '../app/file1.rb/..'
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,23 @@
1
+ RSpec.describe N1Finder::Storage do
2
+ let(:service) { described_class.new }
3
+
4
+ describe '#queries' do
5
+ subject { service.queries }
6
+
7
+ it 'returns an Array' do
8
+ expect(subject).to be_an Array
9
+ end
10
+ end
11
+
12
+ describe '#add' do
13
+ subject { service.add(query, params, backtrace) }
14
+
15
+ let(:query) { 'query' }
16
+ let(:params) { {} }
17
+ let(:backtrace) { [] }
18
+
19
+ it 'adds query to queries collection' do
20
+ expect { subject }.to change { service.queries.count }.by 1
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,67 @@
1
+ RSpec.describe N1Finder do
2
+ describe 'self.find', type: :no_connection do
3
+ let(:block) { -> { 'OK' } }
4
+ let(:storage) { N1Finder::Storage.new }
5
+ let(:logger) { instance_double(N1Finder::Logger, log: nil) }
6
+ let(:n1_queries) { 'N1_QUERIES' }
7
+
8
+ before do
9
+ allow(N1Finder::Storage).to receive(:new).and_return(storage)
10
+ allow(N1Finder::Logger).to receive(:new).and_return(logger)
11
+ allow(N1Finder::N1Query).to \
12
+ receive(:generate_by).with(storage.queries).and_return(n1_queries)
13
+ end
14
+
15
+ subject { described_class.find(&block) }
16
+
17
+ it 'returns a block execution result' do
18
+ expect(subject).to eq 'OK'
19
+ end
20
+
21
+ it 'finds queries in block' do
22
+ expect(described_class).to receive(:catch_queries).with(storage)
23
+ subject
24
+ end
25
+
26
+ it 'logs n+1 queries' do
27
+ expect(logger).to receive(:log).with(n1_queries)
28
+ subject
29
+ end
30
+ end
31
+
32
+ describe '#logger=' do
33
+ subject { described_class.logger = logger }
34
+ context 'when logger is a Logger' do
35
+ let(:logger) { Logger.new(STDOUT) }
36
+ it 'sets a logger' do
37
+ subject
38
+ expect(described_class.logger).to eq logger
39
+ end
40
+ end
41
+
42
+ context 'when logger is not a Logger' do
43
+ let(:logger) { nil }
44
+ it 'raises error' do
45
+ expect { subject }.to raise_error described_class::InvalidLogger
46
+ end
47
+ end
48
+ end
49
+
50
+ describe '#orm=' do
51
+ subject { described_class.orm = orm }
52
+ context 'when provided supported orm' do
53
+ let(:orm) { described_class::ORM_ADAPTERS.keys.sample }
54
+ it 'sets orm' do
55
+ subject
56
+ expect(described_class.orm).to eq orm
57
+ end
58
+ end
59
+
60
+ context 'when orm is not supported' do
61
+ let(:orm) { :mongo }
62
+ it 'raises error' do
63
+ expect { subject }.to raise_error N1Finder::InvalidORM
64
+ end
65
+ end
66
+ end
67
+ end