sql_safety_net 1.1.11 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,196 +0,0 @@
1
- require 'rack'
2
-
3
- module SqlSafetyNet
4
- # This Rack handler must be added to the middleware stack in order for query analysis to be output.
5
- # If the configuration option for debug is set to true, it will add response headers indicating information
6
- # about the queries executed in the course of the request.
7
- class RackHandler
8
-
9
- X_SQL_SAFETY_NET_HEADER = "X-SqlSafetyNet".freeze
10
- HTML_CONTENT_TYPE_PATTERN = /text\/(x?)html/i
11
- XML_CONTENT_TYPE_PATTERN = /application\/xml/i
12
-
13
- def initialize(app, logger = Rails.logger)
14
- @app = app
15
- @logger = logger
16
- end
17
-
18
- def call(env)
19
- response = nil
20
- analysis = QueryAnalysis.analyze do
21
- response = @app.call(env)
22
- end
23
-
24
- if @logger && (analysis.too_many_selects? || analysis.too_many_rows?)
25
- request = Rack::Request.new(env)
26
- @logger.warn("Excess database usage: request generated #{analysis.selects} queries and returned #{analysis.rows} rows [#{request.request_method} #{request.url}]")
27
- end
28
-
29
- # Add a response header that contains a summary of the debug info
30
- if SqlSafetyNet.config.header? || SqlSafetyNet.config.debug?
31
- headers = response[1]
32
- headers[X_SQL_SAFETY_NET_HEADER] = "selects=#{analysis.selects}; rows=#{analysis.rows}; elapsed_time=#{(analysis.elapsed_time * 1000).round}; flagged_queries=#{analysis.flagged_queries.size}" if headers
33
- end
34
-
35
- if SqlSafetyNet.config.debug?
36
- wrapped_response = Rack::Response.new(response[2], response[0], response[1])
37
- if analysis.flagged? || SqlSafetyNet.config.always_show?
38
- request = Rack::Request.new(env)
39
- # Ignore Ajax calls
40
- unless request.xhr?
41
- # Only if content type is text/html
42
- type = wrapped_response.content_type
43
- if type.nil? || type.to_s.match(HTML_CONTENT_TYPE_PATTERN)
44
- wrapped_response.write(flagged_sql_html(analysis))
45
- elsif type.to_s.match(XML_CONTENT_TYPE_PATTERN)
46
- wrapped_response.write(xml_comment(flagged_sql_text(analysis)))
47
- end
48
- end
49
- end
50
- response = wrapped_response.finish
51
- end
52
-
53
- response
54
- end
55
-
56
- def flagged_sql_html(analysis)
57
- flagged_html = ''
58
- cached_selects = 0
59
- cached_rows = 0
60
- cached_elapsed_time = 0.0
61
-
62
- if analysis.flagged_queries?
63
- flagged_html << '<div style="color:#C00;">'
64
- flagged_html << "<div style=\"font-weight:bold; margin-bottom:10px;\">#{analysis.flagged_queries.size == 1 ? 'This query has' : "These #{analysis.flagged_queries.size} queries have"} flagged query plans:</div>"
65
- analysis.flagged_queries.each do |query|
66
- if query[:cached]
67
- cached_selects += 1
68
- cached_rows += query[:rows]
69
- cached_elapsed_time += query[:elapsed_time]
70
- end
71
- flagged_html << '<div style="margin-bottom:10px;">'
72
- flagged_html << "<div style=\"font-weight:bold; margin-bottom: 5px;\">#{query[:rows]} rows returned, #{(query[:elapsed_time] * 1000).round} ms#{ " <span style='color:teal;'>(CACHED)</span>" if query[:cached]}</div>"
73
- flagged_html << "<div style=\"font-weight:bold; margin-bottom: 5px;\">#{query[:flags].join(', ')}</div>"
74
- flagged_html << "<div style=\"margin-bottom: 5px;\">#{Rack::Utils.escape_html(query[:sql])}</div>"
75
- flagged_html << "<div style=\"margin-bottom: 5px;\">Query Plan: #{Rack::Utils.escape_html(query[:query_plan].inspect)}</div>" if query[:query_plan]
76
- flagged_html << '</div>'
77
- end
78
- flagged_html << '</div>'
79
- end
80
-
81
- if analysis.too_many_selects? || analysis.too_many_rows? || SqlSafetyNet.config.always_show?
82
- flagged_html << "<div style=\"font-weight:bold; margin-bottom:10px;\">#{analysis.non_flagged_queries.size == 1 ? 'This query' : "These #{analysis.non_flagged_queries.size} queries"} did not have flagged query plans:</div>"
83
- analysis.non_flagged_queries.each do |query|
84
- if query[:cached]
85
- cached_selects += 1
86
- cached_rows += query[:rows]
87
- cached_elapsed_time += query[:elapsed_time]
88
- end
89
- flagged_html << '<div style="margin-bottom:10px;">'
90
- flagged_html << "<div style=\"font-weight:bold; margin-bottom: 5px;\">#{query[:rows]} rows returned, #{(query[:elapsed_time] * 1000).round} ms#{ " <span style='color:teal;'>(CACHED)</span>" if query[:cached]}</div>"
91
- flagged_html << "<div style=\"margin-bottom: 5px;\">#{Rack::Utils.escape_html(query[:sql])}</div>"
92
- flagged_html << '</div>'
93
- end
94
- end
95
-
96
- color_scheme = '#060'
97
- if analysis.flagged_queries?
98
- color_scheme = '#C00'
99
- elsif analysis.flagged?
100
- color_scheme = '#C60'
101
- end
102
- label = (analysis.flagged?) ? 'SQL WARNING' : 'SQL INFO'
103
-
104
- cache_html = nil
105
- if cached_selects > 0
106
- cache_html = <<-EOS
107
- <div style="margin-bottom:10px; font-weight:bold;">
108
- Some of the queries will be cached.
109
- <div style="color:#C00;">
110
- Uncached: #{analysis.selects - cached_selects} selects, #{analysis.rows - cached_rows} rows, #{((analysis.elapsed_time - cached_elapsed_time) * 1000).round} ms
111
- </div>
112
- <div style="color:teal;">
113
- Cached: #{cached_selects} selects, #{cached_rows} rows, #{(cached_elapsed_time * 1000).round} ms
114
- </div>
115
- </div>
116
- EOS
117
- end
118
-
119
- <<-EOS
120
- <div id="sql_safety_net_warning" style="font-family:sans-serif; font-size:10px; position:fixed; z-index:999999999; text-align:left; #{SqlSafetyNet.config.position}">
121
- <div style="background-color:#{color_scheme}; color:#FFF; padding:4px; width:160px; float:right;">
122
- <a href="javascript:void(document.getElementById('sql_safety_net_warning').style.display = 'none')" style="text-decoration:none; float:right; display:block; font-size:9px;"><span style="color:#FFF; text-decoration:none; font-weight:bold;">&times;</span></a>
123
- <a href="javascript:void(document.getElementById('sql_safety_net_flagged_queries').style.display = (document.getElementById('sql_safety_net_flagged_queries').style.display == 'block' ? 'none' : 'block'))" style="text-decoration:none;">
124
- <span style="color:#FFF; text-decoration:none; font-weight:bold;">#{label} &raquo;</span>
125
- </a>
126
- <div>#{analysis.selects} selects, #{analysis.rows} rows, #{(analysis.elapsed_time * 1000).round} ms</div>
127
- </div>
128
- <div id="sql_safety_net_flagged_queries" style="clear:right; display:none; width:500px; padding:2px; border:1px solid #{color_scheme}; background-color:#FFF; color:#000; overflow:auto; max-height:500px;">
129
- <div style="margin-bottom:10px; font-weight:bold;">
130
- There are #{analysis.selects} queries on this page that return #{analysis.rows} rows and took #{(analysis.elapsed_time * 1000).round} ms to execute.
131
- </div>
132
- #{cache_html}
133
- #{flagged_html}
134
- </div>
135
- </div>
136
- EOS
137
- end
138
-
139
- def flagged_sql_text(analysis)
140
- flagged_text = ''
141
- cached_selects = 0
142
- cached_rows = 0
143
- cached_elapsed_time = 0.0
144
-
145
- if analysis.flagged_queries?
146
- flagged_text << "#{analysis.flagged_queries.size == 1 ? 'This query has' : "These #{analysis.flagged_queries.size} queries have"} flagged query plans:\n\n"
147
- analysis.flagged_queries.each do |query|
148
- if query[:cached]
149
- cached_selects += 1
150
- cached_rows += query[:rows]
151
- cached_elapsed_time += query[:elapsed_time]
152
- end
153
- flagged_text << "#{query[:rows]} rows returned, #{(query[:elapsed_time] * 1000).round} ms#{ " (CACHED)" if query[:cached]}\n"
154
- flagged_text << "#{query[:flags].join(', ')}\n\n"
155
- flagged_text << "#{query[:sql]}\n\n"
156
- flagged_text << "Query Plan: #{Rack::Utils.escape_html(query[:query_plan].inspect)}\n" if query[:query_plan]
157
- flagged_text << "\n"
158
- end
159
- flagged_text << "\n"
160
- end
161
-
162
- if analysis.too_many_selects? || analysis.too_many_rows? || SqlSafetyNet.config.always_show?
163
- flagged_text << "#{analysis.non_flagged_queries.size == 1 ? 'This query' : "These #{analysis.non_flagged_queries.size} queries"} did not have flagged query plans:\n\n"
164
- analysis.non_flagged_queries.each do |query|
165
- if query[:cached]
166
- cached_selects += 1
167
- cached_rows += query[:rows]
168
- cached_elapsed_time += query[:elapsed_time]
169
- end
170
- flagged_text << "#{query[:rows]} rows returned, #{(query[:elapsed_time] * 1000).round} ms#{ " (CACHED)" if query[:cached]}\n\n"
171
- flagged_text << "#{query[:sql]}\n\n"
172
- flagged_text << "\n"
173
- end
174
- end
175
-
176
- label = (analysis.flagged?) ? 'SQL WARNING' : 'SQL INFO'
177
-
178
- cache_text = ""
179
- if cached_selects > 0
180
- cache_text << "Some of the queries will be cached.\n\n"
181
- cache_text << "Uncached: #{analysis.selects - cached_selects} selects, #{analysis.rows - cached_rows} rows, #{((analysis.elapsed_time - cached_elapsed_time) * 1000).round} ms\n"
182
- cache_text << "Cached: #{cached_selects} selects, #{cached_rows} rows, #{(cached_elapsed_time * 1000).round} ms\n\n"
183
- end
184
-
185
- text = "SqlSafetyNet\n\n"
186
- text << "There are #{analysis.selects} queries on this page that return #{analysis.rows} rows and took #{(analysis.elapsed_time * 1000).round} ms to execute.\n\n"
187
- text << cache_text
188
- text << flagged_text
189
- text
190
- end
191
-
192
- def xml_comment(text)
193
- "<!-- #{text.gsub('-->', '\\-\\->')} -->"
194
- end
195
- end
196
- end
@@ -1,14 +0,0 @@
1
- # Copy this file to database.yml to run the test with connection settings for you environment.
2
- # You must create the database in both mysql and postgres.
3
-
4
- mysql:
5
- adapter: mysql
6
- username: root
7
- password: ""
8
- database: sql_safety_net_test
9
-
10
- postgresql:
11
- adapter: postgresql
12
- username: postgres
13
- password: postgres
14
- database: sql_safety_net_test
@@ -1,32 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe SqlSafetyNet::ConnectionAdapter::MysqlAdapter do
4
-
5
- before(:each) do
6
- @connection = SqlSafetyNet::MysqlTestModel.connection
7
- end
8
-
9
- it "should use query plan analysis to flag queries" do
10
- query_plan = [{'type' => 'ALL', 'rows' => 200}]
11
- @connection.should_receive(:select_without_sql_safety_net).with('EXPLAIN Select sql', 'EXPLAIN', []).and_return(query_plan)
12
- @connection.should_receive(:analyze_query_plan).with(query_plan).and_return(["bad query"])
13
- @connection.analyze_query('Select sql', 'name', []).should == {:query_plan => query_plan, :flags => ["bad query"]}
14
- end
15
-
16
- it "should only analyze query plans for select statements" do
17
- @connection.should_not_receive(:select_without_sql_safety_net)
18
- @connection.should_not_receive(:analyze_query_plan)
19
- @connection.analyze_query('Execute sql', 'name', []).should == nil
20
- end
21
-
22
- it "should translate query plans into flags" do
23
- @connection.send(:analyze_query_plan, [{'type' => 'ALL', 'rows' => 500}]).should == ["table scan", "no index used", "no indexes possible"]
24
- @connection.send(:analyze_query_plan, [{'Extra' => 'using temporary table; using filesort', 'rows' => 200, 'key' => 'index', 'possible_keys' => 'index'}]).should == ["uses temporary table for 200 rows", "uses filesort for 200 rows"]
25
- @connection.send(:analyze_query_plan, [{'select_type' => 'dependent subquery', 'rows' => 20, 'key' => 'index', 'possible_keys' => 'index'}]).should == ["dependent subquery"]
26
- @connection.send(:analyze_query_plan, [{'select_type' => 'uncacheable subquery', 'rows' => 20, 'key' => 'index', 'possible_keys' => 'index'}]).should == ["uncacheable subquery"]
27
- @connection.send(:analyze_query_plan, [{'rows' => 20000, 'key' => 'index', 'possible_keys' => 'index'}]).should == ["examines 20000 rows"]
28
- @connection.send(:analyze_query_plan, [{'select_type' => 'SIMPLE', 'rows' => 1, 'key' => 'index', 'possible_keys' => 'index'}]).should == []
29
- end
30
-
31
- end
32
-
@@ -1,43 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe SqlSafetyNet::ConnectionAdapter::PostgreSQLAdapter do
4
-
5
- before(:each) do
6
- @connection = SqlSafetyNet::PostgresqlTestModel.connection
7
- end
8
-
9
- it "should use query plan analysis to flag queries" do
10
- query_plan = [{"QUERY PLAN"=>"Limit (cost=0.00..0.06 rows=1 width=335)"}, {"QUERY PLAN"=>" -> Seq Scan on records (cost=0.00..12.20 rows=220 width=335)"}]
11
- @connection.should_receive(:select_without_sql_safety_net).with('EXPLAIN Select sql', 'EXPLAIN', []).and_return(query_plan)
12
- @connection.should_receive(:analyze_query_plan).with(query_plan).and_return(["bad query"])
13
- @connection.analyze_query('Select sql', 'name', []).should == {:query_plan => query_plan, :flags => ["bad query"]}
14
- end
15
-
16
- it "should only analyze query plans for select statements" do
17
- @connection.should_not_receive(:select_without_sql_safety_net)
18
- @connection.should_not_receive(:analyze_query_plan)
19
- @connection.analyze_query('Execute sql', 'name', []).should == nil
20
- end
21
-
22
- it "should not flag a small table scan" do
23
- query_plan = [{"QUERY PLAN"=>"Seq Scan on records (cost=0.00..12.20 rows=10 width=335)"}]
24
- @connection.send(:analyze_query_plan, query_plan).should == []
25
- end
26
-
27
- it "should flag a table scan" do
28
- query_plan = [{"QUERY PLAN"=>"Seq Scan on records (cost=0.00..12.20 rows=1000000 width=335)"}]
29
- @connection.send(:analyze_query_plan, query_plan).should == ["table scan"]
30
- end
31
-
32
- it "should flag too many rows returned" do
33
- query_plan = [{"QUERY PLAN"=>"Index Scan using records_pkey on records (cost=0.00..8.27 rows=1000000 width=336)"}]
34
- @connection.send(:analyze_query_plan, query_plan).should == ["examines 1000000 rows"]
35
- end
36
-
37
- it "should apply a limit to the rows returned" do
38
- query_plan = [{"QUERY PLAN"=>"Limit (cost=0.00..0.06 rows=1 width=335)"}, {"QUERY PLAN"=>" -> Seq Scan on records (cost=0.00..12.20 rows=1000000 width=335)"}]
39
- @connection.send(:analyze_query_plan, query_plan).should == []
40
- end
41
-
42
- end
43
-
@@ -1,193 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe SqlSafetyNet::RackHandler do
4
-
5
- class SqlSafetyNet::TestApp
6
- attr_accessor :response, :block
7
-
8
- def initialize
9
- @response = Rack::Response.new
10
- end
11
-
12
- def call (env)
13
- @block.call(env) if @block
14
- @response["Content-Type"] = env["response_content_type"] if env["response_content_type"]
15
- @response.finish
16
- end
17
- end
18
-
19
- before(:each) do
20
- SqlSafetyNet.config.debug = true
21
- SqlSafetyNet::QueryAnalysis.clear
22
- end
23
-
24
- let(:logger) do
25
- logger = Object.new
26
- def logger.warn (message)
27
- # noop
28
- end
29
- logger
30
- end
31
-
32
- let(:app){ SqlSafetyNet::TestApp.new }
33
-
34
- let(:handler){ SqlSafetyNet::RackHandler.new(app, logger) }
35
-
36
- let(:env) do
37
- {
38
- "rack.url_scheme" => "http",
39
- "PATH_INFO" => "/test",
40
- "SERVER_PORT" => 80,
41
- "HTTP_HOST" => "example.com",
42
- "REQUEST_METHOD" => "GET"
43
- }
44
- end
45
-
46
- it "should append the bad queries to the HTML when debug is enabled and always_show is false" do
47
- SqlSafetyNet.config.always_show = false
48
-
49
- app.block = lambda do |env|
50
- SqlSafetyNet::QueryAnalysis.current.flagged_queries << {:sql => 'sql', :query_plan => 'bad plan', :flags => ['bad query'], :rows => 4, :elapsed_time => 0.1}
51
- end
52
- app.response.write("Monkeys are neat.")
53
-
54
- r = handler.call(env)
55
- response = Rack::Response.new(r[2], r[0], r[1])
56
-
57
- response.body.join("").should include('<div id="sql_safety_net_warning"')
58
- response.body.join("").should include("Monkeys are neat.")
59
- response["X-SqlSafetyNet"].should == "selects=0; rows=0; elapsed_time=0; flagged_queries=1"
60
- end
61
-
62
- it "should not append the bad queries to the HTML when there are none and always_show is false" do
63
- SqlSafetyNet.config.always_show = false
64
-
65
- app.response.write("Monkeys are neat.")
66
-
67
- r = handler.call(env)
68
- response = Rack::Response.new(r[2], r[0], r[1])
69
-
70
- response.body.join("").should == "Monkeys are neat."
71
- response["X-SqlSafetyNet"].should == "selects=0; rows=0; elapsed_time=0; flagged_queries=0"
72
- end
73
-
74
- it "should append the queries to the HTML when there are none and always_show is true" do
75
- SqlSafetyNet.config.always_show = true
76
-
77
- app.response.write("Monkeys are neat.")
78
-
79
- r = handler.call(env)
80
- response = Rack::Response.new(r[2], r[0], r[1])
81
-
82
- response.body.join("").should include('<div id="sql_safety_net_warning"')
83
- response.body.join("").should include("Monkeys are neat.")
84
- response["X-SqlSafetyNet"].should == "selects=0; rows=0; elapsed_time=0; flagged_queries=0"
85
- end
86
-
87
- it "should append the bad queries to XML when debug is enabled" do
88
- SqlSafetyNet.config.always_show = false
89
-
90
- app.block = lambda do |env|
91
- SqlSafetyNet::QueryAnalysis.current.flagged_queries << {:sql => 'sql', :query_plan => 'bad plan', :flags => ['bad query'], :rows => 4, :elapsed_time => 0.1}
92
- end
93
- app.response.write("<woot>Monkeys are neat.</woot>")
94
-
95
- r = handler.call(env.merge("response_content_type" => "application/xml"))
96
- response = Rack::Response.new(r[2], r[0], r[1])
97
-
98
- response.body.join("").should_not include('<div id="sql_safety_net_warning"')
99
- response.body.join("").should include("<!-- SqlSafetyNet")
100
- response.body.join("").should include("<woot>Monkeys are neat.</woot>")
101
- Hash.from_xml(response.body.join('')).should == {"woot" => "Monkeys are neat."}
102
- response["X-SqlSafetyNet"].should == "selects=0; rows=0; elapsed_time=0; flagged_queries=1"
103
- end
104
-
105
- it "should not append the bad queries to the HTML or add a response header if debug is not enabled" do
106
- SqlSafetyNet.config.debug = false
107
-
108
- app.block = lambda do |env|
109
- SqlSafetyNet::QueryAnalysis.current.flagged_queries << {:sql => 'sql', :query_plan => 'bad plan', :flags => ['bad query'], :rows => 4, :elapsed_time => 0.1}
110
- end
111
- app.response.write("Monkeys are neat.")
112
-
113
- r = handler.call(env)
114
- response = Rack::Response.new(r[2], r[0], r[1])
115
-
116
- response.body.join("").should == "Monkeys are neat."
117
- response["X-SqlSafetyNet"].should == nil
118
- end
119
-
120
- it "should not append the bad queries to the HTML if not text/html" do
121
- app.block = lambda do |env|
122
- SqlSafetyNet::QueryAnalysis.current.flagged_queries << {:sql => 'sql', :query_plan => 'bad plan', :flags => ['bad query'], :rows => 4, :elapsed_time => 0.1}
123
- end
124
- app.response.write("Monkeys are neat.")
125
- app.response["Content-Type"] = "text/plain"
126
-
127
- r = handler.call(env)
128
- response = Rack::Response.new(r[2], r[0], r[1])
129
-
130
- response.body.join("").should == "Monkeys are neat."
131
- response["X-SqlSafetyNet"].should == "selects=0; rows=0; elapsed_time=0; flagged_queries=1"
132
- end
133
-
134
- it "should not append the bad queries to the HTML if Ajax request" do
135
- app.block = lambda do |env|
136
- SqlSafetyNet::QueryAnalysis.current.flagged_queries << {:sql => 'sql', :query_plan => 'bad plan', :flags => ['bad query'], :rows => 4, :elapsed_time => 0.1}
137
- end
138
- app.response.write("Monkeys are neat.")
139
- app.response["Content-Type"] = "text/plain"
140
-
141
- r = handler.call(env.merge("HTTP_X_REQUESTED_WITH" => "XMLHttpRequest"))
142
- response = Rack::Response.new(r[2], r[0], r[1])
143
-
144
- response.body.join("").should == "Monkeys are neat."
145
- response["X-SqlSafetyNet"].should == "selects=0; rows=0; elapsed_time=0; flagged_queries=1"
146
- end
147
-
148
- it "should log too many selects even if debug is not enabled" do
149
- SqlSafetyNet.config.debug = false
150
- app.block = lambda do |env|
151
- SqlSafetyNet::QueryAnalysis.current.selects = 1000
152
- end
153
- logger.should_receive(:warn).with("Excess database usage: request generated 1000 queries and returned 0 rows [GET http://example.com/test]")
154
- r = handler.call(env)
155
- end
156
-
157
- it "should log too many rows even if debug is not enabled" do
158
- SqlSafetyNet.config.debug = false
159
- SqlSafetyNet.config.header = false
160
- app.block = lambda do |env|
161
- SqlSafetyNet::QueryAnalysis.current.rows = 10000
162
- end
163
- app.response.write("Monkeys are neat.")
164
-
165
- logger.should_receive(:warn).with("Excess database usage: request generated 0 queries and returned 10000 rows [GET http://example.com/test]")
166
- r = handler.call(env)
167
- response = Rack::Response.new(r[2], r[0], r[1])
168
-
169
- response.body.join("").should == "Monkeys are neat."
170
- response["X-SqlSafetyNet"].should == nil
171
- end
172
-
173
- it "should log too many rows even if debug is not enabled" do
174
- SqlSafetyNet.config.debug = false
175
- SqlSafetyNet.config.header = true
176
- app.block = lambda do |env|
177
- SqlSafetyNet::QueryAnalysis.current.rows = 10
178
- end
179
- app.response.write("Monkeys are neat.")
180
- r = handler.call(env)
181
- response = Rack::Response.new(r[2], r[0], r[1])
182
-
183
- response.body.join("").should == "Monkeys are neat."
184
- response["X-SqlSafetyNet"].should == "selects=0; rows=10; elapsed_time=0; flagged_queries=0"
185
- end
186
-
187
- it "should not log warnings if there are none" do
188
- SqlSafetyNet.config.debug = false
189
- logger.should_not_receive(:warn)
190
- r = handler.call(env)
191
- end
192
-
193
- end