You are on page 1of 16

Chapter 18.

Stored Procedures and Functions


Table of Contents

18.1. Stored Routines and the Grant Tables


18.2. Stored Routine Syntax
18.2.1. CREATE PROCEDURE and CREATE FUNCTION Syntax
18.2.2. ALTER PROCEDURE and ALTER FUNCTION Syntax
18.2.3. DROP PROCEDURE and DROP FUNCTION Syntax
18.2.4. CALL Statement Syntax
18.2.5. BEGIN ... END Compound Statement Syntax
18.2.6. DECLARE Statement Syntax
18.2.7. Variables in Stored Routines
18.2.8. Conditions and Handlers
18.2.9. Cursors
18.2.10. Flow Control Constructs
18.3. Stored Procedures, Functions, Triggers, and LAST_INSERT_ID()
18.4. Binary Logging of Stored Routines and Triggers

Stored routines (procedures and functions) are supported in MySQL 5.0. A stored
procedure is a set of SQL statements that can be stored in the server. Once this has been
done, clients don't need to keep reissuing the individual statements but can refer to the
stored procedure instead.

Answers to some questions that are commonly asked regarding stored routines in MySQL
can be found in Section A.4, “MySQL 5.0 FAQ — Stored Procedures”.

MySQL Enterprise. For expert advice on using stored procedures and functions
subscribe to the MySQL Network Monitoring and Advisory Service. For more
information see http://www.mysql.com/products/enterprise/advisors.html.

Some situations where stored routines can be particularly useful:

• When multiple client applications are written in different languages or work on


different platforms, but need to perform the same database operations.
• When security is paramount. Banks, for example, use stored procedures and functions
for all common operations. This provides a consistent and secure environment, and
routines can ensure that each operation is properly logged. In such a setup,
applications and users would have no access to the database tables directly, but can
only execute specific stored routines.

Stored routines can provide improved performance because less information needs to be
sent between the server and the client. The tradeoff is that this does increase the load on
the database server because more of the work is done on the server side and less is done
on the client (application) side. Consider this if many client machines (such as Web
servers) are serviced by only one or a few database servers.
Stored routines also allow you to have libraries of functions in the database server. This is
a feature shared by modern application languages that allow such design internally (for
example, by using classes). Using these client application language features is beneficial
for the programmer even outside the scope of database use.

MySQL follows the SQL:2003 syntax for stored routines, which is also used by IBM's
DB2.

The MySQL implementation of stored routines is still in progress. All syntax described in
this chapter is supported and any limitations and extensions are documented where
appropriate. Further discussion of restrictions on use of stored routines is given in
Section F.1, “Restrictions on Stored Routines and Triggers”.

Binary logging for stored routines takes place as described in Section 18.4, “Binary
Logging of Stored Routines and Triggers”.

Recursive stored procedures are disabled by default, but can be enabled on the server by
setting the max_sp_recursion_depth server system variable to a nonzero value. See
Section 5.2.3, “System Variables”, for more information.

Stored functions cannot be recursive. See Section F.1, “Restrictions on Stored Routines
and Triggers”.

Previous / Next / Up / Table of Contents

User Comments
Posted by [name withheld] on February 7 2004 5:00pm [Delete] [Edit]

You CAN return a result set from a procedure. I was under the impression that it was not
possible because the documentation is a bit, well... lacking in useful examples. It does not
say anything about returning a table as your result of the procedure, at least I didnt see
that, maybe I missed it. Frankly, if it didnt return a result set, I would have no use for
stored procedures.

But it does indeed! :) Here's what I did:


===========================
Delimiter $
create procedure sp_SortPlayerList(
IN SPT varchar(30),
IN TY INT,
IN OB varchar(20)
)
BEGIN
If OB = 'LastName' then
select * from tsdata.Players
where TourneyYear = TY
and Sport = SPT
Order by LastName;
else
select * from tsdata.Players;
end if;
END$
call sp_SortPlayerList('Volleyball', 2004, 'LastName')$
============================

You can see in the 'call sp...' line, that it is not followed by a 'select @parameter' satement
(as it does in the documentation), you just call it and it returns the records.

Also, you do not use parameters within your procedure using an '@'. In the example
above, you do not do: 'If @OB = 'LastName' then'. Leave the '@' off of it. This one really
messed me up.

I hope this helps someone out there, I just spent hours trying to figure this out.

Good luck!

Posted by Sean Countryman on May 7 2004 4:50pm [Delete] [Edit]

I believe that the current MySQL online poll asking for the features that developers such
as us are most looking forward to is very enlightening to this topic. At present, the
number 1 feature is Stored Procedures. I have seen the range of comments both here and
in other forums equating SP's to both bad and good practice. I began my SQL by
avoiding SPs entirely as I had read they were somehow bad practice. As my SQL skills
improved, I began experimenting with Stored Procedures and found that, contrary to the
naysayers, SP's are exceptionally valuable. I have slowly and steadily converted the vast
majority of the Project Management database that I wrote and administer to using SP's
for most data transactions. This has paid off by reducing code and unifying the methods
used to update and insert data across the entire application. I found that I had coded
certain data transactions in different manners in different areas of the application even
though they should have been the same. SP's allowed me to unify this. The other huge
advantage is, of course, precompiling the SP (SQL Server 2000) gives huge speed
advantages. The T-SQL language gives me the ability to call an SP from the application,
and then have the SP execute many various queries and updates contained within
transactions that do lock checks and deadlock handling. I have many 10 page SP's that
run huge amounts of data transactions and exit in milliseconds. I also appreciate the fact
that SP's run on the server, not the client and reduce network traffic. This has given my
application huge speed increases and reduced our network traffic to almost nothing. I am
very much looking forward to complete implementation of Stored Procedures in MySQL
and think that I will likely port the entire application out of MS SQL Server 2000 as soon
as this is done. The only other features I need to be able to do this are Jobs that can be run
by the server on schedules and MySQL also needs to improve Transactions.

In regards to other comments about using SP's to use cursors to process data... I HIGHLY
discourage the use of cursors. To quote another SQL professional that I sadly foget his
name "Cursors are Evil". The fact is that you can probably rewrite nearly any task using a
cursor into a SQL statement (or set of SQL statements). Cursors run extremely slooooow
compared to standard ANSI SQL statements. When my SQL skills were quite novice, I
used a SP to run a cursor to convert huge blocks of data and import them into new tables.
The procedure took just over 2 hours to process 11GB of data. When my SQL skills
improved, I rewrote that SP, removing the cursor and replacing it with a complex, nested,
pure SQL statement. The new SP ran in 5 minutes and did the entire 11GB data
processing. Cursors should be avoided at all costs in a production environment. In lieu of
using a cursor, I recommend you learn more about SQL.

Posted by dave mausner on June 29 2004 7:58pm [Delete] [Edit]

The prior comment about the usefulness of stored procedures is TRUE; the comment
about avoiding cursors at all costs is FALSE.

One must always use the appropriate tool in its most advantageous context. One bad
programming choice does not prove that the tool is bad.

For example, it is possible for a compiler to prepare the SQL in a declared CURSOR in
advance, so that repeated fetches of the cursor do not require repeated prepares of the
same SQL. This can avoid a huge amount of server traffic, especially for one-row result
sets of primary key look-ups.

We don't yet know how MySQL compiles cursors, but Oracle cursors can be many times
FASTER in the example i just gave.

Posted by Tsoi Pui Hang on August 12 2004 5:17am [Delete] [Edit]

There are too few examples about stored procedures up to now. I post a simple example
here and I hope it is useful to beginners of MySQL like me :)

create table catagory


(
catagory_id int unsigned not null auto_increment,
name varchar(50) not null,
description text,
primary key (catagory_id)

) type=innodb;
create table catagory_set
(
master_id int unsigned not null,
slave_id int unsigned not null,
index(master_id),
index(slave_id),
primary key (master_id,slave_id),
foreign key (master_id) references catagory (catagory_id) on delete cascade,
foreign key (slave_id) references catagory (catagory_id) on delete cascade

) type=innodb;

drop procedure add_catagory;


delimiter ?
create procedure add_catagory (IN param1 int, IN param2 char(50),
IN param3 text, OUT cid int, OUT error_msg char(80))
begin
declare master_id, master_exist, name_exist int;
set cid = -1;
set name_exist = 0, master_exist = 0;

# Insert a subcatagory #
if param1 > 0 then

# Check if the master catagory ID is valid #


select count(catagory_id), catagory_id into master_exist, master_id
from catagory where catagory_id=param1 group by catagory_id;

# Check if the same catagory name exist and the master catagory #
select count(catagory_id) into name_exist from catagory, catagory_set
where catagory.name=param2 and catagory.catagory_id=catagory_set.slave_id
and catagory_set.master_id=master_id;

if master_exist > 0 and name_exist = 0 then

lock tables catagory write, catagory_set write;


flush table catagory, catagory_set;
insert into catagory values (null, param2, param3);
select last_insert_id() into cid;
insert into catagory_set values (param1, cid);
unlock tables;

elseif master_exist = 0 then


set error_msg = 'The master catagory ID provided does not exist';
elseif name_exist > 0 then
set error_msg = 'The catagory name already exist, please choose another name';

end if;
# Insert a primary catagory #
else
# Search and compare the name of all primary catagory #
select count(catagory_id) into name_exist from catagory
where name = param2 and not exists(
select * from catagory_set
where catagory_set.slave_id = catagory.catagory_id
);
if name_exist > 0 then
set error_msg = 'The catagory name already exist, please choose another name';
else
insert into catagory values (null, param2, param3);
select last_insert_id() into cid;
end if;

end if;
end ?
delimiter ;

call add_catagory(1,'Planet','Earth',@cid,@error);
select @cid, @error;

Posted by Nathan Wallbridge on September 17 2004 6:59am [Delete] [Edit]

Here is some code I used to geocode a dataset based on the NG Field and parcel number
(UK Ordinance survey) Useful if you are working with GIS data.

I have cut out some of the case statement as it is very repetitive and you can easily look it
up, mainly this example show how you can manipulate data then use your result to update
existing tables.

delimiter //

CREATE PROCEDURE GEOCODE03()


BEGIN
DECLARE X_digit_one CHAR(1);
DECLARE Y_digit_one CHAR(1);
DECLARE INTOSSHEET INT;
DECLARE INTNGFIELD INT;
DECLARE strOSSHEET CHAR(50);
DECLARE strNGFIELD CHAR(50);
DECLARE lngGeoLoop INT;
DECLARE lngXINT INT;
DECLARE lngYINT INT;
DECLARE INTCOUNT INT;
DECLARE INTNUMROWS INT;
DECLARE intMIKEY INT;
DECLARE STRXINT CHAR(50);
DECLARE STRYINT CHAR(50);
DECLARE FIRSTDIG CHAR(2);
DECLARE GeoCodeCUR CURSOR FOR SELECT
lpaSheetReference,lpaFieldNumber,UID FROM MIXCheckDB.IACS2003GIS;
OPEN GeoCodeCUR;
SELECT COUNT(*) INTO INTNUMROWS FROM MIXCheckDB.IACS2003GIS;
SET INTCOUNT = INTNUMROWS;
WHILE INTCOUNT > 0 DO
FETCH GeoCodeCUR INTO strOSSHEET,strNGFIELD,intMIKEY;
SET FIRSTDIG = LEFT(strOSSHEET,2);
CASE FIRSTDIG
WHEN "SR" THEN
SET X_digit_one = "1";
SET Y_digit_one = "1";
WHEN "SM" THEN
SET X_digit_one = "1";
SET Y_digit_one = "2";
WHEN "SS" THEN
SET X_digit_one = "2";
SET Y_digit_one = "1";
WHEN "SN" THEN
SET X_digit_one = "2";
SET Y_digit_one = "2";
....(Lots more of the case statement here)...
ELSE
SET X_digit_one = "0";
SET Y_digit_one = "0";
END CASE;
SET STRXINT = CONCAT(X_digit_one, MID(strOSSHEET,3,2),
LEFT(strNGFIELD,2), '0');
SET STRYINT = CONCAT(Y_digit_one, RIGHT(strOSSHEET,2),
RIGHT(strNGFIELD,2),'0');
SET lngXINT = STRXINT;
SET lngYINT = STRYINT;
UPDATE MIXCheckDB.IACS2003GIS SET xcoord = lngXINT, ycoord = lngYINT
WHERE MIXCheckDB.IACS2003GIS.UID = intMIKEY;
SET INTCOUNT = INTCOUNT - 1;
END WHILE;
CLOSE GeoCodeCUR;
END

Posted by Kai Price on September 14 2005 11:10am [Delete] [Edit]

Confused?!

Read this:
http://dev.mysql.com/tech-resources/articles/mysql-storedprocedures.pdf

Posted by S Giacinto on October 9 2005 3:52pm [Delete] [Edit]

Just a comment:
Using SQL stored procedures has been the only correct way to write web applications
when using oracle, mssql or other enterprise database servers(meaning ones people have
to pay for) - The introduction of SPs in MySQL was a long time coming and it now puts
it on the map as a serious alternative to the RDBMSs named above. I never considered it
a serious alternative for any project before it had SPs and I think there are many who feel
this way. Hats off to the team of people who made this happen, it will be big like never
before.

Posted by Mike Heath on October 26 2005 10:31pm [Delete] [Edit]

MySQL as of version 5.0.15 does not support recursive stored procedures. This is
contrary to the 'hierarchy2' example found here http://dev.mysql.com/tech-
resources/articles/mysql-storedprocedures.pdf

Posted by Joel Hoard on December 12 2005 2:36am [Delete] [Edit]

Maybe I'm the only one to have this problem, but I noticed that since the variable names
don't start with "@" or anything to signify them as variables, you have to be very careful
about naming. For example I tried to run:

DELIMITER |
CREATE PROCEDURE sp_getUserInfo(IN userID INT)
BEGIN
SELECT * FROM users WHERE userID = userID;
END|

It returned all rows in the table. I was very curious about why this was happening, then I
changed the variable name to _userID and it only returned the correct row. There aren't
many examples and this might cost people some time trying to figure out what's going
on.
Posted by Paul Pikowsky on February 24 2006 6:01pm [Delete] [Edit]

You can get a Selection set back from a procedure, but apparently you can't just replace a
Select statement in PHP with a Call statement.

Create Procedure Procedure_Select_Statement


Begin
Select statement ...
End##

If I replace mysql_query("Select statement ... ", $Connection) with mysql_query("Call


Procedure_Select_Statement()", $Connection), I get an error, "PROCEDURE
Database.Procedure_Select_Statement can't return a result set in the given context"

Posted by Patrick Rikhof on March 1 2006 11:00am [Delete] [Edit]

@ Joel Hoard:

Thatswhy i always use my fields this way: `table`.`field` Then you could have
`table`.`userid` = userid :)

Posted by Joseph Wilk on March 17 2006 12:38pm [Delete] [Edit]

You can make a procedure act like a select and hence return a result set.
I was initially confused by statements that procedures could not return values. It appears
that as the person above mentioned temporary tables or as my example shows using
prepared statements can achieve a returned value.

As this example shows, the key is to use prepared statements:

CREATE PROCEDURE `getTaxonomiesContent_internal`()


NOT DETERMINISTIC
SQL SECURITY DEFINER
COMMENT ''
BEGIN
SET @sql = "SELECT * from example";
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END;

Try it out calling it from command line mysql, you will see a lovely result set returned.
NOTE: since dynamic sql statements are not allowed in functions this only works in
stored procedures.

Posted by Marc Grue on June 13 2006 12:13pm [Delete] [Edit]

How to pass arrays to stored procedures from php to isolate records using the "WHERE
col IN (param_list)" syntax. Examples of how to delete multiple rows and select multiple
rows.

Example:
$id_array = array(3,6,7,12,78);
$id_list = implode(',', $id_array);

Would have loved to do this:


CREATE PROCEDURE `delete_in/select_in`(id_list VARCHAR(200))
BEGIN
DELETE/SELECT FROM table WHERE id IN (id_list);
END
.. and call
$mysqli->query("CALL delete_in($id_list)"); or
$mysqli->query("CALL select_in($id_list)");
.. but this doesn't work :(

Since "WHERE id IN ()" only accepts specified params (a, b, n) and not a comma-
separated list ('a,b,n'), we need to either supply each parameter which is useless since we
usually don't know how many we expect, or we could delete multiple records one by one
in a loop:

CREATE PROCEDURE `delete_one`(_id INT)


BEGIN
DELETE FROM table WHERE id=_id;
END

foreach ($id_array AS $i => $id) {


$mysqli->query("CALL delete_one($id)");
}

But this seems to be a bad solution also since we call the database count($id_array) times.
Better to make the loop inside a procedure:

First we need to split the $id_list into separate ids. Thanks to


http://forge.mysql.com/snippets/view.php?id=4 we can do this and save the values into a
temporary table 'SplitValues':

CREATE PROCEDURE `split_string`(IN input TEXT, IN delimiter VARCHAR(10) )


BEGIN
DECLARE cur_position INT DEFAULT 1 ;
DECLARE remainder TEXT;
DECLARE cur_string VARCHAR(1000);
DECLARE delimiter_length TINYINT UNSIGNED;

DROP TEMPORARY TABLE IF EXISTS SplitValues;


CREATE TEMPORARY TABLE SplitValues (
value VARCHAR(1000) NOT NULL PRIMARY KEY
) ENGINE=MEMORY;

SET remainder = input;


SET delimiter_length = CHAR_LENGTH(delimiter);

WHILE CHAR_LENGTH(remainder) > 0 AND cur_position > 0 DO


SET cur_position = INSTR(remainder, delimiter);
IF cur_position = 0 THEN
SET cur_string = remainder;
ELSE
SET cur_string = LEFT(remainder, cur_position - 1);
END IF;
IF TRIM(cur_string) != '' THEN
-- multiple inserts inside this helper procedure...
INSERT INTO SplitValues VALUES (cur_string);
END IF;
SET remainder = SUBSTRING(remainder, cur_position + delimiter_length);
END WHILE;
END

Now we can retrive the ids easily in both SELECT and DELETE procedures:

CREATE PROCEDURE `select_many`(IN id_list TEXT)


BEGIN
CALL split_string(id_list, ',');
SELECT * FROM table WHERE id IN (SELECT value FROM SplitValues);
END

CREATE PROCEDURE `delete_many`(IN id_list TEXT)


BEGIN
CALL split_string(id_list, ',');
DELETE FROM table WHERE id IN (SELECT value FROM SplitValues);
END

The DELETE procedure could even be trimmed, so string splitting and deletion happens
in the same procedure:

CREATE PROCEDURE `delete_many`(IN id_list TEXT)


BEGIN

DECLARE delimiter VARCHAR(1) DEFAULT ',';


DECLARE cur_position INT DEFAULT 1;
DECLARE remainder TEXT;
DECLARE cur_string VARCHAR(1000);
DECLARE delimiter_length TINYINT UNSIGNED;

SET remainder = id_list;


SET delimiter_length = CHAR_LENGTH(delimiter);

WHILE CHAR_LENGTH(remainder) > 0 AND cur_position > 0 DO


SET cur_position = INSTR(remainder, delimiter);
IF cur_position = 0 THEN
SET cur_string = remainder;
ELSE
SET cur_string = LEFT(remainder, cur_position - 1);
END IF;

IF TRIM(cur_string) != '' THEN


-- multiple deletes inside main procedure...
DELETE FROM table WHERE id=cur_string;
END IF;
SET remainder = SUBSTRING(remainder, cur_position + delimiter_length);
END WHILE;

END

Easy execution is now possible from php:


$mysqli->query("CALL select_many($id_list)"); or
$mysqli->query("CALL delete_many($id_list)");

Hope this helps :) Suggestions, corrections and better solutions are welcome!

Posted by Robert Folkerts on June 14 2006 2:17pm [Delete] [Edit]

@ Joseph Wilk

Thanks for the example of using the Prepare and Execute statements. This sort of
statement allows for great flexiblity in writing dyanamic SQL. For example, I have
written stored procedures that generate a string that created standard CRUD (Create,
Retrieve, Update, Delete) procedures given only the table name as an input. It querried
the metadata to get column names and identify primary keys and so forth. For big
projects, this was a time saver and you cannot do this sort of 'meta programming' without
an execute command that can run arbitrary SQL. This is the only time that I can recall
neededing to use an execute statement in a stored procedure.
The fact that a prepare statement is in a stored procedure means that the SQL within the
stored procedure cannot be optimized when the stored procedure is created. This means
that either the SQL is not optimized or it must be optimized each time the procedure is
called. I don't know which of these MySQL does, but you are better off avoiding the
'execute' statement when performance is an issue. (Performance was not an issue in my
example, the sproc was run once per table during development & the sproc was much
faster than a developer)

The prepare and execute statements are powerful, but they should only be used if there is
no other reasonable option.

Posted by Clifford Hill on August 2 2006 9:38pm [Delete] [Edit]

@Paul Pikowsky

Actually, it will work, you just need to make sure you can handle multiple result sets
being returned. I don't know how that works in PHP, but I have seen the "can't return a
result set in the given context" error in other languages (Java, C++, Python, Tcl) and it
was all because of the need to be able to handle multiple result sets (that is a mysql flag
which should be passable in your connection or otherwise able to be set). Good luck
finding how to set that flag in PHP.

Posted by Graham Jordan on November 16 2006 1:17pm [Delete] [Edit]

HERE IT IS:
For all of you getting "can't return a result set in the given context" errors when using
PHP to execute stored procedures,
the mysql_connect flag is:

mysql_connect( host, databaseuser,password,TRUE, 131074)

Worked with mysql 5.0.20 and PHP 5.1.4


Also, stored procedures seem to close the connection when they've finished running in
PHP.
Can be fixed using mysql_ping( db_resource_id ) to reinstate lost connections

Posted by Steve Carter on January 11 2007 8:42pm [Delete] [Edit]

Try the following if, like me, you have had difficulties making dynamic references to
tables work. Let's say you have a series of tables, identical in structure `tbl_1`, `tbl_2`,
`tbl_3`, etc... that you wish to work on from one stored procedure.

Rather than using excessive SET, PREPARE, EXECUTE statements (with their many
restrictions), use ALTER TABLE (RENAME TABLE doesn't work for temporary tables)
to rename the table to a fixed name at the start of the stored procedure and revert to the
original name at the end. This allows the procedure to flow smoothly and efficiently and
avoids problems with variables. A major benefit is also that it allows you to return a
recordset from the SELECT statement, which is not possible if you construct the query
using SET PREPARE EXECUTE. Example:

/*---------------------------------------------------------------------------------------------------------
-*/
DELIMITER $$
DROP PROCEDURE IF EXISTS proc_name$$
CREATE PROCEDURE proc_name(IN table_num INT)
BEGIN

/* rename table */
SET @s = CONCAT("ALTER TABLE tbl_", table_num, " RENAME tbl_tempname;");
PREPARE stmt FROM @s;
EXECUTE stmt;

/* your code then goes in the middle here with any references to your table like this, etc.
*/
SELECT * FROM tbl_tempname;

/* then change the table name back again at end of procedure */


SET @s = CONCAT("ALTER TABLE tbl_tempname RENAME tbl_", table_num, ";");
PREPARE stmt FROM @s;
EXECUTE stmt;

END$$
DELIMITER ;
/*---------------------------------------------------------------------------------------------------------
-*/

When calling the routine, you'd pass the table number part in something like this, i.e. to
work on `tbl_2`

CALL proc_name(2);

Hope this is of use to someone.

Posted by Peter Morgan on January 10 2007 2:36am [Delete] [Edit]

For editing or updating a row the same procedure can be used. Here's an example of the
style I use. Passing zero as the ID inserts, otherwise it updates

DELIMITER $$
DROP PROCEDURE IF EXISTS `link_edit`$$
CREATE PROCEDURE `io`.`link_edit` (_link_id int, _title varchar(20), _url
varchar(150))
/* Inserts or update a web link, 0 as link_id means insert
returns the new or updated link row
*/
BEGIN
declare _new_id int;

case _link_id

/* new link */
when 0 then
insert into links (title, url) values(_title, _url);
select last_insert_id() into _link_id;

/* update link */
else
update links set title=_title, url=_url where link_id=_link_id;
end case;

/* return Row */
select link_id, title, url from links where link_id = _link_id;

END$$
DELIMITER ;

Posted by Hakon Haugnes on August 9 2007 1:42pm [Delete] [Edit]

As a note to help others wanting to call a procedure from within a procedure: To call a
stored procedure within a stored procedure, and get the result out, you must use an
"OUT" variable, and call the procedure with it.

E.g.

CREATE PROCEDURE proc1 (IN data varchar(30), OUT result varchar(30))


...

Then call it later inside another procedure like this:

CALL proc1(data,result);
/* result is now populated with the result from proc1 */
SELECT result;

I thought this was not obvious from the current documentation - hope it helps someone.
The comments here are the best documentation of stored procedures so far - thanks!
Add your own comment.

You might also like