sql_safety_net 1.1.11
Sign up to get free protection for your applications and to get access to all the features.
- data/License.txt +674 -0
- data/README.rdoc +19 -0
- data/Rakefile +40 -0
- data/lib/sql_safety_net/cache_store.rb +19 -0
- data/lib/sql_safety_net/configuration.rb +57 -0
- data/lib/sql_safety_net/connection_adapter/mysql_adapter.rb +43 -0
- data/lib/sql_safety_net/connection_adapter/postgresql_adapter.rb +48 -0
- data/lib/sql_safety_net/connection_adapter.rb +103 -0
- data/lib/sql_safety_net/query_analysis.rb +69 -0
- data/lib/sql_safety_net/rack_handler.rb +196 -0
- data/lib/sql_safety_net.rb +27 -0
- data/spec/cache_store_spec.rb +26 -0
- data/spec/configuration_spec.rb +67 -0
- data/spec/connection_adapter_spec.rb +143 -0
- data/spec/example_database.yml +14 -0
- data/spec/mysql_connection_adapter_spec.rb +32 -0
- data/spec/postgres_connection_adapter_spec.rb +43 -0
- data/spec/query_analysis_spec.rb +71 -0
- data/spec/rack_handler_spec.rb +193 -0
- data/spec/spec_helper.rb +56 -0
- metadata +148 -0
data/README.rdoc
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
= SQL Safety Net
|
2
|
+
|
3
|
+
This gem provides hooks into ActiveRecord to analyze SQL queries. It can be very useful in keeping an eye on tuning your code before it becomes a problem. ActiveRecord can make it very easy to send excessive load to the database.
|
4
|
+
|
5
|
+
With this gem enabled, HTML pages which have bad queries or too many queries on them will have a debug div added to the HTML with information about the queries. If you see this in development mode, you should investigate and come up with ways to reduce the load.
|
6
|
+
|
7
|
+
== Example
|
8
|
+
|
9
|
+
Add this code to an initializer:
|
10
|
+
|
11
|
+
SqlSafetyNet.init_rails
|
12
|
+
|
13
|
+
You can pass a block to this method which will yield a configuration object you can use to set threshold parameters on. See SqlSafetyNet::Configuration for all options. For example:
|
14
|
+
|
15
|
+
SqlSafetyNet.init_rails do |config|
|
16
|
+
config.query_limit = 20
|
17
|
+
end
|
18
|
+
|
19
|
+
If you need finer control over how the gem is set up, or you are using it in a non-Rails environment, you can initialize it manually. You will need to call <tt>SqlSafetyNet::Configuration.enable_on</tt> with the ActiveRecord connection object and you will need to add SqlSafetyNet::RackHandler to the Rack middleware stack.
|
data/Rakefile
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
desc 'Default: run unit tests'
|
5
|
+
task :default => :test
|
6
|
+
|
7
|
+
begin
|
8
|
+
require 'rspec'
|
9
|
+
require 'rspec/core/rake_task'
|
10
|
+
desc 'Run the unit tests'
|
11
|
+
RSpec::Core::RakeTask.new(:test)
|
12
|
+
rescue LoadError
|
13
|
+
task :test do
|
14
|
+
raise "You must have rspec 2.0 installed to run the tests"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
begin
|
19
|
+
require 'jeweler'
|
20
|
+
Jeweler::Tasks.new do |gem|
|
21
|
+
gem.name = "sql_safety_net"
|
22
|
+
gem.summary = %Q{Debug SQL statements in ActiveRecord}
|
23
|
+
gem.description = %Q{Debug SQL statements in ActiveRecord by displaying warnings on bad queries.}
|
24
|
+
gem.authors = ["Brian Durand"]
|
25
|
+
gem.email = ["mdobrota@tribune.com", "ddpr@tribune.com"]
|
26
|
+
gem.files = FileList["lib/**/*", "spec/**/*", "README.rdoc", "Rakefile", "License.txt"].to_a
|
27
|
+
gem.has_rdoc = true
|
28
|
+
gem.rdoc_options << '--line-numbers' << '--inline-source' << '--main' << 'README.rdoc'
|
29
|
+
gem.extra_rdoc_files = ["README.rdoc"]
|
30
|
+
gem.add_dependency('activesupport')
|
31
|
+
gem.add_dependency('activerecord', '>= 2.2.2')
|
32
|
+
gem.add_dependency('actionpack')
|
33
|
+
gem.add_development_dependency('rspec', '>= 2.0.0')
|
34
|
+
gem.add_development_dependency('mysql')
|
35
|
+
gem.add_development_dependency('pg')
|
36
|
+
gem.add_development_dependency('sqlite3-ruby')
|
37
|
+
end
|
38
|
+
Jeweler::RubygemsDotOrgTasks.new
|
39
|
+
rescue LoadError
|
40
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module SqlSafetyNet
|
2
|
+
# Hook into ActiveSupport::Cache to set a caching flag on the QueryAnalysis whenever +fetch+ is called with a block.
|
3
|
+
module CacheStore
|
4
|
+
def self.included(base)
|
5
|
+
base.alias_method_chain(:fetch, :sql_safety_net)
|
6
|
+
end
|
7
|
+
|
8
|
+
def fetch_with_sql_safety_net(*args, &block)
|
9
|
+
analysis = QueryAnalysis.current
|
10
|
+
saved_val = analysis.caching? if analysis
|
11
|
+
begin
|
12
|
+
analysis.caching = true if analysis
|
13
|
+
fetch_without_sql_safety_net(*args, &block)
|
14
|
+
ensure
|
15
|
+
analysis.caching = saved_val if analysis
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module SqlSafetyNet
|
2
|
+
# Configuration for SqlSafetyNet. The various limit attributes are used for adapters that can do query analysis. Queries
|
3
|
+
# will be flagged if their query plan exceeds a limit.
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :table_scan_limit, :temporary_table_limit, :filesort_limit, :return_rows_limit, :examine_rows_limit, :query_limit, :time_limit, :position
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@debug = false
|
9
|
+
@header = false
|
10
|
+
@table_scan_limit = 100
|
11
|
+
@temporary_table_limit = 100
|
12
|
+
@filesort_limit = 100
|
13
|
+
@return_rows_limit = 100
|
14
|
+
@examine_rows_limit = 5000
|
15
|
+
@query_limit = 10
|
16
|
+
@always_show = false
|
17
|
+
@position = "top:5px; right: 5px;"
|
18
|
+
@time_limit = 300
|
19
|
+
end
|
20
|
+
|
21
|
+
# Enable SqlSafetyNet on a connection. Unless this method is called, the code will not be mixed into the database
|
22
|
+
# adapter. This should normally be called only in the development environment.
|
23
|
+
def enable_on(connection_class)
|
24
|
+
connection_class = connection_class.constantize unless connection_class.is_a?(Class)
|
25
|
+
connection_class_name = connection_class.name.split('::').last
|
26
|
+
include_class = ConnectionAdapter.const_get(connection_class_name) if ConnectionAdapter.const_defined?(connection_class_name)
|
27
|
+
connection_class.send(:include, ConnectionAdapter) unless connection_class.include?(ConnectionAdapter)
|
28
|
+
connection_class.send(:include, include_class) if include_class && !connection_class.include?(include_class)
|
29
|
+
end
|
30
|
+
|
31
|
+
def debug=(val)
|
32
|
+
@debug = !!val
|
33
|
+
end
|
34
|
+
|
35
|
+
def debug?
|
36
|
+
@debug
|
37
|
+
end
|
38
|
+
|
39
|
+
def header=(val)
|
40
|
+
@header = !!val
|
41
|
+
end
|
42
|
+
|
43
|
+
def header?
|
44
|
+
@header
|
45
|
+
end
|
46
|
+
|
47
|
+
# Set a flag to always show information about queries on rendered HTML pages. If this is not set to true, the debug
|
48
|
+
# information will only be shown if the queries on the page exceed one of the limits.
|
49
|
+
def always_show=(val)
|
50
|
+
@always_show = !!val
|
51
|
+
end
|
52
|
+
|
53
|
+
def always_show?
|
54
|
+
@always_show
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module SqlSafetyNet
|
2
|
+
module ConnectionAdapter
|
3
|
+
# Logic for analyzing MySQL query plans.
|
4
|
+
module MysqlAdapter
|
5
|
+
def analyze_query(sql, name, *args)
|
6
|
+
if select_statement?(sql)
|
7
|
+
query_plan = select_without_sql_safety_net("EXPLAIN #{sql}", "EXPLAIN", *args)
|
8
|
+
query_plan_flags = analyze_query_plan(query_plan)
|
9
|
+
unless query_plan_flags.empty?
|
10
|
+
@logger.debug("Flagged query plan #{name} (#{query_plan_flags.join(', ')}): #{query_plan.inspect}") if @logger
|
11
|
+
return {:query_plan => query_plan, :flags => query_plan_flags}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def analyze_query_plan(query_plan)
|
19
|
+
flagged = []
|
20
|
+
query_plan.each do |row|
|
21
|
+
select_type = (row['select_type'] || '').downcase
|
22
|
+
type = (row['type'] || '').downcase
|
23
|
+
rows = row['rows'].to_i
|
24
|
+
extra = (row['Extra'] || '').downcase
|
25
|
+
key = row['key']
|
26
|
+
possible_keys = row['possible_keys']
|
27
|
+
|
28
|
+
flagged << 'table scan' if (type.include?('all') and rows > SqlSafetyNet.config.table_scan_limit)
|
29
|
+
flagged << 'fulltext search' if type.include?('fulltext')
|
30
|
+
flagged << 'no index used' if (key.blank? and rows > SqlSafetyNet.config.table_scan_limit)
|
31
|
+
flagged << 'no indexes possible' if (possible_keys.blank? and rows > SqlSafetyNet.config.table_scan_limit)
|
32
|
+
flagged << 'dependent subquery' if select_type.include?('dependent')
|
33
|
+
flagged << 'uncacheable subquery' if select_type.include?('uncacheable')
|
34
|
+
flagged << 'full scan on null key' if extra.include?('full scan on null key')
|
35
|
+
flagged << "uses temporary table for #{rows} rows" if extra.include?('using temporary') and rows > SqlSafetyNet.config.temporary_table_limit
|
36
|
+
flagged << "uses filesort for #{rows} rows" if extra.include?('filesort') and rows > SqlSafetyNet.config.filesort_limit
|
37
|
+
flagged << "examines #{rows} rows" if rows > SqlSafetyNet.config.examine_rows_limit
|
38
|
+
end
|
39
|
+
return flagged
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module SqlSafetyNet
|
2
|
+
module ConnectionAdapter
|
3
|
+
# Logic for analyzing a query plan from PostgreSQL. These plans are not terribly useful and sometimes
|
4
|
+
# the statistics are off, so take them with a grain of salt.
|
5
|
+
module PostgreSQLAdapter
|
6
|
+
def analyze_query(sql, name, *args)
|
7
|
+
if select_statement?(sql)
|
8
|
+
query_plan = select_without_sql_safety_net("EXPLAIN #{sql}", "EXPLAIN", *args)
|
9
|
+
query_plan_flags = analyze_query_plan(query_plan)
|
10
|
+
unless query_plan_flags.empty?
|
11
|
+
@logger.debug("Flagged query plan #{name} (#{query_plan_flags.join(', ')}): #{query_plan.inspect}") if @logger
|
12
|
+
return {:query_plan => query_plan, :flags => query_plan_flags}
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def analyze_query_plan(query_plan)
|
20
|
+
query_plan = query_plan.collect{|r| r.values.first}
|
21
|
+
flagged = []
|
22
|
+
limit = nil
|
23
|
+
query_plan.each do |row|
|
24
|
+
row_count = query_plan_rows_value(row)
|
25
|
+
row_count = [limit, row_count].min if limit
|
26
|
+
if row =~ /^(\s|(->))*Limit\s/
|
27
|
+
limit = row_count
|
28
|
+
elsif row =~ /^(\s|(->))*Seq Scan/
|
29
|
+
flagged << 'table scan' if row_count > SqlSafetyNet.config.table_scan_limit
|
30
|
+
elsif row_count > SqlSafetyNet.config.examine_rows_limit
|
31
|
+
flagged << "examines #{row_count} rows"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
return flagged
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def query_plan_rows_value(plan_row)
|
40
|
+
if plan_row.match(/\brows=(\d+)/)
|
41
|
+
return $~[1].to_i
|
42
|
+
else
|
43
|
+
return 0
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module SqlSafetyNet
|
2
|
+
# Logic to be mixed into connection adapters allowing them to analyze queries.
|
3
|
+
module ConnectionAdapter
|
4
|
+
autoload :MysqlAdapter, File.expand_path('../connection_adapter/mysql_adapter', __FILE__)
|
5
|
+
autoload :PostgreSQLAdapter, File.expand_path('../connection_adapter/postgresql_adapter', __FILE__)
|
6
|
+
|
7
|
+
SELECT_STATEMENT = /^\s*SELECT\b/i
|
8
|
+
|
9
|
+
def self.included(base)
|
10
|
+
base.alias_method_chain :select, :sql_safety_net
|
11
|
+
base.alias_method_chain :select_rows, :sql_safety_net
|
12
|
+
base.alias_method_chain :columns, :sql_safety_net
|
13
|
+
base.alias_method_chain :active?, :sql_safety_net
|
14
|
+
end
|
15
|
+
|
16
|
+
def active_with_sql_safety_net?
|
17
|
+
active = disable_sql_safety_net{active_without_sql_safety_net?}
|
18
|
+
if @logger && !active
|
19
|
+
@logger.warn("#{adapter_name} connection not active")
|
20
|
+
end
|
21
|
+
active
|
22
|
+
end
|
23
|
+
|
24
|
+
def columns_with_sql_safety_net(table_name, name = nil)
|
25
|
+
disable_sql_safety_net do
|
26
|
+
columns_without_sql_safety_net(table_name, name)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def select_rows_with_sql_safety_net(sql, name = nil, *args)
|
31
|
+
analyze_sql_safety_net_query(sql, name, *args) do
|
32
|
+
select_rows_without_sql_safety_net(sql, name, *args)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Disable query analysis within a block.
|
37
|
+
def disable_sql_safety_net
|
38
|
+
save_disabled = Thread.current[:sql_safety_net_disable]
|
39
|
+
begin
|
40
|
+
Thread.current[:sql_safety_net_disable] = true
|
41
|
+
yield
|
42
|
+
ensure
|
43
|
+
Thread.current[:sql_safety_net_disable] = save_disabled
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def analyze_query(sql, *args)
|
48
|
+
# No op; other modules may redefine to analyze query plans
|
49
|
+
end
|
50
|
+
|
51
|
+
def select_statement?(sql)
|
52
|
+
!!sql.match(SELECT_STATEMENT)
|
53
|
+
end
|
54
|
+
|
55
|
+
protected
|
56
|
+
|
57
|
+
def select_with_sql_safety_net(sql, name = nil, *args)
|
58
|
+
analyze_sql_safety_net_query(sql, name, *args) do
|
59
|
+
select_without_sql_safety_net(sql, name, *args)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def analyze_sql_safety_net_query(sql, name, *args)
|
66
|
+
if Thread.current[:sql_safety_net_disable]
|
67
|
+
yield
|
68
|
+
else
|
69
|
+
t = Time.now.to_f
|
70
|
+
query_results = nil
|
71
|
+
disable_sql_safety_net do
|
72
|
+
query_results = yield
|
73
|
+
end
|
74
|
+
elapsed_time = Time.now.to_f - t
|
75
|
+
|
76
|
+
unless Thread.current[:sql_safety_net_disable]
|
77
|
+
query_info = Thread.current[:sql_safety_net_query_info]
|
78
|
+
if query_info
|
79
|
+
query_info.count += 1
|
80
|
+
query_info.selects += query_results.size
|
81
|
+
end
|
82
|
+
analysis = QueryAnalysis.current
|
83
|
+
if analysis
|
84
|
+
analysis.selects += 1
|
85
|
+
analysis.rows += query_results.size
|
86
|
+
analysis.elapsed_time += elapsed_time
|
87
|
+
if SqlSafetyNet.config.debug?
|
88
|
+
flagged = (@logger ? @logger.silence{analyze_query(sql, name, *args)} : analyze_query(sql, name, *args))
|
89
|
+
if elapsed_time * 1000 >= SqlSafetyNet.config.time_limit
|
90
|
+
flagged ||= {}
|
91
|
+
flagged[:flags] ||= []
|
92
|
+
flagged[:flags] << "query time exceeded #{SqlSafetyNet.config.time_limit} ms"
|
93
|
+
end
|
94
|
+
analysis.add_query(sql, name, query_results.size, elapsed_time, flagged)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
query_results
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
module SqlSafetyNet
|
2
|
+
# Analysis container for the sql queries in a context.
|
3
|
+
class QueryAnalysis
|
4
|
+
attr_accessor :selects, :rows, :elapsed_time
|
5
|
+
attr_reader :flagged_queries, :non_flagged_queries
|
6
|
+
attr_writer :caching
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@selects = 0
|
10
|
+
@rows = 0
|
11
|
+
@elapsed_time = 0.0
|
12
|
+
@flagged_queries = []
|
13
|
+
@non_flagged_queries = []
|
14
|
+
@caching = false
|
15
|
+
end
|
16
|
+
|
17
|
+
# Analyze all queries within a block.
|
18
|
+
def self.analyze
|
19
|
+
Thread.current[:sql_safety_net] = new
|
20
|
+
begin
|
21
|
+
yield
|
22
|
+
return current
|
23
|
+
ensure
|
24
|
+
clear
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Get the current analysis object.
|
29
|
+
def self.current
|
30
|
+
Thread.current[:sql_safety_net]
|
31
|
+
end
|
32
|
+
|
33
|
+
# Clear all query information from the current analysis.
|
34
|
+
def self.clear
|
35
|
+
Thread.current[:sql_safety_net] = nil
|
36
|
+
end
|
37
|
+
|
38
|
+
def caching?
|
39
|
+
@caching
|
40
|
+
end
|
41
|
+
|
42
|
+
def flagged?
|
43
|
+
too_many_selects? || too_many_rows? || flagged_queries?
|
44
|
+
end
|
45
|
+
|
46
|
+
def too_many_selects?
|
47
|
+
selects > SqlSafetyNet.config.query_limit
|
48
|
+
end
|
49
|
+
|
50
|
+
def too_many_rows?
|
51
|
+
rows > SqlSafetyNet.config.return_rows_limit
|
52
|
+
end
|
53
|
+
|
54
|
+
def flagged_queries?
|
55
|
+
!flagged_queries.empty?
|
56
|
+
end
|
57
|
+
|
58
|
+
def add_query(sql, name, rows, elapsed_time, flagged)
|
59
|
+
info = {:sql => sql, :name => name, :rows => rows, :elapsed_time => elapsed_time, :cached => caching?}
|
60
|
+
if flagged.blank?
|
61
|
+
non_flagged_queries << info
|
62
|
+
else
|
63
|
+
info[:query_plan] = flagged[:query_plan]
|
64
|
+
info[:flags] = flagged[:flags]
|
65
|
+
flagged_queries << info
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,196 @@
|
|
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;">×</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} »</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
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'action_controller'
|
3
|
+
|
4
|
+
# Collects and displays debugging information about SQL statements.
|
5
|
+
module SqlSafetyNet
|
6
|
+
autoload :CacheStore, File.expand_path('../sql_safety_net/cache_store', __FILE__)
|
7
|
+
autoload :Configuration, File.expand_path('../sql_safety_net/configuration', __FILE__)
|
8
|
+
autoload :ConnectionAdapter, File.expand_path('../sql_safety_net/connection_adapter', __FILE__)
|
9
|
+
autoload :QueryAnalysis, File.expand_path('../sql_safety_net/query_analysis', __FILE__)
|
10
|
+
autoload :RackHandler, File.expand_path('../sql_safety_net/rack_handler', __FILE__)
|
11
|
+
|
12
|
+
class << self
|
13
|
+
# Get the configuration for the safety net.
|
14
|
+
def config
|
15
|
+
@config ||= SqlSafetyNet::Configuration.new
|
16
|
+
end
|
17
|
+
|
18
|
+
def init_rails
|
19
|
+
SqlSafetyNet.config.enable_on(ActiveRecord::Base.connection.class)
|
20
|
+
if Rails.env == "development"
|
21
|
+
SqlSafetyNet.config.debug = true
|
22
|
+
ActiveSupport::Cache::Store.send(:include, CacheStore)
|
23
|
+
end
|
24
|
+
Rails.configuration.middleware.use(SqlSafetyNet::RackHandler)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe SqlSafetyNet::CacheStore do
|
4
|
+
let(:cache){ ActiveSupport::Cache::MemoryStore.new }
|
5
|
+
|
6
|
+
it "should set the caching flag on the current query analysis when fetching with a block" do
|
7
|
+
SqlSafetyNet::QueryAnalysis.analyze do
|
8
|
+
SqlSafetyNet::QueryAnalysis.current.caching?.should == false
|
9
|
+
val = cache.fetch("key") do
|
10
|
+
SqlSafetyNet::QueryAnalysis.current.caching?.should == true
|
11
|
+
"woot"
|
12
|
+
end
|
13
|
+
val.should == "woot"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
it "should fetch properly even when there is not current query analysis" do
|
18
|
+
SqlSafetyNet::QueryAnalysis.current.should == nil
|
19
|
+
val = cache.fetch("key") do
|
20
|
+
SqlSafetyNet::QueryAnalysis.current.should == nil
|
21
|
+
"woot"
|
22
|
+
end
|
23
|
+
val.should == "woot"
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|