Bashed this together after reading Ola B.'s post about his annoyances
about the MySQL centric approach of ActiveRecord at
I decided to find out if it was really that bad, and I suppose it might
be. I spent some time trying to get the type information about columns
from ActiveRecord into the driver, but I couldn’t come up with a good
way to do it.
So I started over and wrote this query cache thingie into the
PostgreSQL driver. It’s a giant hack, ugly and buggy, but it passes all
ActiveRecord tests except one (where it results in two queries being
made, one for the prepare and another for the execute, instead of a
single query doing select) and I managed to try it out on a site of
mine in development mode.
With prepared statements I got:
Completed in 1.59062 (0 reqs/sec) | Rendering: 1.31451 (82%) | DB:
0.25888 (16%) | 200 OK [http://192.168.0.46/characters]
Completed in 1.61538 (0 reqs/sec) | Rendering: 1.36034 (84%) | DB:
0.23845 (14%) | 200 OK [http://192.168.0.46/characters]
Without prepared statements:
Completed in 1.74804 (0 reqs/sec) | Rendering: 1.23182 (70%) | DB:
0.49718 (28%) | 200 OK [http://192.168.0.46/characters]
Completed in 1.67329 (0 reqs/sec) | Rendering: 1.21762 (72%) | DB:
0.43935 (26%) | 200 OK [http://192.168.0.46/characters]
So that’s a nice speed up, and while that might be a poor example
beause of the high number of repeated selects in it, I think that
prepared statements should be supported in some way by ActiveRecord, as
soon as possible.
It’s a giant hack and I’m actually surprised it works at all, I’m not
proud of this code but it told me what I wanted to know, whether or not
prepared statements really are that cool or not (turns out they are).
Index:
activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
(revision 5147)
+++
activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
(working copy)
@@ -1,4 +1,5 @@
require ‘active_record/connection_adapters/abstract_adapter’
+require ‘csv’
module ActiveRecord
class Base
@@ -54,6 +55,8 @@
def initialize(connection, logger, config = {})
super(connection, logger)
-
@statements = {}
-
@prepared_statements = [] @config = config @async = config[:allow_concurrency] configure_connection
@@ -141,8 +144,184 @@
result.first if result
end
-
def try_prepared_select(sql)
-
# split the query into the statement part (select a from x
left join y…) and the conditions part (where b = c and x.d = y.d)
-
return nil unless sql =~ /WHERE/
-
statement, conditions = sql.split("WHERE", 2)
-
return nil if conditions == nil
-
# try to find a "primary table", for simple queries where
column names aren’t fully qualified
-
# select name from developers where name = 'Joel'
-
# ^-- this is the primary table
-
# vs
-
# select developers.name from developers where
developers.name = ‘Joel’
-
# if we find columns without a table in the conditions, we
prefix them with the primary table
-
# we don't always get a primary table, but when we don't, all
columns are fully qualified
-
sql =~ /FROM ([\w\s,]+?)\s+(WHERE|LEFT|INNER|OUTER)/
-
primary_table = $1.strip if $1
-
# table aliases cause all kinds of trouble, since we need to
scan the real table to find column types
-
# select name from developers d where d.name = 'Joel'
-
# ^ ^-- we need to translate
this into “developers.name” when trying to find out the type
-
# \-- d is an alias for
developers
-
alias_table = {}
-
tables = []
-
if primary_table && (primary_table =~ /\w+\s+\w+/) # aliases
-
primary_table.split(/,/).each_with_index { |table_alias, i|
-
pri, ali = table_alias.strip.split(/\s+/)
-
pri.strip!
-
ali.strip!
-
primary_table = pri if i == 0
-
tables << pri
-
alias_table[ali] = pri
-
}
-
end
-
tables << primary_table if primary_table
-
tables = tables.sort.uniq
-
# next we need to normalize the conditions, since the
following two statements are functionally the same and should use the
same stored procedure
-
# select name from developers d where d.name = 'Joel'
-
# select name from developers d where d.name = 'Andreas'
-
# so, we should normalize the conditions into "name = $1" for
the comparison and for the prepared statement
-
variables_values = []
conditions.split(/\s+(AND|OR|LIMIT|ORDER|OFFSET|GROUP|HAVING|FOR)\s+/i).each
{ |part|
-
variable, operand, value =
part.strip.split(/\s*(=|>|>=|<>|<|<=|!=)\s*/, 3)
-
if variable and value
-
r1 = Regexp.new(/^[\(\)\s]*/) # remove leading
parantheses and spaces …
-
r2 = Regexp.new(/[\(\)\s]*$/) # ... and remove trailing
parantheses and spaes
-
r3 = Regexp.new(/^\w+\(/)
-
variable.sub!(r1, '')
-
variable.sub!(r2, '')
-
variable.sub!(r3, '')
-
value.sub!(r1, '')
-
value.sub!(r2, '')
-
if value =~ /^('|"|\d)/
-
variables_values << [ variable, operand, value ]
-
end
-
end
-
}
-
return nil if variables_values.length == 0
-
# make the replacements and store the columns involved
-
variables = [ ]
-
aliased_columns = [ ]
-
normalized_conditions = conditions
-
variables_values.each_with_index { |v,i|
-
name, operand, value = v
-
variables << value
-
aliased_columns << name
-
normalized_conditions.sub!(/\s*#{operand}\s*#{value}/,
“#{operand} $#{i+1}”)
-
normalized_conditions.sub!(/\s*#{operand}\s*#{value}/,
“#{operand} $#{i+1}”)
-
}
-
# keep track of how many times we've seen this statement
-
if @statements.has_key?(statement+normalized_conditions)
-
@statements[statement+normalized_conditions] += 1
-
else
-
@statements[statement+normalized_conditions] = 0
-
end
-
# now that we have the variables and normalized conditions, we
can look for a prepared statement
-
if
@prepared_statements.include?(statement+normalized_conditions)
-
# if we have one, go for it
-
number =
@prepared_statements.index(statement+normalized_conditions)
-
return execute("EXECUTE prep#{number}(#{variables.join(',
')})")
-
# if we have some number of hits on this statement, we deem it
worthy of being turned into a prepared statement
-
elsif @statements.fetch(statement+normalized_conditions, 0) >
3
-
columns = []
-
# here we want to match d.name and turn it into
developers.name, and save it for the next step
-
aliased_columns.each { |c|
-
table, column = c.split(/\./)
-
table = alias_table[table] if alias_table[table]
-
if column
-
columns << "#{table}.#{column.strip.gsub(/\"/, "")}"
-
else
-
columns << "#{primary_table}.#{table.strip.gsub(/\"/,
“”)}"
-
end
-
}
-
# now we need to find the type of all the columns in the
conditions. we should find out that developers.name is a char
varying(255)
-
# the first step is to store an array of columns for every
table in condition_columns[table] = [ column1, column2, … ]
-
columns
-
condition_columns = { }
-
columns.uniq.each { |c|
-
t, c = c.split("\.")
-
t.strip! if t
-
c.strip! if c
-
if condition_columns[t]
-
condition_columns[t] << c
-
else
-
condition_columns[t] = [ c ]
-
end
-
}
-
# then we go through every table and find the types for
(hopefully) all the columns we care about
-
column_types = { }
-
condition_columns.each_key { |t|
-
column_definitions(t).collect do |name, type, default,
notnull, typmod|
-
if condition_columns[t].include?(name)
-
column_types["#{t}.#{name}"] = type
-
end
-
end
-
}
-
# and now that we finally have all types of the columns
where interested in…
-
argument_types = []
-
columns.each { |c|
-
return nil unless column_types.has_key?(c) # actually
sometimes we don’t, and in that case we give up
-
argument_types << column_types[c]
-
}
-
# ... but when we do, we can prepare a statement
-
execute("PREPARE
prep#{@prepared_statements.length}(#{argument_types.join(', ')}) AS
#{statement} WHERE #{normalized_conditions}")
-
@prepared_statements << (statement+normalized_conditions)
-
number =
@prepared_statements.index(statement+normalized_conditions)
-
return execute("EXECUTE prep#{number}(#{variables.join(',
')})")
-
end
-
return nil
-
end
-
def try_prepared_insert(sql)
-
statement = sql.split("VALUES")[0]
-
values = sql.split("VALUES")[1]
-
statement =~ /INSERT INTO (\w+) \((.+)\)/i
-
table = $1
-
columns = $2
-
if @statements.fetch(statement, 0) > 3
-
value_array = CSV::parse_line(values).map { |e| e.to_s.strip
}
-
column_array = CSV::parse_line(columns.gsub(/\"/, "")).map {
|e| e.to_s.strip }
-
argument_types = {}
-
if tables.include?(table)
-
column_definitions(table).collect do |name, type, default,
notnull, typmod|
-
if column_array.include?(name)
-
argument_types[name] = type
-
column_array.delete(name)
-
end
-
end
-
if column_array.length == 0 # we matched all columns
-
type_array = []
-
column_array = CSV::parse_line(columns.gsub(/\"/,
“”)).map { |e| e.to_s.strip }
-
column_array.each { |c| type_array << argument_types[c]
}
-
unless @prepared_statements.include?(statement)
-
dollar_arguments = ((1 .. column_array.length).map {
|i| ‘$’ + “#{i}” }).join(", ")
-
execute("PREPARE prep#{@prepared_statements.length}
(#{type_array.join(', ‘)}) AS INSERT INTO
#{table}(#{column_array.join(’, ')}) VALUES(#{dollar_arguments})")
-
@prepared_statements << statement
-
end
-
number = @prepared_statements.index(statement)
-
return execute("EXECUTE prep#{number}#{values}")
-
end
-
end
-
else
-
if @statements.has_key?(statement)
-
@statements[statement] += 1
-
else
-
@statements[statement] = 0
-
end
-
end
-
return nil
-
end
-
def insert(sql, name = nil, pk = nil, id_value = nil,
sequence_name = nil) #:nodoc:
-
execute(sql, name)
-
execute(sql, name) if try_prepared_insert(sql) == nil table = sql.split(" ", 4)[2] id_value || last_insert_id(table, sequence_name ||
default_sequence_name(table, pk))
end
@@ -405,7 +584,8 @@
end
def select(sql, name = nil)
-
res = execute(sql, name)
-
res = try_prepared_select(sql)
-
res = execute(sql, name) if res == nil results = res.result rows = [] if results.length > 0