Overview
Stored procedures and functions are database objects that execute SQL statements and procedural logic on the database server. A stored procedure performs operations that may modify data and return multiple result sets, while a function computes and returns a single value or table. Both accept input parameters, execute precompiled code, and run with the privileges of the database user who created them.
Database systems compile and optimize stored procedures and functions when created, storing the execution plan for reuse. When an application calls a stored procedure, the database server executes the precompiled code without parsing SQL statements each time. This compilation happens once, reducing overhead on subsequent executions.
The distinction between procedures and functions centers on their return behavior and usage context. Functions return a value and can appear in SQL expressions, while procedures execute as standalone statements and may return multiple result sets or output parameters. A function calculating a discount percentage can embed directly in a SELECT statement, but a procedure processing an order executes as a separate call.
-- Function returning a scalar value
CREATE FUNCTION calculate_discount(amount DECIMAL, tier VARCHAR)
RETURNS DECIMAL
BEGIN
RETURN CASE tier
WHEN 'gold' THEN amount * 0.20
WHEN 'silver' THEN amount * 0.10
ELSE amount * 0.05
END;
END;
-- Stored procedure with multiple operations
CREATE PROCEDURE process_order(
IN customer_id INT,
IN order_total DECIMAL,
OUT order_number INT
)
BEGIN
INSERT INTO orders (customer_id, total, created_at)
VALUES (customer_id, order_total, NOW());
SET order_number = LAST_INSERT_ID();
UPDATE customers
SET total_spent = total_spent + order_total
WHERE id = customer_id;
END;
Most relational databases support stored procedures and functions, including PostgreSQL, MySQL, Oracle, SQL Server, and DB2. Each database system implements its own procedural language: PL/pgSQL for PostgreSQL, Transact-SQL for SQL Server, PL/SQL for Oracle. The syntax and capabilities vary significantly between systems, though basic concepts remain consistent.
Key Principles
Stored procedures and functions execute within the database server process, accessing data without network round trips between application and database. When an application needs to perform multiple related operations, sending individual SQL statements requires multiple network calls. A stored procedure combines these operations into a single server-side execution, reducing latency and network traffic.
Parameter handling differs between procedures and functions. Stored procedures support IN parameters for input values, OUT parameters for return values, and INOUT parameters that serve both purposes. Functions accept only input parameters and communicate results through their return value. The parameter modes determine how data flows between the caller and the database routine.
-- Procedure with multiple parameter modes
CREATE PROCEDURE get_customer_stats(
IN customer_id INT,
OUT order_count INT,
OUT total_spent DECIMAL,
INOUT status_flag VARCHAR
)
BEGIN
SELECT COUNT(*), SUM(total)
INTO order_count, total_spent
FROM orders
WHERE customer_id = customer_id;
IF total_spent > 10000 THEN
SET status_flag = 'premium';
END IF;
END;
Transaction control within stored procedures enables atomic operations across multiple statements. A procedure can start a transaction, perform several data modifications, and commit or roll back based on conditions. If any operation fails, the procedure rolls back all changes, maintaining data consistency. This transaction boundary cannot extend beyond the procedure's execution scope in most databases.
The execution context determines which database objects a procedure can access. Procedures execute with either definer rights or invoker rights. Definer rights execute with the permissions of the user who created the procedure, allowing controlled access to restricted data. Invoker rights execute with the caller's permissions, applying the same access controls as direct SQL execution.
Stored functions must remain deterministic or non-deterministic based on their declaration. A deterministic function returns the same result for the same inputs, enabling query optimization and index usage. Non-deterministic functions may return different results for identical inputs, such as functions using random numbers or current timestamps. The database system relies on this declaration for optimization decisions.
-- Deterministic function for query optimization
CREATE FUNCTION calculate_tax(amount DECIMAL, rate DECIMAL)
RETURNS DECIMAL
DETERMINISTIC
BEGIN
RETURN amount * rate;
END;
-- Non-deterministic function with varying results
CREATE FUNCTION generate_order_code()
RETURNS VARCHAR
NOT DETERMINISTIC
BEGIN
RETURN CONCAT('ORD-', UNIX_TIMESTAMP(), '-', FLOOR(RAND() * 1000));
END;
Result set handling separates procedures from functions. Stored procedures can return zero or more result sets by executing SELECT statements without INTO clauses. The calling application receives each result set sequentially. Functions return a single scalar value or table, integrating directly into SQL queries. A function cannot execute SELECT statements that generate result sets for the caller.
Error handling within procedural code uses condition handlers and exception blocks. When an error occurs during execution, the procedure can catch the error, log information, perform cleanup operations, or re-raise the error to the caller. Unhandled errors automatically roll back any uncommitted transactions, though this behavior varies between database systems.
Temporary tables and variables provide working storage during procedure execution. A procedure can create temporary tables visible only within the current session, populate them with intermediate results, and query them as needed. Local variables hold scalar values for calculations and control flow. These objects exist only for the procedure's duration and do not persist after execution completes.
Design Considerations
The decision to implement logic in stored procedures versus application code involves trade-offs in maintainability, performance, and deployment complexity. Stored procedures centralize business rules within the database, ensuring all applications enforce the same logic regardless of programming language or framework. Application code distributes logic across multiple codebases, complicating consistency but improving portability.
Database vendor lock-in intensifies with stored procedure usage. Each database system implements its own procedural language with incompatible syntax and features. A codebase with extensive stored procedures cannot easily migrate to a different database platform without rewriting all procedural code. Application logic written in Ruby, Python, or Java runs on any database with standard SQL support.
Testing and debugging stored procedures requires database-specific tools and techniques. Unit testing a stored procedure involves creating test data, executing the procedure within a transaction, asserting results, and rolling back changes. Modern application code benefits from established testing frameworks, mocking libraries, and IDE integration. Debugging a stored procedure often means examining execution with database-specific debuggers or inserting logging statements.
Version control and deployment processes differ between stored procedures and application code. Stored procedures exist as database objects, requiring schema migration tools to manage versions. An application deployment must include database migrations that create, modify, or drop stored procedures. Source control tracks procedure definitions as SQL scripts, but the actual running code resides in the database until migrations execute.
-- Migration creating a stored procedure
CREATE OR REPLACE PROCEDURE archive_old_orders()
BEGIN
INSERT INTO orders_archive
SELECT * FROM orders
WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR);
DELETE FROM orders
WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR);
END;
Performance characteristics favor stored procedures for data-intensive operations with multiple database round trips. A procedure that validates customer data, inserts an order, updates inventory, and creates audit records completes all operations within the database server. The equivalent application code sends multiple SQL statements over the network, incurring latency for each round trip. The performance advantage grows with operation count and network latency.
Data access patterns influence the stored procedure decision. Operations that filter, transform, and aggregate large datasets benefit from server-side execution. The database engine processes data using indexes and optimized algorithms without transferring rows to the application. Application code that fetches large result sets for processing consumes network bandwidth and memory.
Security requirements sometimes necessitate stored procedures. When applications should not have direct table access, stored procedures provide a controlled interface with parameter validation and access checks. The procedure executes with elevated privileges while applications run with minimal permissions. This security model prevents SQL injection attacks and restricts data access to defined operations.
Team expertise affects stored procedure adoption. Teams proficient in database development and comfortable with procedural SQL can maintain stored procedures effectively. Teams focused on application development may struggle with the different paradigms, testing approaches, and debugging tools required for database programming. The learning curve for database-specific procedural languages adds training overhead.
Horizontal scaling challenges emerge with business logic in stored procedures. Scaling application servers is straightforward: add more servers behind a load balancer. Scaling database servers with stored procedures means scaling the entire database tier, including replication, sharding, and connection pooling complexity. Application logic scales independently from data storage.
Ruby Implementation
Ruby applications interact with stored procedures and functions through database adapter gems. The specific API depends on the database system and gem used. ActiveRecord, Sequel, and low-level adapters like pg and mysql2 provide mechanisms to call stored procedures and retrieve results.
PostgreSQL stored functions (procedures are called functions in PostgreSQL) can be invoked using ActiveRecord's connection object. The execute method runs raw SQL, including function calls. For functions returning scalar values, use SELECT statements with the function invocation.
# Calling a PostgreSQL function that returns a scalar value
class OrderCalculator
def self.calculate_discount(amount, tier)
result = ActiveRecord::Base.connection.execute(
"SELECT calculate_discount(#{amount}, '#{tier}')"
)
result.first['calculate_discount'].to_f
end
end
# Using parameterized queries for safety
class CustomerService
def self.get_customer_stats(customer_id)
sql = "SELECT * FROM get_customer_stats($1)"
result = ActiveRecord::Base.connection.exec_query(sql, 'SQL', [[nil, customer_id]])
result.first
end
end
The pg gem provides direct access to PostgreSQL stored procedures through the connection object. Use the exec_params method for parameterized queries, which prevents SQL injection and handles type conversion. The result object contains rows, fields, and metadata from the stored procedure execution.
require 'pg'
class DatabaseConnection
def initialize
@conn = PG.connect(dbname: 'myapp', user: 'postgres')
end
def call_stored_procedure(customer_id)
# Call procedure with output parameters
sql = <<-SQL
CALL process_customer_order($1, $2, $3)
SQL
result = @conn.exec_params(sql, [customer_id, 100.00, 'pending'])
result.each do |row|
puts "Order: #{row['order_number']}"
end
end
def call_function_returning_table
# Call function that returns a table
sql = "SELECT * FROM get_recent_orders($1)"
result = @conn.exec_params(sql, [30])
result.map do |row|
{
id: row['id'].to_i,
customer_id: row['customer_id'].to_i,
total: row['total'].to_f,
created_at: row['created_at']
}
end
end
end
MySQL stored procedures require a different approach due to MySQL's protocol for handling multiple result sets. The mysql2 gem supports the client-side flag CLIENT_MULTI_RESULTS to enable stored procedure calls that return result sets. Without this flag, calling a stored procedure raises an error.
require 'mysql2'
class MysqlProcedureCaller
def initialize
@client = Mysql2::Client.new(
host: 'localhost',
username: 'root',
database: 'myapp',
flags: Mysql2::Client::MULTI_RESULTS
)
end
def call_procedure_with_results(customer_id)
results = []
# Call stored procedure
@client.query("CALL get_customer_orders(#{customer_id})")
# Process first result set
result = @client.store_result
result.each do |row|
results << row
end
# MySQL procedures may return multiple result sets
# Move to next result set if it exists
while @client.next_result
result = @client.store_result
# Process additional result sets if needed
end
results
end
def call_function(amount, rate)
# Call a MySQL function
result = @client.query("SELECT calculate_tax(#{amount}, #{rate}) as tax")
result.first['tax'].to_f
end
end
ActiveRecord doesn't provide a high-level API for stored procedures, requiring raw SQL execution. Creating a wrapper class provides a cleaner interface and handles parameter binding, result processing, and error handling.
class StoredProcedure
def self.call(procedure_name, *args)
placeholders = args.each_with_index.map { |_, i| "$#{i + 1}" }.join(', ')
sql = "SELECT * FROM #{procedure_name}(#{placeholders})"
result = ActiveRecord::Base.connection.exec_query(
sql,
'Stored Procedure',
args.map { |arg| [nil, arg] }
)
result.to_a
end
def self.call_procedure(procedure_name, *args)
placeholders = args.each_with_index.map { |_, i| "$#{i + 1}" }.join(', ')
sql = "CALL #{procedure_name}(#{placeholders})"
ActiveRecord::Base.connection.execute(sql, args)
end
end
# Usage
class OrderService
def process_order(customer_id, items)
result = StoredProcedure.call('create_order', customer_id, items.to_json)
result.first
end
end
Handling stored procedure errors requires catching database-specific exceptions and extracting error information. PostgreSQL raises PG::Error for stored procedure failures, while MySQL raises Mysql2::Error. The error object contains the original SQL state code and error message from the database.
class ProcedureExecutor
def safe_execute(procedure_name, *args)
begin
StoredProcedure.call(procedure_name, *args)
rescue ActiveRecord::StatementInvalid => e
# Parse database error
if e.message.include?('insufficient_funds')
raise InsufficientFundsError, "Cannot process transaction: #{e.message}"
elsif e.message.include?('invalid_customer')
raise InvalidCustomerError, "Customer not found or inactive"
else
raise DatabaseError, "Stored procedure error: #{e.message}"
end
rescue PG::Error, Mysql2::Error => e
# Handle low-level database errors
Rails.logger.error("Database error calling #{procedure_name}: #{e.message}")
raise DatabaseConnectionError, "Database unavailable"
end
end
end
Output parameters from stored procedures require special handling depending on the database system. PostgreSQL functions return values directly, while MySQL procedures use OUT parameters accessed through session variables.
# PostgreSQL function with multiple return values
class PostgresService
def self.get_statistics(customer_id)
sql = <<-SQL
SELECT
order_count,
total_spent,
status_flag
FROM get_customer_stats($1)
SQL
result = ActiveRecord::Base.connection.exec_query(sql, 'SQL', [[nil, customer_id]])
result.first
end
end
# MySQL procedure with OUT parameters
class MysqlService
def self.process_with_output(customer_id)
client = Mysql2::Client.new(...)
# Set user variables for OUT parameters
client.query("SET @order_num = 0")
client.query("SET @status = ''")
# Call procedure with OUT parameters
client.query("CALL process_order(#{customer_id}, @order_num, @status)")
# Retrieve OUT parameter values
result = client.query("SELECT @order_num as order_number, @status as status")
result.first
end
end
Practical Examples
A customer management system uses stored procedures to handle complex business operations that span multiple tables. The procedure ensures data consistency by executing all operations within a single transaction, rolling back if any step fails.
CREATE PROCEDURE update_customer_status(
IN p_customer_id INT,
IN p_new_status VARCHAR,
OUT p_previous_status VARCHAR,
OUT p_affected_orders INT
)
BEGIN
DECLARE v_error BOOLEAN DEFAULT FALSE;
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET v_error = TRUE;
START TRANSACTION;
-- Get previous status
SELECT status INTO p_previous_status
FROM customers
WHERE id = p_customer_id
FOR UPDATE;
-- Update customer status
UPDATE customers
SET status = p_new_status,
updated_at = NOW()
WHERE id = p_customer_id;
-- Update related orders
UPDATE orders
SET customer_status = p_new_status
WHERE customer_id = p_customer_id
AND status IN ('pending', 'processing');
SET p_affected_orders = ROW_COUNT();
-- Insert audit record
INSERT INTO status_changes (customer_id, old_status, new_status, changed_at)
VALUES (p_customer_id, p_previous_status, p_new_status, NOW());
IF v_error THEN
ROLLBACK;
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'Failed to update customer status';
ELSE
COMMIT;
END IF;
END;
The Ruby application calls this procedure and handles the results:
class CustomerStatusUpdater
def update_status(customer_id, new_status)
sql = <<-SQL
CALL update_customer_status($1, $2, $3, $4)
SQL
begin
result = ActiveRecord::Base.connection.execute(
ActiveRecord::Base.sanitize_sql([sql, customer_id, new_status, nil, nil])
)
# Extract output parameters from result
row = result.first
{
success: true,
previous_status: row['p_previous_status'],
affected_orders: row['p_affected_orders'].to_i
}
rescue ActiveRecord::StatementInvalid => e
Rails.logger.error("Status update failed: #{e.message}")
{ success: false, error: e.message }
end
end
end
A reporting system uses functions to perform complex calculations that would be inefficient in application code. The function processes large datasets using database indexes and aggregations, returning summarized results.
CREATE FUNCTION calculate_monthly_metrics(p_year INT, p_month INT)
RETURNS TABLE (
metric_name VARCHAR,
metric_value DECIMAL,
comparison_previous_month DECIMAL
)
LANGUAGE plpgsql
AS $$
DECLARE
v_current_start DATE;
v_current_end DATE;
v_previous_start DATE;
v_previous_end DATE;
BEGIN
v_current_start := make_date(p_year, p_month, 1);
v_current_end := v_current_start + INTERVAL '1 month' - INTERVAL '1 day';
v_previous_start := v_current_start - INTERVAL '1 month';
v_previous_end := v_current_start - INTERVAL '1 day';
-- Revenue metrics
RETURN QUERY
SELECT
'revenue'::VARCHAR,
SUM(total)::DECIMAL,
(SUM(total) / NULLIF(prev_total, 0) - 1) * 100
FROM orders
CROSS JOIN (
SELECT SUM(total) as prev_total
FROM orders
WHERE created_at BETWEEN v_previous_start AND v_previous_end
) prev
WHERE created_at BETWEEN v_current_start AND v_current_end;
-- Order count metrics
RETURN QUERY
SELECT
'order_count'::VARCHAR,
COUNT(*)::DECIMAL,
(COUNT(*) / NULLIF(prev_count, 0) - 1) * 100
FROM orders
CROSS JOIN (
SELECT COUNT(*) as prev_count
FROM orders
WHERE created_at BETWEEN v_previous_start AND v_previous_end
) prev
WHERE created_at BETWEEN v_current_start AND v_current_end;
END;
$$;
The Ruby application retrieves and formats these metrics:
class MonthlyReport
def generate(year, month)
sql = "SELECT * FROM calculate_monthly_metrics($1, $2)"
results = ActiveRecord::Base.connection.exec_query(
sql,
'Monthly Metrics',
[[nil, year], [nil, month]]
)
metrics = results.map do |row|
{
name: row['metric_name'],
value: row['metric_value'].to_f,
change_percentage: row['comparison_previous_month'].to_f.round(2)
}
end
format_report(metrics)
end
private
def format_report(metrics)
metrics.each_with_object({}) do |metric, hash|
hash[metric[:name]] = {
current: metric[:value],
change: "#{metric[:change_percentage] > 0 ? '+' : ''}#{metric[:change_percentage]}%"
}
end
end
end
A data validation procedure checks business rules before allowing data modifications. The procedure returns a result set with validation errors, allowing the application to present detailed feedback.
CREATE PROCEDURE validate_order_data(
IN p_customer_id INT,
IN p_items JSON,
IN p_shipping_address JSON
)
BEGIN
CREATE TEMPORARY TABLE validation_errors (
field VARCHAR,
error_message VARCHAR
);
-- Validate customer exists and is active
IF NOT EXISTS (
SELECT 1 FROM customers
WHERE id = p_customer_id AND status = 'active'
) THEN
INSERT INTO validation_errors VALUES ('customer_id', 'Invalid or inactive customer');
END IF;
-- Validate items exist and have sufficient inventory
IF JSON_LENGTH(p_items) = 0 THEN
INSERT INTO validation_errors VALUES ('items', 'Order must contain at least one item');
END IF;
-- Validate shipping address format
IF JSON_EXTRACT(p_shipping_address, '$.postal_code') IS NULL THEN
INSERT INTO validation_errors VALUES ('shipping_address', 'Postal code is required');
END IF;
-- Check credit limit
IF (
SELECT total_spent + JSON_EXTRACT(p_items, '$[*].price')
FROM customers WHERE id = p_customer_id
) > (
SELECT credit_limit FROM customers WHERE id = p_customer_id
) THEN
INSERT INTO validation_errors VALUES ('credit_limit', 'Order exceeds customer credit limit');
END IF;
-- Return all validation errors
SELECT * FROM validation_errors;
DROP TEMPORARY TABLE validation_errors;
END;
The Ruby validation service calls this procedure and returns structured errors:
class OrderValidator
ValidationError = Struct.new(:field, :message)
def validate(customer_id, items, shipping_address)
sql = "CALL validate_order_data($1, $2, $3)"
result = ActiveRecord::Base.connection.exec_query(
sql,
'Order Validation',
[
[nil, customer_id],
[nil, items.to_json],
[nil, shipping_address.to_json]
]
)
errors = result.map do |row|
ValidationError.new(row['field'], row['error_message'])
end
ValidationResult.new(errors)
end
end
class ValidationResult
attr_reader :errors
def initialize(errors)
@errors = errors
end
def valid?
errors.empty?
end
def errors_by_field
errors.group_by(&:field).transform_values do |field_errors|
field_errors.map(&:message)
end
end
end
Performance Considerations
Network latency dominates performance in chatty applications that execute multiple SQL statements sequentially. Each round trip between application and database introduces milliseconds of overhead. A stored procedure executing ten statements incurs one network round trip instead of ten, reducing total execution time significantly. The performance gain increases with network latency and statement count.
Query execution plans for stored procedures compile once and cache for reuse. The database parses SQL, optimizes the execution plan, and stores the compiled procedure. Subsequent executions skip parsing and optimization, directly executing the cached plan. This compilation advantage diminishes for statements executed infrequently, where the overhead of compiling occurs anyway.
Data transfer volume affects performance when applications fetch large result sets for processing. A stored procedure that filters and aggregates a million rows returns a few summary values to the application. The equivalent application code fetches all million rows over the network, processes them in memory, and discards most data. The stored procedure approach minimizes network transfer and memory usage.
-- Stored procedure reducing data transfer
CREATE FUNCTION get_sales_summary(p_start_date DATE, p_end_date DATE)
RETURNS TABLE (
category VARCHAR,
total_sales DECIMAL,
average_order DECIMAL,
order_count INT
)
AS $$
SELECT
category,
SUM(total) as total_sales,
AVG(total) as average_order,
COUNT(*) as order_count
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.created_at BETWEEN p_start_date AND p_end_date
GROUP BY category
ORDER BY total_sales DESC;
$$ LANGUAGE sql;
Parameter binding optimization occurs when prepared statements reuse execution plans with different parameter values. Stored procedures benefit from this optimization automatically. Applications using raw SQL must explicitly prepare statements to achieve similar performance. The database matches parameter types and can use indexes effectively when procedures use parameterized queries internally.
Lock contention increases when stored procedures hold locks for extended periods. A long-running procedure executing multiple statements within a transaction locks all affected rows until completion. Concurrent operations block waiting for lock release. Short procedures that complete quickly release locks sooner, improving concurrency. Breaking complex procedures into smaller units can reduce lock duration at the cost of additional round trips.
Memory consumption in stored procedures depends on temporary table usage, variable allocation, and result set buffering. Procedures that create large temporary tables consume memory proportional to data size. Cursor operations may hold entire result sets in memory. Database configuration limits memory available to individual connections, potentially causing out-of-memory errors for memory-intensive procedures.
-- Memory-efficient cursor processing
CREATE PROCEDURE process_large_dataset()
BEGIN
DECLARE done BOOLEAN DEFAULT FALSE;
DECLARE v_id INT;
DECLARE v_data VARCHAR;
DECLARE cur CURSOR FOR
SELECT id, data FROM large_table WHERE processed = 0;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_id, v_data;
IF done THEN
LEAVE read_loop;
END IF;
-- Process one row at a time
CALL process_single_row(v_id, v_data);
END LOOP;
CLOSE cur;
END;
Index usage within stored procedures follows the same optimization rules as standalone queries. The query optimizer analyzes each statement in the procedure and selects appropriate indexes. Poorly written procedures that bypass indexes or force table scans perform badly regardless of network optimization. Examining execution plans for statements within procedures reveals optimization opportunities.
Connection pooling behavior changes with stored procedures. Long-running procedures hold database connections from the pool, potentially exhausting available connections. Applications waiting for connections block until procedures complete. Designing procedures to execute quickly and release connections promptly maintains pool availability. Splitting batch operations across multiple procedure calls distributes load and prevents connection starvation.
Parallel execution within stored procedures depends on database capabilities. PostgreSQL can parallelize certain operations within a single query, but procedural logic executes sequentially. Multiple procedure calls from different connections can execute in parallel, utilizing multiple CPU cores. Database configuration controls parallelism and resource allocation for concurrent procedure execution.
Common Pitfalls
Missing output parameter handling causes silent failures in Ruby applications. A stored procedure with OUT parameters returns those values, but the calling code must explicitly retrieve them. Ignoring output parameters means losing important return values like generated IDs, counts, or status codes. Different database systems and gems handle output parameters differently.
# Incorrect: Ignoring output parameters
def process_order_wrong(customer_id)
ActiveRecord::Base.connection.execute(
"CALL create_order(#{customer_id}, @order_id)"
)
# @order_id is lost - not returned to Ruby
end
# Correct: Retrieving output parameters (MySQL)
def process_order_correct(customer_id)
conn = Mysql2::Client.new(...)
conn.query("CALL create_order(#{customer_id}, @order_id)")
result = conn.query("SELECT @order_id as order_id")
result.first['order_id']
end
SQL injection vulnerabilities persist even with stored procedures when parameter concatenation occurs. A stored procedure that builds dynamic SQL using string concatenation remains vulnerable. The Ruby application must still use parameterized queries when calling procedures, but the procedure implementation must also avoid SQL injection.
-- Vulnerable stored procedure
CREATE PROCEDURE search_orders_vulnerable(IN p_search_term VARCHAR)
BEGIN
SET @sql = CONCAT('SELECT * FROM orders WHERE customer_name LIKE ''%',
p_search_term, '%''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END;
-- Safe stored procedure
CREATE PROCEDURE search_orders_safe(IN p_search_term VARCHAR)
BEGIN
SELECT * FROM orders
WHERE customer_name LIKE CONCAT('%', p_search_term, '%');
END;
Transaction scope confusion occurs when applications assume stored procedures manage transactions independently. A procedure starting a transaction affects the application's transaction state. Calling a procedure within an application transaction creates nested transaction behavior, which some databases don't support. Understanding transaction boundaries prevents unexpected rollbacks or commits.
# Problematic: Nested transaction confusion
ActiveRecord::Base.transaction do
customer = Customer.create!(name: 'John')
# This procedure starts its own transaction
# Behavior varies by database - might cause errors
ActiveRecord::Base.connection.execute(
"CALL process_with_transaction(#{customer.id})"
)
# If procedure commits, changes persist even if outer transaction rolls back
end
# Better: Let application manage transactions
ActiveRecord::Base.transaction do
customer = Customer.create!(name: 'John')
# Procedure doesn't manage transactions
ActiveRecord::Base.connection.execute(
"CALL process_without_transaction(#{customer.id})"
)
end
Multiple result set handling fails when Ruby code doesn't process all result sets from a stored procedure. MySQL procedures that execute multiple SELECT statements return multiple result sets. The client must retrieve each result set to completion before executing additional queries. Failing to retrieve all result sets causes "Commands out of sync" errors.
# Incorrect: Not processing all result sets
def call_multi_result_procedure
client = Mysql2::Client.new(...)
client.query("CALL get_multiple_results()")
# Missing next_result calls - causes errors
client.query("SELECT 1") # ERROR: Commands out of sync
end
# Correct: Processing all result sets
def call_multi_result_procedure_correct
client = Mysql2::Client.new(flags: Mysql2::Client::MULTI_RESULTS)
client.query("CALL get_multiple_results()")
# Process first result
result = client.store_result
# Process remaining results
while client.next_result
client.store_result
end
# Now safe to execute new queries
client.query("SELECT 1")
end
Error handling gaps appear when applications don't catch database-specific exceptions from stored procedures. A procedure raising an error generates a database-specific exception. Generic exception handling masks the actual error cause. Specific error handling based on SQL state codes enables appropriate recovery strategies.
Type conversion mismatches cause subtle bugs when Ruby interprets stored procedure results incorrectly. Database decimal values may return as strings, requiring explicit conversion. Date and time handling varies between databases and Ruby. JSON columns need parsing after retrieval. Missing type conversion leads to calculation errors and comparison failures.
# Problematic: No type conversion
def calculate_total_wrong
result = ActiveRecord::Base.connection.execute(
"SELECT calculate_order_total(123)"
)
total = result.first['calculate_order_total']
# total is a string, not a number
total + 10 # String concatenation, not addition
end
# Correct: Explicit type conversion
def calculate_total_correct
result = ActiveRecord::Base.connection.execute(
"SELECT calculate_order_total(123) as total"
)
total = result.first['total'].to_f
total + 10 # Numeric addition
end
Database connection leaks occur when procedures hold connections without proper cleanup. A procedure call that fails mid-execution may leave the connection in an inconsistent state. Transaction rollback may not happen automatically. Proper exception handling with connection cleanup prevents resource leaks.
Procedure redefinition during application runtime causes version skew. If a database migration updates a stored procedure while application instances run, different instances may call different procedure versions. Coordinating deployments to update procedures and application code simultaneously prevents inconsistent behavior. Blue-green deployments handle this more safely.
Reference
Common Stored Procedure Operations
| Operation | PostgreSQL | MySQL | SQL Server |
|---|---|---|---|
| Create procedure | CREATE FUNCTION | CREATE PROCEDURE | CREATE PROCEDURE |
| Call procedure | SELECT function() | CALL procedure() | EXEC procedure |
| Drop procedure | DROP FUNCTION | DROP PROCEDURE | DROP PROCEDURE |
| List procedures | \df in psql | SHOW PROCEDURE STATUS | sys.procedures |
| View definition | \df+ function | SHOW CREATE PROCEDURE | sp_helptext |
| Modify procedure | CREATE OR REPLACE | DROP and CREATE | ALTER PROCEDURE |
Parameter Modes
| Mode | Direction | Usage | Example |
|---|---|---|---|
| IN | Input only | Pass values to procedure | IN customer_id INT |
| OUT | Output only | Return values from procedure | OUT total_orders INT |
| INOUT | Both directions | Pass and return modified values | INOUT status VARCHAR |
| RETURNS | Function return | Return single value from function | RETURNS DECIMAL |
Ruby Database Gem Comparison
| Gem | Stored Procedure Support | Result Set Handling | Output Parameters |
|---|---|---|---|
| pg | Native support | Single result per call | Use SELECT to retrieve |
| mysql2 | Requires MULTI_RESULTS flag | Multiple result sets | Session variables |
| ActiveRecord | Raw SQL required | Limited support | Varies by adapter |
| Sequel | Native support | Good handling | Adapter-specific |
Transaction Control in Procedures
| Statement | Effect | Notes |
|---|---|---|
| START TRANSACTION | Begin transaction | Some DBs use BEGIN |
| COMMIT | Save changes | Ends transaction |
| ROLLBACK | Discard changes | Ends transaction |
| SAVEPOINT | Create savepoint | For partial rollback |
| ROLLBACK TO SAVEPOINT | Restore to savepoint | Transaction continues |
| SET TRANSACTION ISOLATION | Set isolation level | Must be first statement |
Error Handling Patterns
| Database | Syntax | Usage |
|---|---|---|
| PostgreSQL | EXCEPTION WHEN | BEGIN/EXCEPTION/END blocks |
| MySQL | DECLARE HANDLER | DECLARE CONTINUE/EXIT HANDLER |
| SQL Server | TRY/CATCH | BEGIN TRY/BEGIN CATCH blocks |
| Oracle | EXCEPTION WHEN | EXCEPTION section in block |
Common SQL States
| SQL State | Meaning | Typical Cause |
|---|---|---|
| 23000 | Integrity constraint violation | Foreign key, unique constraint |
| 40001 | Serialization failure | Transaction deadlock |
| 42000 | Syntax error | Invalid SQL statement |
| 42S02 | Table not found | Missing table reference |
| HY000 | General error | Various error conditions |
Performance Optimization Checklist
| Check | Purpose | Action |
|---|---|---|
| Parameter sniffing | Execution plan optimization | Use local variables in MySQL |
| Index usage | Query performance | EXPLAIN all queries in procedure |
| Lock duration | Concurrency | Keep transactions short |
| Temporary table size | Memory usage | Limit temporary table rows |
| Cursor necessity | Resource efficiency | Use set-based operations instead |
| Result set size | Network transfer | Return only needed columns |
Security Considerations
| Risk | Mitigation | Implementation |
|---|---|---|
| SQL injection | Parameterized queries | No dynamic SQL concatenation |
| Privilege escalation | Definer rights | Use invoker rights when possible |
| Information disclosure | Error handling | Don't expose internal details |
| Unauthorized access | Permission checks | Validate caller permissions |
| Audit trail gaps | Logging | Log all data modifications |