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
@@ -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,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,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
|