shiba 0.2.3 → 0.3.0
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 +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
|