Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Add stored procedure support for the MySQL and JDBC adapters (Fixes #252

)

This commit adds support for database stored procedures, with an API
similar to Sequel's prepared statement support, and implemented
internally in a similar way.  While it is directly callable on the
Database object (via #call_sproc), that use is discouraged.  Instead
it should be used at the dataset level with the following API:

  DB[:table].call_sproc(:select, :mysp, 'first param', 'second param')
  # or
  sp = DB[:table].prepare_sproc(:select, :mysp)
  sp.call('first param', 'second param')
  sp.call('third param', 'fourth param')

The only adapters with support for this are MySQL and JDBC (if using
a database that supports it).  Other databases don't even expose this
API.  Adding support to other databases should be fairly easy, though
I have no plans to at present.

The stored procedure implementation is similar to the prepared
statement implementation at the Dataset level.  #call_sproc and
and returning a clone of the dataset,and overriding Dataset#execute
and related functions to add options that are used by
Database#execute to send the request to the Dataset#call_sproc
method.

While working on stored procedure support, it became necessary to fix
the MySQL adapter to make it handle multiple results, since MySQL
stored procedures require that.  This also fixed issues with using
multiple statements at once in the MySQL adapter.  Before, this would
cause a "commands out of sync" error message that wasn't easily
recoverable from.  The MySQL adapter now supports this, though the
JDBC adapter connecting to MySQL still barfs when you attempt this.

Additionally, fix the socket tests in the MySQL adapter to use the same
user, password, and database.
  • Loading branch information...
commit ca1161686df3c4fbd3c802b03cf592cd73e98a6f 1 parent fc6e8e1
@jeremyevans authored
View
4 CHANGELOG
@@ -1,5 +1,9 @@
=== HEAD
+* Support multiple SQL statements in one query in the MySQL adapter (jeremyevans)
+
+* Add stored procedure support for the MySQL and JDBC adapters (jeremyevans, krsgoss) (#252)
+
* Support options when altering a column's type (for changing enums, varchar size, etc.) (jeremyevans)
* Support AliasedExpressions in tables when using implicitly qualified arguments in joins (jeremyevans)
View
65 lib/sequel_core/adapters/jdbc.rb
@@ -1,4 +1,5 @@
require 'java'
+require 'sequel_core/dataset/stored_procedures'
module Sequel
# Houses Sequel's JDBC support when running on JRuby.
@@ -93,6 +94,37 @@ def initialize(opts)
end
end
+ # Execute the given stored procedure with the give name. If a block is
+ # given, the stored procedure should return rows.
+ def call_sproc(name, opts = {})
+ args = opts[:args] || []
+ sql = "{call #{name}(#{args.map{'?'}.join(',')})}"
+ synchronize(opts[:server]) do |conn|
+ cps = conn.prepareCall(sql)
+
+ i = 0
+ args.each{|arg| set_ps_arg(cps, arg, i+=1)}
+
+ begin
+ if block_given?
+ yield cps.executeQuery
+ else
+ case opts[:type]
+ when :insert
+ cps.executeUpdate
+ last_insert_id(conn, opts)
+ else
+ cps.executeUpdate
+ end
+ end
+ rescue NativeException, JavaSQL::SQLException => e
+ raise_error(e)
+ ensure
+ cps.close
+ end
+ end
+ end
+
# Connect to the database using JavaSQL::DriverManager.getConnection.
def connect(server)
setup_connection(JavaSQL::DriverManager.getConnection(uri(server_opts(server))))
@@ -111,6 +143,7 @@ def disconnect
# Execute the given SQL. If a block is given, if should be a SELECT
# statement or something else that returns rows.
def execute(sql, opts={}, &block)
+ return call_sproc(sql, opts, &block) if opts[:sproc]
return execute_prepared_statement(sql, opts, &block) if sql.is_one_of?(Symbol, Dataset)
log_info(sql)
synchronize(opts[:server]) do |conn|
@@ -287,6 +320,8 @@ def connection_pool_default_options
end
class Dataset < Sequel::Dataset
+ include StoredProcedures
+
# Use JDBC PreparedStatements instead of emulated ones. Statements
# created using #prepare are cached at the connection level to allow
# reuse. This also supports bind variables by using unnamed
@@ -313,6 +348,29 @@ def execute_insert(sql, opts={}, &block)
end
end
+ # Use JDBC CallableStatements to execute stored procedures. Only supported
+ # if the underlying database has stored procedure support.
+ module StoredProcedureMethods
+ include Sequel::Dataset::StoredProcedureMethods
+
+ private
+
+ # Execute the database stored procedure with the stored arguments.
+ def execute(sql, opts={}, &block)
+ super(@sproc_name, {:args=>@sproc_args, :sproc=>true, :type=>sql_query_type}.merge(opts), &block)
+ end
+
+ # Same as execute, explicit due to intricacies of alias and super.
+ def execute_dui(sql, opts={}, &block)
+ super(@sproc_name, {:args=>@sproc_args, :sproc=>true, :type=>sql_query_type}.merge(opts), &block)
+ end
+
+ # Same as execute, explicit due to intricacies of alias and super.
+ def execute_insert(sql, opts={}, &block)
+ super(@sproc_name, {:args=>@sproc_args, :sproc=>true, :type=>sql_query_type}.merge(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)
@@ -361,6 +419,13 @@ def prepare(type, name, values=nil)
end
ps
end
+
+ private
+
+ # Extend the dataset with the JDBC stored procedure methods.
+ def prepare_extend_sproc(ds)
+ ds.extend(StoredProcedureMethods)
+ end
end
end
end
View
49 lib/sequel_core/adapters/mysql.rb
@@ -1,5 +1,6 @@
require 'mysql'
require 'sequel_core/adapters/shared/mysql'
+require 'sequel_core/dataset/stored_procedures'
# Add methods to get columns, yield hashes with symbol keys, and do
# type conversion.
@@ -85,6 +86,12 @@ class Database < Sequel::Database
set_adapter_scheme :mysql
+ # Support stored procedures on MySQL
+ def call_sproc(name, opts={}, &block)
+ args = opts[:args] || []
+ execute("CALL #{name}(#{literal(args) unless args.empty?})", opts.merge(:sproc=>false), &block)
+ end
+
# Connect to the database. In addition to the usual database options,
# the following options have effect:
#
@@ -133,6 +140,7 @@ def disconnect
# Executes the given SQL using an available connection, yielding the
# connection if the block is given.
def execute(sql, opts={}, &block)
+ return call_sproc(sql, opts, &block) if opts[:sproc]
return execute_prepared_statement(sql, opts, &block) if Symbol === sql
begin
synchronize(opts[:server]){|conn| _execute(conn, sql, opts, &block)}
@@ -178,11 +186,19 @@ def _execute(conn, sql, opts)
log_info(sql)
conn.query(sql)
if opts[:type] == :select
- r = conn.use_result
- begin
- yield r
- ensure
- r.free
+ loop do
+ begin
+ r = conn.use_result
+ rescue Mysql::Error
+ nil
+ else
+ begin
+ yield r
+ ensure
+ r.free
+ end
+ end
+ break unless conn.respond_to?(:next_result) && conn.next_result
end
else
yield conn if block_given?
@@ -231,6 +247,7 @@ def execute_prepared_statement(ps_name, opts, &block)
# Dataset class for MySQL datasets accessed via the native driver.
class Dataset < Sequel::Dataset
include Sequel::MySQL::DatasetMethods
+ include StoredProcedures
# Methods for MySQL prepared statements using the native driver.
module PreparedStatementMethods
@@ -248,6 +265,23 @@ def execute_dui(sql, opts={}, &block)
end
end
+ # Methods for MySQL stored procedures using the native driver.
+ module StoredProcedureMethods
+ include Sequel::Dataset::StoredProcedureMethods
+
+ private
+
+ # Execute the database stored procedure with the stored arguments.
+ def execute(sql, opts={}, &block)
+ super(@sproc_name, {:args=>@sproc_args, :sproc=>true}.merge(opts), &block)
+ end
+
+ # Same as execute, explicit due to intricacies of alias and super.
+ def execute_dui(sql, opts={}, &block)
+ super(@sproc_name, {:args=>@sproc_args, :sproc=>true}.merge(opts), &block)
+ end
+ end
+
# Delete rows matching this dataset
def delete(opts = nil)
execute_dui(delete_sql(opts)){|c| c.affected_rows}
@@ -309,6 +343,11 @@ def execute(sql, opts={}, &block)
def execute_dui(sql, opts={}, &block)
super(sql, {:type=>:dui}.merge(opts), &block)
end
+
+ # Extend the dataset with the MySQL stored procedure methods.
+ def prepare_extend_sproc(ds)
+ ds.extend(StoredProcedureMethods)
+ end
end
end
end
View
3  lib/sequel_core/dataset/prepared_statements.rb
@@ -37,6 +37,7 @@ def prepared_sql
private
+ # The type of query (:select, :insert, :delete, :update).
def sql_query_type
SQL_QUERY_TYPE[@prepared_type]
end
@@ -51,7 +52,7 @@ def sql_query_type
module PreparedStatementMethods
PLACEHOLDER_RE = /\A\$(.*)\z/
- # The type of prepared statement, should be one of :select,
+ # The type of prepared statement, should be one of :select, :first,
# :insert, :update, or :delete
attr_accessor :prepared_type
View
75 lib/sequel_core/dataset/stored_procedures.rb
@@ -0,0 +1,75 @@
+module Sequel
+ class Dataset
+ module StoredProcedureMethods
+ SQL_QUERY_TYPE = Hash.new{|h,k| h[k] = k}
+ SQL_QUERY_TYPE[:first] = SQL_QUERY_TYPE[:all] = :select
+
+ # The name of the stored procedure to call
+ attr_accessor :sproc_name
+
+ # Call the prepared statement
+ def call(*args, &block)
+ @sproc_args = args
+ case @sproc_type
+ when :select, :all
+ all(&block)
+ when :first
+ first
+ when :insert
+ insert
+ when :update
+ update
+ when :delete
+ delete
+ end
+ end
+
+ # Programmer friendly string showing this is a stored procedure,
+ # showing the name of the procedure.
+ def inspect
+ "<#{self.class.name}/StoredProcedure name=#{@sproc_name}>"
+ end
+
+ # Set the type of the sproc and override the corresponding _sql
+ # method to return the empty string (since the result will be
+ # ignored anyway).
+ def sproc_type=(type)
+ @sproc_type = type
+ meta_def("#{sql_query_type}_sql"){|*a| ''}
+ end
+
+ private
+
+ # The type of query (:select, :insert, :delete, :update).
+ def sql_query_type
+ SQL_QUERY_TYPE[@sproc_type]
+ end
+ end
+
+ module StoredProcedures
+ # For the given type (:select, :first, :insert, :update, or :delete),
+ # run the database stored procedure with the given name with the given
+ # arguments.
+ def call_sproc(type, name, *args)
+ prepare_sproc(type, name).call(*args)
+ end
+
+ # Transform this dataset into a stored procedure that you can call
+ # multiple times with new arguments.
+ def prepare_sproc(type, name)
+ sp = clone
+ prepare_extend_sproc(sp)
+ sp.sproc_type = type
+ sp.sproc_name = name
+ sp
+ end
+
+ private
+
+ # Extend the dataset with the stored procedure methods.
+ def prepare_extend_sproc(ds)
+ ds.extend(StoredProcedureMethods)
+ end
+ end
+ end
+end
View
41 spec/adapters/mysql_spec.rb
@@ -12,7 +12,6 @@
end
MYSQL_URI = URI.parse(MYSQL_DB.uri)
-MYSQL_DB_NAME = (m = /\/(.*)/.match(MYSQL_URI.path)) && m[1]
MYSQL_DB.create_table! :items do
text :name
@@ -482,23 +481,32 @@ def MYSQL_DB.ret_commit
@db << 'DELETE FROM items'
@db[:items].first.should == nil
end
+
+ specify "should handle multiple select statements at once" do
+ @db << 'DELETE FROM items; '
+
+ @db[:items].delete
+ @db[:items].insert(:name => 'tutu', :value => 1234)
+ @db["SELECT * FROM items; SELECT * FROM items"].all.should == \
+ [{:name => 'tutu', :value => 1234}, {:name => 'tutu', :value => 1234}]
+ end
end
# Socket tests should only be run if the MySQL server is on localhost
if %w'localhost 127.0.0.1 ::1'.include? MYSQL_URI.host
context "A MySQL database" do
specify "should accept a socket option" do
- db = Sequel.mysql(MYSQL_DB_NAME, :host => 'localhost', :user => MYSQL_USER, :socket => MYSQL_SOCKET_FILE)
+ db = Sequel.mysql(MYSQL_DB.opts[:database], :host => 'localhost', :user => MYSQL_DB.opts[:user], :password => MYSQL_DB.opts[:password], :socket => MYSQL_SOCKET_FILE)
proc {db.test_connection}.should_not raise_error
end
specify "should accept a socket option without host option" do
- db = Sequel.mysql(MYSQL_DB_NAME, :user => MYSQL_USER, :socket => MYSQL_SOCKET_FILE)
+ db = Sequel.mysql(MYSQL_DB.opts[:database], :user => MYSQL_DB.opts[:user], :password => MYSQL_DB.opts[:password], :socket => MYSQL_SOCKET_FILE)
proc {db.test_connection}.should_not raise_error
end
specify "should fail to connect with invalid socket" do
- db = Sequel.mysql(MYSQL_DB_NAME, :host => 'localhost', :user => MYSQL_USER, :socket => 'blah')
+ db = Sequel.mysql(MYSQL_DB.opts[:database], :user => MYSQL_DB.opts[:user], :password => MYSQL_DB.opts[:password], :socket =>'blah')
proc {db.test_connection}.should raise_error
end
end
@@ -761,3 +769,28 @@ def MYSQL_DB.ret_commit
@d.literal([:x].sql_string_join(' ')).should == "x"
end
end
+
+context "MySQL Stored Procedures" do
+ teardown do
+ MYSQL_DB.execute('DROP PROCEDURE test_sproc')
+ end
+
+ specify "should be callable on the database object" do
+ MYSQL_DB.execute('CREATE PROCEDURE test_sproc() BEGIN DELETE FROM items; END')
+ MYSQL_DB[:items].delete
+ MYSQL_DB[:items].insert(:value=>1)
+ MYSQL_DB[:items].count.should == 1
+ MYSQL_DB.call_sproc(:test_sproc)
+ MYSQL_DB[:items].count.should == 0
+ end
+
+ specify "should be callable on the dataset object" do
+ MYSQL_DB.execute('CREATE PROCEDURE test_sproc(a INTEGER) BEGIN SELECT *, a AS b FROM items; END')
+ @d = MYSQL_DB[:items]
+ @d.call_sproc(:select, :test_sproc, 3).should == []
+ @d.insert(:value=>1)
+ @d.call_sproc(:select, :test_sproc, 4).should == [{:id=>nil, :value=>1, :b=>4}]
+ @d.row_proc = proc{|r| r.keys.each{|k| r[k] *= 2 if r[k].is_a?(Integer)}; r}
+ @d.call_sproc(:select, :test_sproc, 3).should == [{:id=>nil, :value=>2, :b=>6}]
+ end
+end
Please sign in to comment.
Something went wrong with that request. Please try again.