Skip to content

Commit 7d00131

Browse files
committed
EXPLAIN it to them! http://youtu.be/ckb3YYZZZ2Q
1 parent 046c181 commit 7d00131

File tree

13 files changed

+387
-19
lines changed

13 files changed

+387
-19
lines changed

CHANGELOG

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11

22
* 3.2.0 *
33

4+
* ActiveRecord explain (SHOWPLAN) support.
5+
http://youtu.be/ckb3YYZZZ2Q
6+
47
* Remove our log_info_schema_queries config since we are not hooking properly into AR's 'SCHEMA' names.
58

69
* Properly use 'SCHEMA' name arguement in DB statements to comply with ActiveRecord::ExplainSubscriber::IGNORED_PAYLOADS.

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ group :development do
3232
gem 'mocha', '0.9.8'
3333
gem 'shoulda', '2.10.3'
3434
gem 'bench_press'
35+
gem 'nokogiri'
3536
end
3637

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11

22
# SQL Server 2005/2008 & Azure Adapter For ActiveRecord
33

4-
The SQL Server adapter for ActiveRecord. If you need the adapter for SQL Server 2000, you are still in the right spot. Just install the latest 2.3.x version of the adapter. Note, we follow a rational versioning policy that tracks ActiveRecord. That means that our 2.3.x version of the adapter is only for the latest 2.3 version of Rails.
4+
The SQL Server adapter for ActiveRecord. If you need the adapter for SQL Server 2000, you are still in the right spot. Just install the latest 2.3.x version of the adapter. Note, we follow a rational versioning policy that tracks ActiveRecord. That means that our 2.3.x version of the adapter is only for the latest 2.3 version of Rails. We also have stable branches for each major/minor release of ActiveRecord.
55

66

77
## What's New
88

9+
* Rails 3.2 support. With explain (SHOWPLAN) support.
910
* Deadlock victim retry logic using the #retry_deadlock_victim config.
1011
* Proper interface to configure the connection and TinyTDS app name reported to SQL Server.
1112
* Rails 3.1 prepared statement support leverages cached query plans.
@@ -170,6 +171,37 @@ module ActiveRecord
170171
end
171172
```
172173

174+
#### Explain Support (SHOWPLAN)
175+
176+
The 3.2 version of the adapter support ActiveRecord's explain features. In SQL Server, this is called the showplan. By default we use the `SHOWPLAN_ALL` option and format it using a simple table printer. So the following ruby would log the plan table below it.
177+
178+
```ruby
179+
Car.where(:id => 1).explain
180+
```
181+
182+
```
183+
EXPLAIN for: SELECT [cars].* FROM [cars] WHERE [cars].[id] = 1
184+
+----------------------------------------------------+--------+--------+--------+----------------------+----------------------+----------------------------------------------------+----------------------------------------------------+--------------+---------------------+----------------------+------------+---------------------+----------------------------------------------------+----------+----------+----------+--------------------+
185+
| StmtText | StmtId | NodeId | Parent | PhysicalOp | LogicalOp | Argument | DefinedValues | EstimateRows | EstimateIO | EstimateCPU | AvgRowSize | TotalSubtreeCost | OutputList | Warnings | Type | Parallel | EstimateExecutions |
186+
+----------------------------------------------------+--------+--------+--------+----------------------+----------------------+----------------------------------------------------+----------------------------------------------------+--------------+---------------------+----------------------+------------+---------------------+----------------------------------------------------+----------+----------+----------+--------------------+
187+
| SELECT [cars].* FROM [cars] WHERE [cars].[id] = 1 | 1 | 1 | 0 | NULL | NULL | 2 | NULL | 1.0 | NULL | NULL | NULL | 0.00328309996984899 | NULL | NULL | SELECT | false | NULL |
188+
| |--Clustered Index Seek(OBJECT:([activerecord... | 1 | 2 | 1 | Clustered Index Seek | Clustered Index Seek | OBJECT:([activerecord_unittest].[dbo].[cars].[P... | [activerecord_unittest].[dbo].[cars].[id], [act... | 1.0 | 0.00312500004656613 | 0.000158099996042438 | 278 | 0.00328309996984899 | [activerecord_unittest].[dbo].[cars].[id], [act... | NULL | PLAN_ROW | false | 1.0 |
189+
+----------------------------------------------------+--------+--------+--------+----------------------+----------------------+----------------------------------------------------+----------------------------------------------------+--------------+---------------------+----------------------+------------+---------------------+----------------------------------------------------+----------+----------+----------+--------------------+
190+
```
191+
192+
You can configure a few options to your needs. First is the max column width for the logged table. The default value is 50 characters. You can change it like so.
193+
194+
```ruby
195+
ActiveRecord::ConnectionAdapters::Sqlserver::Showplan::PrinterTable.max_column_width = 500
196+
```
197+
198+
Another configuration is the showplan option. Some might find the XML format more useful. If you have Nokogiri installed, we will format the XML string. I will gladly accept pathces that make the XML printer more useful!
199+
200+
```ruby
201+
ActiveRecord::ConnectionAdapters::SQLServerAdapter.showplan_option = 'SHOWPLAN_XML'
202+
```
203+
204+
173205

174206
## Versions
175207

activerecord-sqlserver-adapter.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ Gem::Specification.new do |s|
1717
s.require_path = 'lib'
1818
s.rubyforge_project = 'activerecord-sqlserver-adapter'
1919

20-
s.add_dependency('activerecord', '~> 3.1.0')
20+
s.add_dependency('activerecord', '~> 3.2.0.rc1')
2121
end
2222

lib/active_record/connection_adapters/sqlserver/core_ext/active_record.rb

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
require 'set'
2-
require 'active_record/base'
3-
require 'active_record/version'
4-
require 'active_support/concern'
5-
require 'active_support/core_ext/class/attribute'
6-
71
module ActiveRecord
82
module ConnectionAdapters
93
module Sqlserver
@@ -46,4 +40,3 @@ def coerce_sqlserver_time(*attributes)
4640

4741

4842
ActiveRecord::Base.send :include, ActiveRecord::ConnectionAdapters::Sqlserver::CoreExt::ActiveRecord
49-
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
module ActiveRecord
2+
module ConnectionAdapters
3+
module Sqlserver
4+
module CoreExt
5+
module Explain
6+
7+
SQLSERVER_STATEMENT_PREFIX = "EXEC sp_executesql "
8+
SQLSERVER_PARAM_MATCHER = /@\d+ =/
9+
10+
def exec_explain(queries)
11+
unprepared_queries = queries.map { |sql, bind| [unprepare_sqlserver_statement(sql), bind] }
12+
super(unprepared_queries)
13+
end
14+
15+
private
16+
17+
# This is somewhat hacky, but it should reliably reformat our prepared sql statment
18+
# which uses sp_executesql to just the first argument, then unquote it. Likewise our
19+
# do_exec_query method should substitude the @n args withe the quoted values.
20+
def unprepare_sqlserver_statement(sql)
21+
if sql.starts_with?(SQLSERVER_STATEMENT_PREFIX)
22+
executesql = sql.from(SQLSERVER_STATEMENT_PREFIX.length)
23+
executesql_args = executesql.split(', ')
24+
executesql_args.reject! { |arg| arg =~ SQLSERVER_PARAM_MATCHER }
25+
executesql_args.pop if executesql_args.many?
26+
executesql = executesql_args.join(', ').strip.match(/N'(.*)'/)[1]
27+
Utils.unquote_string(executesql)
28+
else
29+
sql
30+
end
31+
end
32+
33+
34+
end
35+
end
36+
end
37+
end
38+
end
39+
40+
ActiveRecord::Base.extend ActiveRecord::ConnectionAdapters::Sqlserver::CoreExt::Explain
41+
ActiveRecord::Relation.send :include, ActiveRecord::ConnectionAdapters::Sqlserver::CoreExt::Explain

lib/active_record/connection_adapters/sqlserver/database_statements.rb

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -310,15 +310,14 @@ def valid_isolation_levels
310310

311311
# === SQLServer Specific (Executing) ============================ #
312312

313-
def do_execute(sql, name = nil)
314-
name ||= 'EXECUTE'
313+
def do_execute(sql, name = 'SQL')
315314
log(sql, name) do
316315
with_sqlserver_error_handling { raw_connection_do(sql) }
317316
end
318317
end
319318

320319
def do_exec_query(sql, name, binds)
321-
statement = quote(sql)
320+
explaining = name == 'EXPLAIN'
322321
names_and_types = []
323322
params = []
324323
binds.each_with_index do |(column,value),index|
@@ -337,10 +336,17 @@ def do_exec_query(sql, name, binds)
337336
raise "Unknown bind columns. We can account for this."
338337
end
339338
quoted_value = ar_column ? quote(v,column) : quote(v,nil)
340-
params << "@#{index} = #{quoted_value}"
339+
params << (explaining ? quoted_value : "@#{index} = #{quoted_value}")
340+
end
341+
if explaining
342+
params.each_with_index do |param, index|
343+
substitute_at_finder = /(@#{index})(?=(?:[^']|'[^']*')*$)/ # Finds unquoted @n values.
344+
sql.sub! substitute_at_finder, param
345+
end
346+
else
347+
sql = "EXEC sp_executesql #{quote(sql)}"
348+
sql << ", #{quote(names_and_types.join(', '))}, #{params.join(', ')}" unless binds.empty?
341349
end
342-
sql = "EXEC sp_executesql #{statement}"
343-
sql << ", #{quote(names_and_types.join(', '))}, #{params.join(', ')}" unless binds.empty?
344350
raw_select sql, name, binds, :ar_result => true
345351
end
346352

@@ -357,7 +363,7 @@ def raw_connection_do(sql)
357363

358364
# === SQLServer Specific (Selecting) ============================ #
359365

360-
def raw_select(sql, name=nil, binds=[], options={})
366+
def raw_select(sql, name='SQL', binds=[], options={})
361367
log(sql,name,binds) { _raw_select(sql, options) }
362368
end
363369

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
require 'active_record/connection_adapters/sqlserver/showplan/printer_table'
2+
require 'active_record/connection_adapters/sqlserver/showplan/printer_xml'
3+
4+
module ActiveRecord
5+
module ConnectionAdapters
6+
module Sqlserver
7+
module Showplan
8+
9+
OPTION_ALL = 'SHOWPLAN_ALL'
10+
OPTION_TEXT = 'SHOWPLAN_TEXT'
11+
OPTION_XML = 'SHOWPLAN_XML'
12+
OPTIONS = [OPTION_ALL, OPTION_TEXT, OPTION_XML]
13+
14+
def explain(arel, binds = [])
15+
sql = to_sql(arel)
16+
result = with_showplan_on { do_exec_query(sql, 'EXPLAIN', binds) }
17+
printer = showplan_printer.new(result)
18+
printer.pp
19+
end
20+
21+
22+
protected
23+
24+
def with_showplan_on
25+
set_showplan_option(true)
26+
yield
27+
ensure
28+
set_showplan_option(false)
29+
end
30+
31+
def set_showplan_option(enable = true)
32+
sql = "SET #{option} #{enable ? 'ON' : 'OFF'}"
33+
raw_connection_do(sql)
34+
rescue Exception => e
35+
raise ActiveRecordError, "#{option} could not be turned #{enable ? 'ON' : 'OFF'}, perhaps you do not have SHOWPLAN permissions?"
36+
end
37+
38+
def option
39+
(SQLServerAdapter.showplan_option || OPTION_ALL).tap do |opt|
40+
raise(ArgumentError, "Unknown SHOWPLAN option #{opt.inspect} found.") if OPTIONS.exclude?(opt)
41+
end
42+
end
43+
44+
def showplan_all?
45+
option == OPTION_ALL
46+
end
47+
48+
def showplan_text?
49+
option == OPTION_TEXT
50+
end
51+
52+
def showplan_xml?
53+
option == OPTION_XML
54+
end
55+
56+
def showplan_printer
57+
case option
58+
when OPTION_XML then PrinterXml
59+
when OPTION_ALL, OPTION_TEXT then PrinterTable
60+
else PrinterTable
61+
end
62+
end
63+
64+
end
65+
end
66+
end
67+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
module ActiveRecord
2+
module ConnectionAdapters
3+
module Sqlserver
4+
module Showplan
5+
class PrinterTable
6+
7+
cattr_accessor :max_column_width, :cell_padding
8+
self.max_column_width = 50
9+
self.cell_padding = 1
10+
11+
attr_reader :result
12+
13+
def initialize(result)
14+
@result = result
15+
end
16+
17+
def pp
18+
@widths = compute_column_widths
19+
@separator = build_separator
20+
pp = []
21+
pp << @separator
22+
pp << build_cells(result.columns)
23+
pp << @separator
24+
result.rows.each do |row|
25+
pp << build_cells(row)
26+
end
27+
pp << @separator
28+
pp.join("\n") + "\n"
29+
end
30+
31+
private
32+
33+
def compute_column_widths
34+
[].tap do |computed_widths|
35+
result.columns.each_with_index do |column, i|
36+
cells_in_column = [column] + result.rows.map { |r| cast_item(r[i]) }
37+
computed_width = cells_in_column.map(&:length).max
38+
final_width = computed_width > max_column_width ? max_column_width : computed_width
39+
computed_widths << final_width
40+
end
41+
end
42+
end
43+
44+
def build_separator
45+
'+' + @widths.map {|w| '-' * (w + (cell_padding*2))}.join('+') + '+'
46+
end
47+
48+
def build_cells(items)
49+
cells = []
50+
items.each_with_index do |item, i|
51+
cells << cast_item(item).ljust(@widths[i])
52+
end
53+
"| #{cells.join(' | ')} |"
54+
end
55+
56+
def cast_item(item)
57+
case item
58+
when NilClass then 'NULL'
59+
when Float then item.to_s.to(9)
60+
else item.to_s.truncate(max_column_width)
61+
end
62+
end
63+
64+
end
65+
66+
end
67+
end
68+
end
69+
end
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module ActiveRecord
2+
module ConnectionAdapters
3+
module Sqlserver
4+
module Showplan
5+
class PrinterXml
6+
7+
def initialize(result)
8+
@result = result
9+
end
10+
11+
def pp
12+
xml = @result.rows.first.first
13+
if defined?(Nokogiri)
14+
Nokogiri::XML(xml).to_xml :indent => 2, :encoding => 'UTF-8'
15+
else
16+
xml
17+
end
18+
end
19+
20+
end
21+
22+
end
23+
end
24+
end
25+
end

0 commit comments

Comments
 (0)