Skip to content

Commit

Permalink
EXPLAIN it to them! http://youtu.be/ckb3YYZZZ2Q
Browse files Browse the repository at this point in the history
  • Loading branch information
metaskills committed Jan 2, 2012
1 parent 046c181 commit 7d00131
Show file tree
Hide file tree
Showing 13 changed files with 387 additions and 19 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@

* 3.2.0 *

* ActiveRecord explain (SHOWPLAN) support.
http://youtu.be/ckb3YYZZZ2Q

* Remove our log_info_schema_queries config since we are not hooking properly into AR's 'SCHEMA' names.

* Properly use 'SCHEMA' name arguement in DB statements to comply with ActiveRecord::ExplainSubscriber::IGNORED_PAYLOADS.
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ group :development do
gem 'mocha', '0.9.8'
gem 'shoulda', '2.10.3'
gem 'bench_press'
gem 'nokogiri'
end

34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@

# SQL Server 2005/2008 & Azure Adapter For ActiveRecord

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.
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.


## What's New

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

#### Explain Support (SHOWPLAN)

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.

```ruby
Car.where(:id => 1).explain
```

```
EXPLAIN for: SELECT [cars].* FROM [cars] WHERE [cars].[id] = 1
+----------------------------------------------------+--------+--------+--------+----------------------+----------------------+----------------------------------------------------+----------------------------------------------------+--------------+---------------------+----------------------+------------+---------------------+----------------------------------------------------+----------+----------+----------+--------------------+
| StmtText | StmtId | NodeId | Parent | PhysicalOp | LogicalOp | Argument | DefinedValues | EstimateRows | EstimateIO | EstimateCPU | AvgRowSize | TotalSubtreeCost | OutputList | Warnings | Type | Parallel | EstimateExecutions |
+----------------------------------------------------+--------+--------+--------+----------------------+----------------------+----------------------------------------------------+----------------------------------------------------+--------------+---------------------+----------------------+------------+---------------------+----------------------------------------------------+----------+----------+----------+--------------------+
| 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 |
| |--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 |
+----------------------------------------------------+--------+--------+--------+----------------------+----------------------+----------------------------------------------------+----------------------------------------------------+--------------+---------------------+----------------------+------------+---------------------+----------------------------------------------------+----------+----------+----------+--------------------+
```

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.

```ruby
ActiveRecord::ConnectionAdapters::Sqlserver::Showplan::PrinterTable.max_column_width = 500
```

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!

```ruby
ActiveRecord::ConnectionAdapters::SQLServerAdapter.showplan_option = 'SHOWPLAN_XML'
```



## Versions

Expand Down
2 changes: 1 addition & 1 deletion activerecord-sqlserver-adapter.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ Gem::Specification.new do |s|
s.require_path = 'lib'
s.rubyforge_project = 'activerecord-sqlserver-adapter'

s.add_dependency('activerecord', '~> 3.1.0')
s.add_dependency('activerecord', '~> 3.2.0.rc1')
end

Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
require 'set'
require 'active_record/base'
require 'active_record/version'
require 'active_support/concern'
require 'active_support/core_ext/class/attribute'

module ActiveRecord
module ConnectionAdapters
module Sqlserver
Expand Down Expand Up @@ -46,4 +40,3 @@ def coerce_sqlserver_time(*attributes)


ActiveRecord::Base.send :include, ActiveRecord::ConnectionAdapters::Sqlserver::CoreExt::ActiveRecord

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
module ActiveRecord
module ConnectionAdapters
module Sqlserver
module CoreExt
module Explain

SQLSERVER_STATEMENT_PREFIX = "EXEC sp_executesql "
SQLSERVER_PARAM_MATCHER = /@\d+ =/

def exec_explain(queries)
unprepared_queries = queries.map { |sql, bind| [unprepare_sqlserver_statement(sql), bind] }
super(unprepared_queries)
end

private

# This is somewhat hacky, but it should reliably reformat our prepared sql statment
# which uses sp_executesql to just the first argument, then unquote it. Likewise our
# do_exec_query method should substitude the @n args withe the quoted values.
def unprepare_sqlserver_statement(sql)
if sql.starts_with?(SQLSERVER_STATEMENT_PREFIX)
executesql = sql.from(SQLSERVER_STATEMENT_PREFIX.length)
executesql_args = executesql.split(', ')
executesql_args.reject! { |arg| arg =~ SQLSERVER_PARAM_MATCHER }
executesql_args.pop if executesql_args.many?
executesql = executesql_args.join(', ').strip.match(/N'(.*)'/)[1]
Utils.unquote_string(executesql)
else
sql
end
end


end
end
end
end
end

ActiveRecord::Base.extend ActiveRecord::ConnectionAdapters::Sqlserver::CoreExt::Explain
ActiveRecord::Relation.send :include, ActiveRecord::ConnectionAdapters::Sqlserver::CoreExt::Explain
Original file line number Diff line number Diff line change
Expand Up @@ -310,15 +310,14 @@ def valid_isolation_levels

# === SQLServer Specific (Executing) ============================ #

def do_execute(sql, name = nil)
name ||= 'EXECUTE'
def do_execute(sql, name = 'SQL')
log(sql, name) do
with_sqlserver_error_handling { raw_connection_do(sql) }
end
end

def do_exec_query(sql, name, binds)
statement = quote(sql)
explaining = name == 'EXPLAIN'
names_and_types = []
params = []
binds.each_with_index do |(column,value),index|
Expand All @@ -337,10 +336,17 @@ def do_exec_query(sql, name, binds)
raise "Unknown bind columns. We can account for this."
end
quoted_value = ar_column ? quote(v,column) : quote(v,nil)
params << "@#{index} = #{quoted_value}"
params << (explaining ? quoted_value : "@#{index} = #{quoted_value}")
end
if explaining
params.each_with_index do |param, index|
substitute_at_finder = /(@#{index})(?=(?:[^']|'[^']*')*$)/ # Finds unquoted @n values.
sql.sub! substitute_at_finder, param
end
else
sql = "EXEC sp_executesql #{quote(sql)}"
sql << ", #{quote(names_and_types.join(', '))}, #{params.join(', ')}" unless binds.empty?
end
sql = "EXEC sp_executesql #{statement}"
sql << ", #{quote(names_and_types.join(', '))}, #{params.join(', ')}" unless binds.empty?
raw_select sql, name, binds, :ar_result => true
end

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

# === SQLServer Specific (Selecting) ============================ #

def raw_select(sql, name=nil, binds=[], options={})
def raw_select(sql, name='SQL', binds=[], options={})
log(sql,name,binds) { _raw_select(sql, options) }
end

Expand Down
67 changes: 67 additions & 0 deletions lib/active_record/connection_adapters/sqlserver/showplan.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
require 'active_record/connection_adapters/sqlserver/showplan/printer_table'
require 'active_record/connection_adapters/sqlserver/showplan/printer_xml'

module ActiveRecord
module ConnectionAdapters
module Sqlserver
module Showplan

OPTION_ALL = 'SHOWPLAN_ALL'
OPTION_TEXT = 'SHOWPLAN_TEXT'
OPTION_XML = 'SHOWPLAN_XML'
OPTIONS = [OPTION_ALL, OPTION_TEXT, OPTION_XML]

def explain(arel, binds = [])
sql = to_sql(arel)
result = with_showplan_on { do_exec_query(sql, 'EXPLAIN', binds) }
printer = showplan_printer.new(result)
printer.pp
end


protected

def with_showplan_on
set_showplan_option(true)
yield
ensure
set_showplan_option(false)
end

def set_showplan_option(enable = true)
sql = "SET #{option} #{enable ? 'ON' : 'OFF'}"
raw_connection_do(sql)
rescue Exception => e
raise ActiveRecordError, "#{option} could not be turned #{enable ? 'ON' : 'OFF'}, perhaps you do not have SHOWPLAN permissions?"
end

def option
(SQLServerAdapter.showplan_option || OPTION_ALL).tap do |opt|
raise(ArgumentError, "Unknown SHOWPLAN option #{opt.inspect} found.") if OPTIONS.exclude?(opt)
end
end

def showplan_all?
option == OPTION_ALL
end

def showplan_text?
option == OPTION_TEXT
end

def showplan_xml?
option == OPTION_XML
end

def showplan_printer
case option
when OPTION_XML then PrinterXml
when OPTION_ALL, OPTION_TEXT then PrinterTable
else PrinterTable
end
end

end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
module ActiveRecord
module ConnectionAdapters
module Sqlserver
module Showplan
class PrinterTable

cattr_accessor :max_column_width, :cell_padding
self.max_column_width = 50
self.cell_padding = 1

attr_reader :result

def initialize(result)
@result = result
end

def pp
@widths = compute_column_widths
@separator = build_separator
pp = []
pp << @separator
pp << build_cells(result.columns)
pp << @separator
result.rows.each do |row|
pp << build_cells(row)
end
pp << @separator
pp.join("\n") + "\n"
end

private

def compute_column_widths
[].tap do |computed_widths|
result.columns.each_with_index do |column, i|
cells_in_column = [column] + result.rows.map { |r| cast_item(r[i]) }
computed_width = cells_in_column.map(&:length).max
final_width = computed_width > max_column_width ? max_column_width : computed_width
computed_widths << final_width
end
end
end

def build_separator
'+' + @widths.map {|w| '-' * (w + (cell_padding*2))}.join('+') + '+'
end

def build_cells(items)
cells = []
items.each_with_index do |item, i|
cells << cast_item(item).ljust(@widths[i])
end
"| #{cells.join(' | ')} |"
end

def cast_item(item)
case item
when NilClass then 'NULL'
when Float then item.to_s.to(9)
else item.to_s.truncate(max_column_width)
end
end

end

end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module ActiveRecord
module ConnectionAdapters
module Sqlserver
module Showplan
class PrinterXml

def initialize(result)
@result = result
end

def pp
xml = @result.rows.first.first
if defined?(Nokogiri)
Nokogiri::XML(xml).to_xml :indent => 2, :encoding => 'UTF-8'
else
xml
end
end

end

end
end
end
end
4 changes: 4 additions & 0 deletions lib/active_record/connection_adapters/sqlserver/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ class Utils

class << self

def unquote_string(string)
string.to_s.gsub(/\'\'/, "'")
end

def unqualify_table_name(table_name)
table_name.to_s.split('.').last.tr('[]','')
end
Expand Down
Loading

0 comments on commit 7d00131

Please sign in to comment.