sql_safety_net 1.1.11 → 2.0.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.
@@ -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