shiba 0.2.3 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/.travis.yml +11 -2
- data/Gemfile +1 -2
- data/Gemfile.lock +4 -2
- data/README.md +1 -1
- data/bin/explain +10 -41
- data/bin/mysql_dump_stats +20 -0
- data/bin/postgres_dump_stats +3 -0
- data/bin/review +181 -0
- data/bin/shiba +3 -3
- data/lib/shiba.rb +65 -4
- data/lib/shiba/activerecord_integration.rb +30 -13
- data/lib/shiba/checker.rb +89 -25
- data/lib/shiba/configure.rb +22 -5
- data/lib/shiba/connection.rb +25 -0
- data/lib/shiba/connection/mysql.rb +45 -0
- data/lib/shiba/connection/postgres.rb +91 -0
- data/lib/shiba/diff.rb +21 -11
- data/lib/shiba/explain.rb +18 -53
- data/lib/shiba/explain/mysql_explain.rb +47 -0
- data/lib/shiba/explain/postgres_explain.rb +91 -0
- data/lib/shiba/explain/postgres_explain_index_conditions.rb +137 -0
- data/lib/shiba/fuzzer.rb +16 -16
- data/lib/shiba/index_stats.rb +9 -5
- data/lib/shiba/output.rb +1 -1
- data/lib/shiba/output/tags.yaml +14 -8
- data/lib/shiba/query_watcher.rb +13 -1
- data/lib/shiba/review/api.rb +100 -0
- data/lib/shiba/review/comment_renderer.rb +62 -0
- data/lib/shiba/reviewer.rb +136 -0
- data/lib/shiba/version.rb +1 -1
- data/shiba.gemspec +2 -0
- data/web/dist/bundle.js +23 -1
- data/web/main.css +3 -0
- data/web/main.js +1 -0
- data/web/package-lock.json +5 -0
- data/web/package.json +1 -0
- data/web/results.html.erb +77 -20
- metadata +43 -5
- data/bin/check +0 -75
- data/bin/dump_stats +0 -44
data/lib/shiba/diff.rb
CHANGED
@@ -12,23 +12,28 @@ module Shiba
|
|
12
12
|
# The position value equals the number of lines down from the first "@@" hunk header
|
13
13
|
# in the file you want to add a comment.
|
14
14
|
|
15
|
-
# diff = `git diff --unified=0`
|
16
|
-
# parse_diff(StringIO.new(diff))
|
17
|
-
# => "hello.rb:1"
|
18
|
-
# => "hello.rb:2"
|
19
|
-
# => "test.rb:5"
|
20
|
-
|
21
|
-
# For simplicity, the default output of git diff is not supported.
|
22
|
-
# The expected format is from 'git diff unified=0'
|
23
|
-
|
24
15
|
attr_reader :status
|
25
16
|
|
26
17
|
def initialize(file)
|
18
|
+
# Fixme. seems like enumerables should work in general.
|
19
|
+
if !file.respond_to?(:pos)
|
20
|
+
raise StandardError.new("Diff file does not appear to be a seekable IO object.")
|
21
|
+
end
|
27
22
|
@diff = file
|
28
23
|
@status = :new
|
29
24
|
end
|
30
25
|
|
31
26
|
# Returns the file and line numbers that contain inserts. Deletions are ignored.
|
27
|
+
# For simplicity, the default output of git diff is not supported.
|
28
|
+
# The expected format is from 'git diff unified=0'
|
29
|
+
#
|
30
|
+
# Example:
|
31
|
+
# diff = `git diff --unified=0`
|
32
|
+
# Diff.new(StringIO.new(diff))
|
33
|
+
# => [ [ "hello.rb", 1..3 ]
|
34
|
+
# => [ "hello.rb", 7..7 ]
|
35
|
+
# => [ "test.rb", 23..23 ]
|
36
|
+
# => ]
|
32
37
|
def updated_lines
|
33
38
|
io = @diff.each_line
|
34
39
|
path = nil
|
@@ -53,6 +58,11 @@ module Shiba
|
|
53
58
|
# Returns the position in the diff, after the relevant file header,
|
54
59
|
# that contains the specified file/lineno modification.
|
55
60
|
# Only supports finding the position in the destination / newest version of the file.
|
61
|
+
#
|
62
|
+
# Example:
|
63
|
+
# diff = Diff.new(`git diff`)
|
64
|
+
# diff.find_position("test.rb", 3)
|
65
|
+
# => 5
|
56
66
|
def find_position(path, line_number)
|
57
67
|
io = @diff.each_line # maybe redundant?
|
58
68
|
|
@@ -101,11 +111,11 @@ module Shiba
|
|
101
111
|
end
|
102
112
|
|
103
113
|
def file_header?(line)
|
104
|
-
line
|
114
|
+
line =~ FILE_PATTERN
|
105
115
|
end
|
106
116
|
|
107
117
|
def hunk_header?(line)
|
108
|
-
line
|
118
|
+
LINE_PATTERN =~ line
|
109
119
|
end
|
110
120
|
|
111
121
|
def line_numbers_for_destination(diff_line)
|
data/lib/shiba/explain.rb
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
require 'json'
|
2
2
|
require 'shiba/index'
|
3
|
+
require 'shiba/explain/mysql_explain'
|
4
|
+
require 'shiba/explain/postgres_explain'
|
3
5
|
|
4
6
|
module Shiba
|
5
7
|
class Explain
|
@@ -12,9 +14,13 @@ module Shiba
|
|
12
14
|
end
|
13
15
|
|
14
16
|
@options = options
|
15
|
-
|
16
|
-
|
17
|
-
|
17
|
+
@explain_json = Shiba.connection.explain(@sql)
|
18
|
+
|
19
|
+
if Shiba.connection.mysql?
|
20
|
+
@rows = Shiba::Explain::MysqlExplain.new.transform_json(@explain_json['query_block'])
|
21
|
+
else
|
22
|
+
@rows = Shiba::Explain::PostgresExplain.new(@explain_json).transform
|
23
|
+
end
|
18
24
|
@stats = stats
|
19
25
|
run_checks!
|
20
26
|
end
|
@@ -47,48 +53,6 @@ module Shiba
|
|
47
53
|
table
|
48
54
|
end
|
49
55
|
|
50
|
-
def self.transform_table(table, extra = {})
|
51
|
-
t = table
|
52
|
-
res = {}
|
53
|
-
res['table'] = t['table_name']
|
54
|
-
res['access_type'] = t['access_type']
|
55
|
-
res['key'] = t['key']
|
56
|
-
res['used_key_parts'] = t['used_key_parts'] if t['used_key_parts']
|
57
|
-
res['rows'] = t['rows_examined_per_scan']
|
58
|
-
res['filtered'] = t['filtered']
|
59
|
-
|
60
|
-
if t['possible_keys'] && t['possible_keys'] != [res['key']]
|
61
|
-
res['possible_keys'] = t['possible_keys']
|
62
|
-
end
|
63
|
-
res['using_index'] = t['using_index'] if t['using_index']
|
64
|
-
|
65
|
-
res.merge!(extra)
|
66
|
-
|
67
|
-
res
|
68
|
-
end
|
69
|
-
|
70
|
-
def self.transform_json(json, res = [], extra = {})
|
71
|
-
rows = []
|
72
|
-
|
73
|
-
if (ordering = json['ordering_operation'])
|
74
|
-
index_walk = (ordering['using_filesort'] == false)
|
75
|
-
return transform_json(json['ordering_operation'], res, { "index_walk" => index_walk } )
|
76
|
-
elsif json['duplicates_removal']
|
77
|
-
return transform_json(json['duplicates_removal'], res, extra)
|
78
|
-
elsif json['grouping_operation']
|
79
|
-
return transform_json(json['grouping_operation'], res, extra)
|
80
|
-
elsif !json['nested_loop'] && !json['table']
|
81
|
-
return [{'Extra' => json['message']}]
|
82
|
-
elsif json['nested_loop']
|
83
|
-
json['nested_loop'].map do |nested|
|
84
|
-
transform_json(nested, res, extra)
|
85
|
-
end
|
86
|
-
elsif json['table']
|
87
|
-
res << transform_table(json['table'], extra)
|
88
|
-
end
|
89
|
-
res
|
90
|
-
end
|
91
|
-
|
92
56
|
# [{"id"=>1, "select_type"=>"SIMPLE", "table"=>"interwiki", "partitions"=>nil, "type"=>"const", "possible_keys"=>"PRIMARY", "key"=>"PRIMARY", "key_len"=>"34", "ref"=>"const", "rows"=>1, "filtered"=>100.0, "Extra"=>nil}]
|
93
57
|
attr_reader :cost
|
94
58
|
|
@@ -143,7 +107,8 @@ module Shiba
|
|
143
107
|
|
144
108
|
# TODO: need to parse SQL here I think
|
145
109
|
def simple_table_scan?
|
146
|
-
@rows.size == 1 &&
|
110
|
+
@rows.size == 1 && (@sql !~ /order by/i) &&
|
111
|
+
(first['using_index'] || !(@sql =~ /\s+WHERE\s+/i))
|
147
112
|
end
|
148
113
|
|
149
114
|
def severity
|
@@ -216,15 +181,14 @@ module Shiba
|
|
216
181
|
messages << "fuzzed_data" if fuzzed?(first_table)
|
217
182
|
end
|
218
183
|
|
184
|
+
# TODO: we don't catch some cases like SELECT * from foo where index_col = 1 limit 1
|
185
|
+
# bcs we really just need to parse the SQL.
|
219
186
|
check :check_simple_table_scan
|
220
187
|
def check_simple_table_scan
|
221
188
|
if simple_table_scan?
|
222
189
|
if limit
|
223
|
-
messages << '
|
190
|
+
messages << 'limited_scan'
|
224
191
|
@cost = limit
|
225
|
-
else
|
226
|
-
tag_query_type
|
227
|
-
@cost = @stats.estimate_key(first_table, first_key, first['used_key_parts'])
|
228
192
|
end
|
229
193
|
end
|
230
194
|
end
|
@@ -349,9 +313,10 @@ module Shiba
|
|
349
313
|
end
|
350
314
|
|
351
315
|
def humanized_explain
|
352
|
-
h = @explain_json['query_block'].dup
|
353
|
-
|
354
|
-
h
|
316
|
+
#h = @explain_json['query_block'].dup
|
317
|
+
#%w(select_id cost_info).each { |i| h.delete(i) }
|
318
|
+
#h
|
319
|
+
@explain_json
|
355
320
|
end
|
356
321
|
end
|
357
322
|
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Shiba
|
2
|
+
class Explain
|
3
|
+
class MysqlExplain
|
4
|
+
def transform_table(table, extra = {})
|
5
|
+
t = table
|
6
|
+
res = {}
|
7
|
+
res['table'] = t['table_name']
|
8
|
+
res['access_type'] = t['access_type']
|
9
|
+
res['key'] = t['key']
|
10
|
+
res['used_key_parts'] = t['used_key_parts'] if t['used_key_parts']
|
11
|
+
res['rows'] = t['rows_examined_per_scan']
|
12
|
+
res['filtered'] = t['filtered']
|
13
|
+
|
14
|
+
if t['possible_keys'] && t['possible_keys'] != [res['key']]
|
15
|
+
res['possible_keys'] = t['possible_keys']
|
16
|
+
end
|
17
|
+
res['using_index'] = t['using_index'] if t['using_index']
|
18
|
+
|
19
|
+
res.merge!(extra)
|
20
|
+
|
21
|
+
res
|
22
|
+
end
|
23
|
+
|
24
|
+
def transform_json(json, res = [], extra = {})
|
25
|
+
rows = []
|
26
|
+
|
27
|
+
if (ordering = json['ordering_operation'])
|
28
|
+
index_walk = (ordering['using_filesort'] == false)
|
29
|
+
return transform_json(json['ordering_operation'], res, { "index_walk" => index_walk } )
|
30
|
+
elsif json['duplicates_removal']
|
31
|
+
return transform_json(json['duplicates_removal'], res, extra)
|
32
|
+
elsif json['grouping_operation']
|
33
|
+
return transform_json(json['grouping_operation'], res, extra)
|
34
|
+
elsif !json['nested_loop'] && !json['table']
|
35
|
+
return [{'Extra' => json['message']}]
|
36
|
+
elsif json['nested_loop']
|
37
|
+
json['nested_loop'].map do |nested|
|
38
|
+
transform_json(nested, res, extra)
|
39
|
+
end
|
40
|
+
elsif json['table']
|
41
|
+
res << transform_table(json['table'], extra)
|
42
|
+
end
|
43
|
+
res
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
require 'shiba/explain/postgres_explain_index_conditions'
|
2
|
+
|
3
|
+
module Shiba
|
4
|
+
class Explain
|
5
|
+
class PostgresExplain
|
6
|
+
def initialize(json)
|
7
|
+
@json = json
|
8
|
+
@state = {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def with_state(hash)
|
12
|
+
old_state = @state
|
13
|
+
@state = @state.merge(hash)
|
14
|
+
yield
|
15
|
+
@state = old_state
|
16
|
+
end
|
17
|
+
|
18
|
+
def transform_node(node, array)
|
19
|
+
case node['Node Type']
|
20
|
+
when "Limit", "LockRows", "Aggregate", "Unique", "Sort", "Hash", "ProjectSet"
|
21
|
+
recurse_plans(node, array)
|
22
|
+
when "Nested Loop"
|
23
|
+
with_state(join_type: node["Join Type"]) do
|
24
|
+
recurse_plans(node, array)
|
25
|
+
end
|
26
|
+
when "Hash Join"
|
27
|
+
join_fields = extract_join_key_parts(node['Hash Cond'])
|
28
|
+
with_state(join_fields: join_fields, join_type: "Hash") do
|
29
|
+
recurse_plans(node, array)
|
30
|
+
end
|
31
|
+
when "Bitmap Heap Scan"
|
32
|
+
with_state(table: node['Relation Name']) do
|
33
|
+
recurse_plans(node, array)
|
34
|
+
end
|
35
|
+
when "Seq Scan"
|
36
|
+
array << {
|
37
|
+
"table" => node["Relation Name"],
|
38
|
+
"access_type" => "ALL",
|
39
|
+
"key" => nil,
|
40
|
+
"filter" => node["Filter"]
|
41
|
+
}
|
42
|
+
when "Index Scan", "Bitmap Index Scan", "Index Only Scan"
|
43
|
+
table = node["Relation Name"] || @state[:table]
|
44
|
+
|
45
|
+
if node['Index Cond']
|
46
|
+
used_key_parts = extract_used_key_parts(node['Index Cond'])
|
47
|
+
else
|
48
|
+
used_key_parts = []
|
49
|
+
end
|
50
|
+
|
51
|
+
h = {
|
52
|
+
"table" => node["Relation Name"] || @state[:table],
|
53
|
+
"access_type" => "ref",
|
54
|
+
"key" => node["Index Name"],
|
55
|
+
"used_key_parts" => used_key_parts
|
56
|
+
}
|
57
|
+
|
58
|
+
if node['Node Type'] == "Index Only Scan"
|
59
|
+
h['using_index'] = true
|
60
|
+
end
|
61
|
+
|
62
|
+
array << h
|
63
|
+
else
|
64
|
+
raise "unhandled node: #{node}"
|
65
|
+
end
|
66
|
+
array
|
67
|
+
end
|
68
|
+
|
69
|
+
def extract_used_key_parts(cond)
|
70
|
+
conds = PostgresExplainIndexConditions.new(cond)
|
71
|
+
conds.fields
|
72
|
+
end
|
73
|
+
|
74
|
+
def extract_join_key_parts(cond)
|
75
|
+
conds = PostgresExplainIndexConditions.new(cond)
|
76
|
+
conds.join_fields
|
77
|
+
end
|
78
|
+
|
79
|
+
def recurse_plans(node, array)
|
80
|
+
node['Plans'].each do |n|
|
81
|
+
transform_node(n, array)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def transform
|
86
|
+
plan = @json.first['Plan']
|
87
|
+
transform_node(plan, [])
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
require 'strscan'
|
2
|
+
|
3
|
+
module Shiba
|
4
|
+
class Explain
|
5
|
+
class PostgresExplainIndexConditions
|
6
|
+
def initialize(string)
|
7
|
+
@string = string
|
8
|
+
@sc = StringScanner.new(string)
|
9
|
+
@fields = nil
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :sc
|
13
|
+
def parse!
|
14
|
+
return if @fields
|
15
|
+
@fields = {}
|
16
|
+
sc.scan(LPAREN)
|
17
|
+
if sc.peek(1) == "(" && !sc.match?(/\(\w+\)::/)
|
18
|
+
|
19
|
+
while sc.peek(1) == "("
|
20
|
+
sc.getch
|
21
|
+
extract_field(sc)
|
22
|
+
sc.scan(/\s+AND\s+/)
|
23
|
+
end
|
24
|
+
else
|
25
|
+
extract_field(sc)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def fields
|
30
|
+
parse!
|
31
|
+
@fields[nil]
|
32
|
+
end
|
33
|
+
|
34
|
+
def join_fields
|
35
|
+
parse!
|
36
|
+
@fields
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
LPAREN = /\(/
|
41
|
+
RPAREN = /\)/
|
42
|
+
|
43
|
+
def parse_string(sc)
|
44
|
+
v = ""
|
45
|
+
qchar = sc.getch
|
46
|
+
double_quote = qchar * 2
|
47
|
+
while true
|
48
|
+
if sc.peek(1) == qchar
|
49
|
+
if sc.peek(2) == double_quote
|
50
|
+
sc.scan(/#{double_quote}/)
|
51
|
+
else
|
52
|
+
# end of string
|
53
|
+
sc.getch
|
54
|
+
# optional type hint
|
55
|
+
sc.scan(/::\w+(\[\])?/)
|
56
|
+
return v
|
57
|
+
end
|
58
|
+
end
|
59
|
+
v += sc.getch
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def parse_value(sc)
|
64
|
+
peek = sc.peek(1)
|
65
|
+
if peek == "'"
|
66
|
+
parse_string(sc)
|
67
|
+
elsif peek == '"'
|
68
|
+
parse_field(sc)
|
69
|
+
elsif (v = sc.scan(/\d+\.?\d*/))
|
70
|
+
if v.include?('.')
|
71
|
+
v.to_f
|
72
|
+
else
|
73
|
+
v.to_i
|
74
|
+
end
|
75
|
+
elsif sc.scan(/ANY \(/)
|
76
|
+
# parse as string
|
77
|
+
v = parse_value(sc)
|
78
|
+
sc.scan(/\)/)
|
79
|
+
v
|
80
|
+
else
|
81
|
+
parse_field(sc)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def parse_ident(sc)
|
86
|
+
peek = sc.peek(1)
|
87
|
+
if peek == "("
|
88
|
+
sc.getch
|
89
|
+
# typed column like (name)::text = 'ben'
|
90
|
+
ident = sc.scan(/[^\)]+/)
|
91
|
+
sc.scan(/\)::\S+/)
|
92
|
+
elsif peek == '"'
|
93
|
+
ident = parse_string(sc)
|
94
|
+
else
|
95
|
+
ident = sc.scan(/[^ \.\)\[]+/)
|
96
|
+
# field[1] for array fields, not bothering to do brace matching here yet, oy vey
|
97
|
+
sc.scan(/\[.*?\]/)
|
98
|
+
end
|
99
|
+
ident
|
100
|
+
end
|
101
|
+
|
102
|
+
def parse_field(sc)
|
103
|
+
first = nil
|
104
|
+
second = nil
|
105
|
+
|
106
|
+
first = parse_ident(sc)
|
107
|
+
if sc.scan(/\./)
|
108
|
+
second = parse_ident(sc)
|
109
|
+
table = first
|
110
|
+
field = second
|
111
|
+
else
|
112
|
+
table = nil
|
113
|
+
field = first
|
114
|
+
end
|
115
|
+
|
116
|
+
@fields[table] ||= []
|
117
|
+
@fields[table] << field unless @fields[table].include?(field)
|
118
|
+
end
|
119
|
+
|
120
|
+
|
121
|
+
def extract_field(sc)
|
122
|
+
# (type = 1)
|
123
|
+
# ((type)::text = 1)
|
124
|
+
# (((type)::text = ANY ('{User,AnonymousUser}'::text[])) AND ((type)::text = 'User'::text))
|
125
|
+
table = nil
|
126
|
+
|
127
|
+
parse_field(sc)
|
128
|
+
sc.scan(/\s+\S+\s+/) # operator
|
129
|
+
parse_value(sc)
|
130
|
+
|
131
|
+
if sc.scan(RPAREN).nil?
|
132
|
+
raise "bad scan; #{sc.inspect}"
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|