Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Support literal strings with placeholders and subselects in prepared …

…statements

Before, the following two types of prepared statements did not work:

  DB[:items].filter("id = ?", :$i).call(:select, :i=>1)
  DB[:items].filter(:id=>DB[:items].select(:id).filter(:id=>:$i)).call(:select, :i=>1)

The first issue is because a placeholder string was literalized
immediately, before the dataset was extended with the prepared
statement code.  The second issue is because the arguments given in
the main prepared statements weren't passed into any subselects.

This commit fixes both of those issues.  It also makes the name
argument to Dataset#prepare optional.

Fixing the first issue is done by adding an
SQL::PlaceholderLiteralString class that holds the string with
placeholders as well as the arguments, and not literalizing them
until the SQL string is needed.

Fixing the second issue was a lot more work, It is done by adding a
Dataset#subselect_sql private method that literal calls, and
overriding it in the PreparedStatement module that extends the
dataset, which takes the subselect dataset, turns it into a prepared
statement, and does the magic necessary pass the args in
(if the default emulated support is used).  It required changes to
the argument mappers so they didn't rely on instance variables.
Instead of using a hash, they now use an array that is shared with
any subselects.  The mapping code is simpler and the code in
general is more generic.  This does away with prepared_args_hash,
as it is no longer necessary.
  • Loading branch information...
commit 92394164d5fa473d9753cdc60f4ca52bc5288e63 1 parent 7c0583f
@jeremyevans authored
View
2  CHANGELOG
@@ -1,5 +1,7 @@
=== HEAD
+* Support literal strings with placeholders and subselects in prepared statements (jeremyevans)
+
* Have the connection pool remove disconnected connections when the adapter supports it (jeremyevans)
* Make Dataset#exists return a LiteralString (jeremyevans)
View
8 lib/sequel_core/adapters/jdbc.rb
@@ -371,12 +371,6 @@ def execute_insert(sql, opts={}, &block)
end
end
- # Create an unnamed prepared statement and call it. Allows the
- # use of bind variables.
- def call(type, hash, values=nil, &block)
- prepare(type, nil, values).call(hash, &block)
- end
-
# Correctly return rows from the database and return them as hashes.
def fetch_rows(sql, &block)
execute(sql) do |result|
@@ -410,7 +404,7 @@ def literal(v)
# Create a named prepared statement that is stored in the
# database (and connection) for reuse.
- def prepare(type, name, values=nil)
+ def prepare(type, name=nil, values=nil)
ps = to_prepared_statement(type, values)
ps.extend(PreparedStatementMethods)
if name
View
35 lib/sequel_core/adapters/mysql.rb
@@ -249,10 +249,25 @@ class Dataset < Sequel::Dataset
include Sequel::MySQL::DatasetMethods
include StoredProcedures
+ # Methods to add to MySQL prepared statement calls without using a
+ # real database prepared statement and bound variables.
+ module CallableStatementMethods
+ # Extend given dataset with this module so subselects inside subselects in
+ # prepared statements work.
+ def subselect_sql(ds)
+ ps = ds.to_prepared_statement(:select)
+ ps.extend(CallableStatementMethods)
+ ps.prepared_args = prepared_args
+ ps.prepared_sql
+ end
+ end
+
# Methods for MySQL prepared statements using the native driver.
module PreparedStatementMethods
include Sequel::Dataset::UnnumberedArgumentMapper
+ private
+
# Execute the prepared statement with the bind arguments instead of
# the given SQL.
def execute(sql, opts={}, &block)
@@ -282,6 +297,17 @@ def execute_dui(sql, opts={}, &block)
end
end
+ # MySQL is different in that it supports prepared statements but not bound
+ # variables outside of prepared statements. The default implementation
+ # breaks the use of subselects in prepared statements, so extend the
+ # temporary prepared statement that this creates with a module that
+ # fixes it.
+ def call(type, bind_arguments={}, values=nil)
+ ps = to_prepared_statement(type, values)
+ ps.extend(CallableStatementMethods)
+ ps.call(bind_arguments)
+ end
+
# Delete rows matching this dataset
def delete(opts = nil)
execute_dui(delete_sql(opts)){|c| c.affected_rows}
@@ -315,11 +341,14 @@ def literal(v)
# Store the given type of prepared statement in the associated database
# with the given name.
- def prepare(type, name, values=nil)
+ def prepare(type, name=nil, values=nil)
ps = to_prepared_statement(type, values)
ps.extend(PreparedStatementMethods)
- ps.prepared_statement_name = name
- db.prepared_statements[name] = ps
+ if name
+ ps.prepared_statement_name = name
+ db.prepared_statements[name] = ps
+ end
+ ps
end
# Replace (update or insert) the matching row.
View
34 lib/sequel_core/adapters/postgres.rb
@@ -343,11 +343,9 @@ module ArgumentMapper
# Return an array of strings for each of the hash values, inserting
# them to the correct position in the array.
def map_to_prepared_args(hash)
- array = []
- @prepared_args.each{|k,v| array[v] = hash[k].to_s}
- array
+ @prepared_args.map{|k| hash[k.to_sym].to_s}
end
-
+
private
# PostgreSQL most of the time requires type information for each of
@@ -357,21 +355,16 @@ def map_to_prepared_args(hash)
# elminate ambiguity (and PostgreSQL from raising an exception).
def prepared_arg(k)
y, type = k.to_s.split("__")
- "#{prepared_arg_placeholder}#{@prepared_args[y.to_sym]}#{"::#{type}" if type}".lit
- end
-
- # If the named argument has already been used, return the position in
- # the output array that it is mapped to. Otherwise, map it to the
- # next position in the array.
- def prepared_args_hash
- max_prepared_arg = 0
- Hash.new do |h,k|
- h[k] = max_prepared_arg
- max_prepared_arg += 1
+ if i = @prepared_args.index(y)
+ i += 1
+ else
+ @prepared_args << y
+ i = @prepared_args.length
end
+ "#{prepared_arg_placeholder}#{i}#{"::#{type}" if type}".lit
end
end
-
+
# Allow use of bind arguments for PostgreSQL using the pg driver.
module BindArgumentMethods
include ArgumentMapper
@@ -428,11 +421,14 @@ def call(type, hash, values=nil, &block)
# Prepare the given type of statement with the given name, and store
# it in the database to be called later.
- def prepare(type, name, values=nil)
+ def prepare(type, name=nil, values=nil)
ps = to_prepared_statement(type, values)
ps.extend(PreparedStatementMethods)
- ps.prepared_statement_name = name
- db.prepared_statements[name] = ps
+ if name
+ ps.prepared_statement_name = name
+ db.prepared_statements[name] = ps
+ end
+ ps
end
private
View
10 lib/sequel_core/adapters/sqlite.rb
@@ -135,14 +135,6 @@ def map_to_prepared_args(hash)
private
- # Work around for the default prepared statement and argument
- # mapper code, which wants a hash that maps. SQLite doesn't
- # need to do this, but still requires a value for the argument
- # in order for the substitution to work correctly.
- def prepared_args_hash
- true
- end
-
# SQLite uses a : before the name of the argument for named
# arguments.
def prepared_arg(k)
@@ -221,7 +213,7 @@ def literal(v)
# Prepare the given type of query with the given name and store
# it in the database. Note that a new native prepared statement is
# created on each call to this prepared statement.
- def prepare(type, name, values=nil)
+ def prepare(type, name=nil, values=nil)
ps = to_prepared_statement(type, values)
ps.extend(PreparedStatementMethods)
db.prepared_statements[name] = ps if name
View
5 lib/sequel_core/database.rb
@@ -277,9 +277,8 @@ def execute_dui(sql, opts={}, &block)
# DB.fetch('SELECT * FROM items WHERE name = ?', my_name).print
def fetch(sql, *args, &block)
ds = dataset
- sql = sql.gsub('?') {|m| ds.literal(args.shift)}
- ds.opts[:sql] = sql
- ds.fetch_rows(sql, &block) if block
+ ds.opts[:sql] = ds.literal(Sequel::SQL::PlaceholderLiteralString.new(sql, args))
+ ds.each(&block) if block
ds
end
alias_method :>>, :fetch
View
55 lib/sequel_core/dataset/prepared_statements.rb
@@ -25,11 +25,10 @@ def call(hash, &block)
end
# Override the given *_sql method based on the type, and
- # cache the result of the sql. This requires that the object
- # that includes this module implement prepared_args_hash.
+ # cache the result of the sql.
def prepared_sql
return @prepared_sql if @prepared_sql
- @prepared_args = prepared_args_hash
+ @prepared_args ||= []
@prepared_sql = super
meta_def("#{sql_query_type}_sql"){|*args| prepared_sql}
@prepared_sql
@@ -137,6 +136,15 @@ def run(&block)
def prepared_arg(k)
@prepared_args[k]
end
+
+ # Use a clone of the dataset extended with prepared statement
+ # support and using the same argument hash so that you can use
+ # bind variables/prepared arguments in subselects.
+ def subselect_sql(ds)
+ ps = ds.prepare(:select)
+ ps.prepared_args = prepared_args
+ ps.prepared_sql
+ end
end
# Default implementation for an argument mapper that uses
@@ -152,26 +160,15 @@ module UnnumberedArgumentMapper
# Keys in the input hash that are used more than once in the query
# have multiple entries in the output array.
def map_to_prepared_args(hash)
- array = []
- @prepared_args.each{|k,vs| vs.each{|v| array[v] = hash[k]}}
- array
+ @prepared_args.map{|v| hash[v]}
end
private
- # Uses a separate array of each key, holding the positions
- # in the output array (necessary to support arguments
- # that are used more than once).
- def prepared_args_hash
- Hash.new{|h,k| h[k] = Array.new}
- end
-
# Associates the argument with name k with the next position in
# the output array.
def prepared_arg(k)
- @max_prepared_arg ||= 0
- @prepared_args[k] << @max_prepared_arg
- @max_prepared_arg += 1
+ @prepared_args << k
prepared_arg_placeholder
end
end
@@ -182,7 +179,7 @@ def prepared_arg(k)
# insert or update (if one of those types is used),
# which may contain placeholders.
def call(type, bind_variables={}, values=nil)
- to_prepared_statement(type, values).call(bind_variables)
+ prepare(type, nil, values).call(bind_variables)
end
# Prepare an SQL statement for later execution. This returns
@@ -194,17 +191,13 @@ def call(type, bind_variables={}, values=nil)
# ps = prepare(:select, :select_by_name)
# ps.call(:name=>'Blah')
# db.call(:select_by_name, :name=>'Blah')
- def prepare(type, name, values=nil)
- db.prepared_statements[name] = to_prepared_statement(type, values)
+ def prepare(type, name=nil, values=nil)
+ ps = to_prepared_statement(type, values)
+ db.prepared_statements[name] = ps if name
+ ps
end
- private
-
- # The argument placeholder. Most databases used unnumbered
- # arguments with question marks, so that is the default.
- def prepared_arg_placeholder
- PREPARED_ARG_PLACEHOLDER
- end
+ protected
# Return a cloned copy of the current dataset extended with
# PreparedStatementMethods, setting the type and modify values.
@@ -215,5 +208,13 @@ def to_prepared_statement(type, values=nil)
ps.prepared_modify_values = values
ps
end
+
+ private
+
+ # The argument placeholder. Most databases used unnumbered
+ # arguments with question marks, so that is the default.
+ def prepared_arg_placeholder
+ PREPARED_ARG_PLACEHOLDER
+ end
end
-end
+end
View
19 lib/sequel_core/dataset/sql.rb
@@ -495,7 +495,7 @@ def literal(v)
when Date
v.strftime(DATE_FORMAT)
when Dataset
- "(#{v.sql})"
+ "(#{subselect_sql(v)})"
else
raise Error, "can't express #{v.inspect} as a SQL literal"
end
@@ -556,6 +556,14 @@ def ordered_expression_sql(oe)
"#{literal(oe.expression)} #{oe.descending ? 'DESC' : 'ASC'}"
end
+ # SQL fragment for a literal string with placeholders
+ def placeholder_literal_string_sql(pls)
+ args = pls.args.dup
+ s = pls.str.gsub(QUESTION_MARK){literal(args.shift)}
+ s = "(#{s})" if pls.parens
+ s
+ end
+
# SQL fragment for the qualifed identifier, specifying
# a table and a column (or schema and table).
def qualified_identifier_sql(qcr)
@@ -786,7 +794,7 @@ def filter_expr(expr = nil, &block)
SQL::BooleanExpression.from_value_pairs(expr)
when Array
if String === expr[0]
- filter_expr(expr.shift.gsub(QUESTION_MARK){literal(expr.shift)}.lit)
+ SQL::PlaceholderLiteralString.new(expr.shift, expr, true)
else
SQL::BooleanExpression.from_value_pairs(expr)
end
@@ -965,7 +973,12 @@ def split_symbol(sym)
end
end
- # SQL fragement specifying a table name.
+ # SQL fragment for a subselect using the given database's SQL.
+ def subselect_sql(ds)
+ ds.sql
+ end
+
+ # SQL fragment specifying a table name.
def table_ref(t)
case t
when Dataset
View
30 lib/sequel_core/sql.rb
@@ -685,6 +685,34 @@ def to_s(ds)
end
end
+ # Represents a literal string with placeholders and arguments.
+ # This is necessary to ensure delayed literalization of the arguments
+ # required for the prepared statement support
+ class PlaceholderLiteralString < SpecificExpression
+ # The arguments that will be subsituted into the placeholders.
+ attr_reader :args
+
+ # The literal string containing placeholders
+ attr_reader :str
+
+ # Whether to surround the expression with parantheses
+ attr_reader :parens
+
+ # Create an object with the given conditions and
+ # default value.
+ def initialize(str, args, parens=false)
+ @str = str
+ @args = args
+ @parens = parens
+ end
+
+ # Delegate the creation of the resulting SQL to the given dataset,
+ # since it may be database dependent.
+ def to_s(ds)
+ ds.placeholder_literal_string_sql(self)
+ end
+ end
+
# Subclass of ComplexExpression where the expression results
# in a numeric value in SQL.
class NumericExpression < ComplexExpression
@@ -831,7 +859,7 @@ def method_missing(m, *args)
# LiteralString is used to represent literal SQL expressions. A
# LiteralString is copied verbatim into an SQL statement. Instances of
# LiteralString can be created by calling String#lit.
- # LiteralStrings can use all of the SQL::ColumnMethods and the
+ # LiteralStrings can also use all of the SQL::OrderMethods and the
# SQL::ComplexExpressionMethods.
class LiteralString < ::String
include SQL::OrderMethods
View
16 spec/integration/prepared_statement_test.rb
@@ -24,6 +24,22 @@
@ds.filter(:number=>@ds.ba(:$n)).call(:first, :n=>10).should == {:id=>1, :number=>10}
end
+ specify "should support placeholder literal strings" do
+ @ds.filter("number = ?", @ds.ba(:$n)).call(:select, :n=>10).should == [{:id=>1, :number=>10}]
+ end
+
+ specify "should support subselects" do
+ @ds.filter(:id=>:$i).filter(:number=>@ds.select(:number).filter(:number=>@ds.ba(:$n))).filter(:id=>:$j).call(:select, :n=>10, :i=>1, :j=>1).should == [{:id=>1, :number=>10}]
+ end
+
+ specify "should support subselects with literal strings" do
+ @ds.filter(:id=>:$i, :number=>@ds.select(:number).filter("number = ?", @ds.ba(:$n))).call(:select, :n=>10, :i=>1).should == [{:id=>1, :number=>10}]
+ end
+
+ specify "should support subselects of subselects" do
+ @ds.filter(:id=>:$i).filter(:number=>@ds.select(:number).filter(:number=>@ds.select(:number).filter(:number=>@ds.ba(:$n)))).filter(:id=>:$j).call(:select, :n=>10, :i=>1, :j=>1).should == [{:id=>1, :number=>10}]
+ end
+
specify "should support bound variables with insert" do
@ds.call(:insert, {:n=>20, :i=>100}, :id=>@ds.ba(:$i), :number=>@ds.ba(:$n))
@ds.count.should == 2
View
20 spec/sequel_core/dataset_spec.rb
@@ -3132,6 +3132,26 @@ def @ds.fetch_rows(sql, &block)
@ds.filter(:num=>:$n).prepare(:select, :sn).inspect.should == \
'<Sequel::Dataset/PreparedStatement "SELECT * FROM items WHERE (num = $n)">'
end
+
+ specify "should handle literal strings" do
+ @ds.filter("num = ?", :$n).call(:select, :n=>1)
+ @db.sqls.should == ['SELECT * FROM items WHERE (num = 1)']
+ end
+
+ specify "should handle subselects" do
+ @ds.filter(:$b).filter(:num=>@ds.select(:num).filter(:num=>:$n)).filter(:$c).call(:select, :n=>1, :b=>0, :c=>2)
+ @db.sqls.should == ['SELECT * FROM items WHERE ((0 AND (num IN (SELECT num FROM items WHERE (num = 1)))) AND 2)']
+ end
+
+ specify "should handle subselects in subselects" do
+ @ds.filter(:$b).filter(:num=>@ds.select(:num).filter(:num=>@ds.select(:num).filter(:num=>:$n))).call(:select, :n=>1, :b=>0)
+ @db.sqls.should == ['SELECT * FROM items WHERE (0 AND (num IN (SELECT num FROM items WHERE (num IN (SELECT num FROM items WHERE (num = 1))))))']
+ end
+
+ specify "should handle subselects with literal strings" do
+ @ds.filter(:$b).filter(:num=>@ds.select(:num).filter("num = ?", :$n)).call(:select, :n=>1, :b=>0)
+ @db.sqls.should == ['SELECT * FROM items WHERE (0 AND (num IN (SELECT num FROM items WHERE (num = 1))))']
+ end
end
context Sequel::Dataset::UnnumberedArgumentMapper do
Please sign in to comment.
Something went wrong with that request. Please try again.