Skip to content

Commit

Permalink
Add Dataset#unbind for unbinding values from a dataset, for use with …
Browse files Browse the repository at this point in the history
…creating prepared statements

This adds a new ASTTranformer subclass that does the work, by
recognizing some types of ComplexExpressions, extracting
bound variables and substituting them with placeholders.  The
statements can then be prepared and executed (or just called)
with the extracted bound variables.

Also, fix a small issue with using double underscores for
placeholder values in the SQLite native support for argument
mapping.
  • Loading branch information
jeremyevans committed May 25, 2011
1 parent de736e7 commit 78c5cba
Show file tree
Hide file tree
Showing 7 changed files with 210 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CHANGELOG
@@ -1,5 +1,7 @@
=== HEAD

* Add Dataset#unbind for unbinding values from a dataset, for use with creating prepared statements (jeremyevans)

* Add prepared_statements plugin for using prepared statements for updates, inserts, deletes, and lookups by primary key (jeremyevans)

* Make Dataset#[] for model datasets consider a single integer argument as a lookup by primary key (jeremyevans)
Expand Down
2 changes: 1 addition & 1 deletion lib/sequel/adapters/sqlite.rb
Expand Up @@ -226,7 +226,7 @@ def map_to_prepared_args(hash)
# SQLite uses a : before the name of the argument for named
# arguments.
def prepared_arg(k)
LiteralString.new("#{prepared_arg_placeholder}#{k}")
LiteralString.new("#{prepared_arg_placeholder}#{k.to_s.gsub('.', '__')}")
end
end

Expand Down
67 changes: 67 additions & 0 deletions lib/sequel/ast_transformer.rb
Expand Up @@ -109,4 +109,71 @@ def v(o)
end
end
end

# +Unbinder+ is used to take a dataset filter and return a modified version
# that unbinds already bound values and returns a dataset with bound value
# placeholders and a hash of bind values. You can then prepare the dataset
# and use the bound variables to execute it with the same values.
#
# This class only does a limited form of unbinding where the variable names
# and values can be associated unambiguously.
class Unbinder < ASTTransformer
# The <tt>SQL::ComplexExpression<tt> operates that will be considered
# for transformation.
UNBIND_OPS = [:'=', :'!=', :<, :>, :<=, :>=]

# The key classes (first argument of the ComplexExpression) that will
# considered for transformation.
UNBIND_KEY_CLASSES = [Symbol, SQL::Identifier, SQL::QualifiedIdentifier]

# The value classes (second argument of the ComplexExpression) that
# will be considered for transformation.
UNBIND_VALUE_CLASSES = [Numeric, String, Date, Time]

# The hash of bind variables that were extracted from the dataset filter.
attr_reader :binds

# Intialize an empty +binds+ hash.
def initialize
@binds = {}
end

private

# Create a suitable bound variable key for the object, which should be
# an instance of one of the +UNBIND_KEY_CLASSES+.
def bind_key(obj)
case obj
when Symbol, String
obj
when SQL::Identifier
bind_key(obj.value)
when SQL::QualifiedIdentifier
:"#{bind_key(obj.table)}__#{bind_key(obj.column)}"
else
raise Error, "unhandled object in Sequel::Unbinder#bind_key: #{obj}"
end
end

# Handle <tt>SQL::ComplexExpression</tt> instances with suitable ops
# and arguments, substituting the value with a bound variable placeholder
# and assigning it an entry in the +binds+ hash with a matching key.
def v(o)
if o.is_a?(SQL::ComplexExpression) && UNBIND_OPS.include?(o.op)
l, r = o.args
if UNBIND_KEY_CLASSES.any?{|c| l.is_a?(c)} && UNBIND_VALUE_CLASSES.any?{|c| r.is_a?(c)} && !r.is_a?(LiteralString)
key = bind_key(l)
if (old = binds[key]) && old != r
raise UnbindDuplicate, "two different values for #{key.inspect}: #{[r, old].inspect}"
end
binds[key] = r
SQL::ComplexExpression.new(o.op, l, :"$#{key}")
else
super
end
else
super
end
end
end
end
16 changes: 16 additions & 0 deletions lib/sequel/dataset/query.rb
Expand Up @@ -702,6 +702,22 @@ def set_overrides(hash)
clone(:overrides=>hash.merge(@opts[:overrides]||{}))
end

# Unbind bound variables from this dataset's filter and return an array of two
# objects. The first object is a modified dataset where the filter has been
# replaced with one that uses bound variable placeholders. The second object
# is the hash of unbound variables. You can then prepare and execute (or just
# call) the dataset with the bound variables to get results.
#
# ds, bv = DB[:items].filter(:a=>1).unbind
# ds # SELECT * FROM items WHERE (a = $a)
# bv # {:a => 1}
# ds.call(:select, bv)
def unbind
u = Unbinder.new
ds = clone(:where=>u.transform(opts[:where]))
[ds, u.binds]
end

# Returns a copy of the dataset with no filters (HAVING or WHERE clause) applied.
#
# DB[:items].group(:a).having(:a=>1).where(:b).unfiltered
Expand Down
4 changes: 4 additions & 0 deletions lib/sequel/exceptions.rb
Expand Up @@ -44,6 +44,10 @@ class PoolTimeout < Error ; end
# and won't reraise it.
class Rollback < Error ; end

# Exception that occurs when unbinding a dataset that has multiple different values
# for a given variable.
class UnbindDuplicate < Error; end

class Error
AdapterNotFound = Sequel::AdapterNotFound
InvalidOperation = Sequel::InvalidOperation
Expand Down
53 changes: 53 additions & 0 deletions spec/core/dataset_spec.rb
Expand Up @@ -3620,6 +3620,59 @@ def @ds.fetch_rows(sql, &block)
end
end

describe "Sequel::Dataset#unbind" do
before do
@ds = MockDatabase.new[:t]
@u = proc{|ds| ds, bv = ds.unbind; [ds.sql, bv]}
end

specify "should unbind values assigned to equality and inequality statements" do
@ds.filter(:foo=>1).unbind.first.sql.should == "SELECT * FROM t WHERE (foo = $foo)"
@ds.exclude(:foo=>1).unbind.first.sql.should == "SELECT * FROM t WHERE (foo != $foo)"
@ds.filter{foo > 1}.unbind.first.sql.should == "SELECT * FROM t WHERE (foo > $foo)"
@ds.filter{foo >= 1}.unbind.first.sql.should == "SELECT * FROM t WHERE (foo >= $foo)"
@ds.filter{foo < 1}.unbind.first.sql.should == "SELECT * FROM t WHERE (foo < $foo)"
@ds.filter{foo <= 1}.unbind.first.sql.should == "SELECT * FROM t WHERE (foo <= $foo)"
end

specify "should return variables that could be used bound to recreate the previous query" do
@ds.filter(:foo=>1).unbind.last.should == {:foo=>1}
@ds.exclude(:foo=>1).unbind.last.should == {:foo=>1}
end

specify "should handle numerics, strings, dates, times, and datetimes" do
@u[@ds.filter(:foo=>1)].should == ["SELECT * FROM t WHERE (foo = $foo)", {:foo=>1}]
@u[@ds.filter(:foo=>1.0)].should == ["SELECT * FROM t WHERE (foo = $foo)", {:foo=>1.0}]
@u[@ds.filter(:foo=>BigDecimal.new('1.0'))].should == ["SELECT * FROM t WHERE (foo = $foo)", {:foo=>BigDecimal.new('1.0')}]
@u[@ds.filter(:foo=>'a')].should == ["SELECT * FROM t WHERE (foo = $foo)", {:foo=>'a'}]
@u[@ds.filter(:foo=>Date.today)].should == ["SELECT * FROM t WHERE (foo = $foo)", {:foo=>Date.today}]
t = Time.now
@u[@ds.filter(:foo=>t)].should == ["SELECT * FROM t WHERE (foo = $foo)", {:foo=>t}]
dt = DateTime.now
@u[@ds.filter(:foo=>dt)].should == ["SELECT * FROM t WHERE (foo = $foo)", {:foo=>dt}]
end

specify "should not unbind literal strings" do
@u[@ds.filter(:foo=>'a'.lit)].should == ["SELECT * FROM t WHERE (foo = a)", {}]
end

specify "should handle QualifiedIdentifiers" do
@u[@ds.filter{foo__bar > 1}].should == ["SELECT * FROM t WHERE (foo.bar > $foo.bar)", {:foo__bar=>1}]
end

specify "should handle deep nesting" do
@u[@ds.filter{foo > 1}.and{bar < 2}.or(:baz=>3).and({~{:x=>4}=>true}.case(false))].should == ["SELECT * FROM t WHERE ((((foo > $foo) AND (bar < $bar)) OR (baz = $baz)) AND (CASE WHEN (x != $x) THEN 't' ELSE 'f' END))", {:foo=>1, :bar=>2, :baz=>3, :x=>4}]
end

specify "should raise an UnbindDuplicate exception if same variable is used with multiple different values" do
proc{@ds.filter(:foo=>1).or(:foo=>2).unbind}.should raise_error(Sequel::UnbindDuplicate)
end

specify "should handle case where the same variable has the same value in multiple places " do
@u[@ds.filter(:foo=>1).or(:foo=>1)].should == ["SELECT * FROM t WHERE ((foo = $foo) OR (foo = $foo))", {:foo=>1}]
end
end

describe "Sequel::Dataset #with and #with_recursive" do
before do
@db = MockDatabase.new
Expand Down
67 changes: 67 additions & 0 deletions spec/integration/prepared_statement_test.rb
Expand Up @@ -268,3 +268,70 @@
end
end unless INTEGRATION_DB.adapter_scheme == :swift && INTEGRATION_DB.database_type == :postgres

describe "Dataset#unbind" do
before do
@ds = ds = INTEGRATION_DB[:items]
@ct = proc do |t, v|
INTEGRATION_DB.create_table!(:items) do
column :c, t
end
ds.insert(:c=>v)
end
@u = proc{|ds| ds, bv = ds.unbind; ds.call(:first, bv)}
end
after do
INTEGRATION_DB.drop_table(:items)
end

specify "should unbind values assigned to equality and inequality statements" do
@ct[Integer, 10]
@u[@ds.filter(:c=>10)].should == {:c=>10}
@u[@ds.exclude(:c=>10)].should == nil
@u[@ds.filter{c < 10}].should == nil
@u[@ds.filter{c <= 10}].should == {:c=>10}
@u[@ds.filter{c > 10}].should == nil
@u[@ds.filter{c >= 10}].should == {:c=>10}
end

specify "should handle numerics and strings" do
@ct[Integer, 10]
@u[@ds.filter(:c=>10)].should == {:c=>10}
@ct[Float, 0.0]
@u[@ds.filter{c < 1}].should == {:c=>0.0}
@ct[BigDecimal, BigDecimal.new('1.0')]
@u[@ds.filter{c > 0}].should == {:c=>BigDecimal.new('1.0')}
@ct[String, 'foo']
@u[@ds.filter(:c=>'foo')].should == {:c=>'foo'}
end

cspecify "should handle dates and times", [:sqlite] do
@ct[Date, Date.today]
@u[@ds.filter(:c=>Date.today)].should == {:c=>Date.today}
t = Time.now
@ct[Time, t]
@u[@ds.filter{c < t + 1}][:c].to_i.should == t.to_i
end

specify "should handle QualifiedIdentifiers" do
@ct[Integer, 10]
@u[@ds.filter{items__c > 1}].should == {:c=>10}
end

specify "should handle deep nesting" do
INTEGRATION_DB.create_table!(:items) do
Integer :a
Integer :b
Integer :c
Integer :d
end
@ds.insert(:a=>2, :b=>0, :c=>3, :d=>5)
@u[@ds.filter{a > 1}.and{b < 2}.or(:c=>3).and({~{:d=>4}=>{1 => 1}}.case(0=>1))].should == {:a=>2, :b=>0, :c=>3, :d=>5}
@u[@ds.filter{a > 1}.and{b < 2}.or(:c=>3).and({~{:d=>5}=>{1 => 1}}.case(0=>1))].should == nil
end

specify "should handle case where the same variable has the same value in multiple places " do
@ct[Integer, 1]
@u[@ds.filter{c > 1}.or{c < 1}.invert].should == {:c=>1}
@u[@ds.filter{c > 1}.or{c < 1}].should == nil
end
end

0 comments on commit 78c5cba

Please sign in to comment.