Procediments emmagatzemats (Stored Procedures)
Què és un procediment emmagatzemat?
Un procediment emmagatzemat (stored procedure) és un conjunt de sentències SQL i lògica procedimental (variables, condicions, bucles, gestió d'errors) que es compila i s'emmagatzema al servidor de base de dades amb un nom, de manera que pot ser cridat repetidament per les aplicacions o per altres objectes de la BD.
A diferència d'una consulta enviada pel client cada vegada, el procediment ja existeix preparsejat i precompilat al servidor. El client simplement diu: "executa el procediment X amb aquests paràmetres" i el servidor s'encarrega de tot.
graph LR
APP["Aplicació client\n(Python, Java, PHP...)"]
SP["Procediment\nemmagatzemat\nprocess_order()"]
T1[("Taula\ncomandes")]
T2[("Taula\nstock")]
T3[("Taula\nfactures")]
APP -- "CALL process_order(42)" --> SP
SP -- "UPDATE" --> T1
SP -- "UPDATE" --> T2
SP -- "INSERT" --> T3
SP -- "Resultat / codi d'error" --> APP
Sense el procediment, l'aplicació hauria d'enviar tres o més consultes separades, gestionar les transaccions al costat del client i tractar cada error individualment. Amb el procediment, tot passa al servidor en una sola crida.
Avantatges dels procediments emmagatzemats
| Avantatge | Descripció |
|---|---|
| Rendiment | El codi es precompila i el pla d'execució es pot reutilitzar. Menys parseig en cada crida. |
| Menys tràfic de xarxa | El client envia una sola crida; el servidor executa internament N operacions sense enviar-les pel cable. |
| Seguretat | Es pot donar permís d'EXECUTE sobre un procediment sense donar permisos directes sobre les taules. La lògica queda encapsulada i protegida. |
| Encapsulació | La lògica de negoci resideix al SGBD, no dispersa en múltiples aplicacions. Un canvi al procediment afecta tots els consumidors. |
| Reutilització | Qualsevol aplicació, usuari o altre procediment pot cridar el mateix codi. |
| Manteniment | Es pot modificar el procediment sense tocar el codi de les aplicacions, sempre que la interfície (paràmetres) no canviï. |
| Transaccions | Fàcil de gestionar transaccions complexes (BEGIN/COMMIT/ROLLBACK) dins del procediment. |
Procediments i portabilitat
Cada SGBD té el seu propi llenguatge procedural: PL/pgSQL (PostgreSQL), SQL/PSM (MySQL), T-SQL (SQL Server), PL/SQL (Oracle). El codi dels procediments no és portable entre motors. Planifiqueu bé quines parts de la lògica poseu al SGBD si preveieu canviar de motor en el futur.
Sintaxi bàsica de creació
-- En PostgreSQL, els procediments es creen amb PL/pgSQL
-- La funció DO $$ ... $$ s'usa per a blocs anònims (sense nom)
-- PROCEDURE és per a codi que no retorna valor (des de PG 11)
CREATE OR REPLACE PROCEDURE saluda_usuari(p_nom TEXT)
LANGUAGE plpgsql
AS $$
BEGIN
RAISE NOTICE 'Hola, %!', p_nom;
END;
$$;
-- Cridar-lo:
CALL saluda_usuari('Maria');
-- MySQL usa DELIMITER per canviar el terminador de sentències
-- mentre es defineix el procediment (evita confusió amb els ;)
DELIMITER //
CREATE PROCEDURE saluda_usuari(IN p_nom VARCHAR(100))
BEGIN
SELECT CONCAT('Hola, ', p_nom, '!') AS missatge;
END //
DELIMITER ;
-- Cridar-lo:
CALL saluda_usuari('Maria');
-- SQL Server usa T-SQL. CREATE OR ALTER permet crear o modificar
-- sense haver de fer DROP prèviament (des de SQL Server 2016 SP1)
CREATE OR ALTER PROCEDURE saluda_usuari
@p_nom NVARCHAR(100)
AS
BEGIN
SET NOCOUNT ON;
SELECT 'Hola, ' + @p_nom + '!' AS missatge;
END;
GO
-- Cridar-lo:
EXEC saluda_usuari @p_nom = 'Maria';
-- Oracle usa PL/SQL. La secció IS/AS separa la capçalera del cos.
-- Les variables locals es declaren entre IS i BEGIN.
CREATE OR REPLACE PROCEDURE saluda_usuari(
p_nom IN VARCHAR2
)
AS
BEGIN
DBMS_OUTPUT.PUT_LINE('Hola, ' || p_nom || '!');
END saluda_usuari;
/
-- Cridar-lo (des de SQL*Plus o SQL Developer):
BEGIN
saluda_usuari('Maria');
END;
/
Paràmetres: IN, OUT, INOUT
Els procediments accepten tres tipus de paràmetres:
- IN — el client passa un valor al procediment (per defecte). El procediment el llegeix però no el pot modificar fora.
- OUT — el procediment retorna un valor al client. L'aplicació declara una variable i el procediment l'omple.
- INOUT — el client passa un valor, el procediment el pot modificar i el valor modificat torna al client.
CREATE OR REPLACE PROCEDURE calcular_descompte(
IN p_preu NUMERIC,
IN p_percentatge NUMERIC,
OUT p_preu_final NUMERIC
)
LANGUAGE plpgsql
AS $$
BEGIN
p_preu_final := p_preu - (p_preu * p_percentatge / 100);
END;
$$;
-- Ús (els paràmetres OUT es recuperen amb INTO):
DO $$
DECLARE
v_resultat NUMERIC;
BEGIN
CALL calcular_descompte(100.00, 15, v_resultat);
RAISE NOTICE 'Preu amb descompte: %', v_resultat;
END;
$$;
DELIMITER //
CREATE PROCEDURE calcular_descompte(
IN p_preu DECIMAL(10,2),
IN p_percentatge DECIMAL(5,2),
OUT p_preu_final DECIMAL(10,2)
)
BEGIN
SET p_preu_final = p_preu - (p_preu * p_percentatge / 100);
END //
DELIMITER ;
-- Ús:
CALL calcular_descompte(100.00, 15, @resultat);
SELECT @resultat AS preu_final;
CREATE OR ALTER PROCEDURE calcular_descompte
@p_preu DECIMAL(10,2),
@p_percentatge DECIMAL(5,2),
@p_preu_final DECIMAL(10,2) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET @p_preu_final = @p_preu - (@p_preu * @p_percentatge / 100);
END;
GO
-- Ús:
DECLARE @resultat DECIMAL(10,2);
EXEC calcular_descompte
@p_preu = 100.00,
@p_percentatge = 15,
@p_preu_final = @resultat OUTPUT;
SELECT @resultat AS preu_final;
CREATE OR REPLACE PROCEDURE calcular_descompte(
p_preu IN NUMBER,
p_percentatge IN NUMBER,
p_preu_final OUT NUMBER
)
AS
BEGIN
p_preu_final := p_preu - (p_preu * p_percentatge / 100);
END calcular_descompte;
/
-- Ús (bloc anònim PL/SQL):
DECLARE
v_resultat NUMBER;
BEGIN
calcular_descompte(100, 15, v_resultat);
DBMS_OUTPUT.PUT_LINE('Preu final: ' || v_resultat);
END;
/
Control de flux
IF / ELSIF / ELSE
DELIMITER //
CREATE PROCEDURE classificar_nota(IN p_nota DECIMAL(4,1))
BEGIN
IF p_nota >= 9 THEN
SELECT 'Excel·lent' AS qualificació;
ELSEIF p_nota >= 7 THEN
SELECT 'Notable' AS qualificació;
ELSEIF p_nota >= 5 THEN
SELECT 'Aprovat' AS qualificació;
ELSE
SELECT 'Suspès' AS qualificació;
END IF;
END //
DELIMITER ;
CREATE OR REPLACE PROCEDURE classificar_nota(p_nota NUMBER)
AS
BEGIN
IF p_nota >= 9 THEN
DBMS_OUTPUT.PUT_LINE('Excel·lent');
ELSIF p_nota >= 7 THEN
DBMS_OUTPUT.PUT_LINE('Notable');
ELSIF p_nota >= 5 THEN
DBMS_OUTPUT.PUT_LINE('Aprovat');
ELSE
DBMS_OUTPUT.PUT_LINE('Suspès');
END IF;
END classificar_nota;
/
Bucles: WHILE i FOR
CREATE OR REPLACE PROCEDURE demo_bucles()
LANGUAGE plpgsql
AS $$
DECLARE
v_i INTEGER := 1;
BEGIN
-- Bucle WHILE
WHILE v_i <= 5 LOOP
RAISE NOTICE 'WHILE: iteració %', v_i;
v_i := v_i + 1;
END LOOP;
-- Bucle FOR (rang numèric)
FOR v_i IN 1..5 LOOP
RAISE NOTICE 'FOR: iteració %', v_i;
END LOOP;
-- Bucle FOR sobre una consulta
FOR rec IN SELECT nom FROM clients LIMIT 5 LOOP
RAISE NOTICE 'Client: %', rec.nom;
END LOOP;
END;
$$;
DELIMITER //
CREATE PROCEDURE demo_bucles()
BEGIN
DECLARE v_i INT DEFAULT 1;
-- Bucle WHILE
WHILE v_i <= 5 DO
SELECT CONCAT('WHILE: iteració ', v_i);
SET v_i = v_i + 1;
END WHILE;
-- Bucle REPEAT (com do-while)
SET v_i = 1;
REPEAT
SELECT CONCAT('REPEAT: iteració ', v_i);
SET v_i = v_i + 1;
UNTIL v_i > 5
END REPEAT;
-- Bucle LOOP amb LEAVE (equivalent a break)
SET v_i = 1;
my_loop: LOOP
IF v_i > 5 THEN
LEAVE my_loop;
END IF;
SELECT CONCAT('LOOP: iteració ', v_i);
SET v_i = v_i + 1;
END LOOP;
END //
DELIMITER ;
CREATE OR ALTER PROCEDURE demo_bucles
AS
BEGIN
SET NOCOUNT ON;
DECLARE @i INT = 1;
-- Bucle WHILE (únic tipus natiu en T-SQL)
WHILE @i <= 5
BEGIN
PRINT 'Iteració: ' + CAST(@i AS NVARCHAR);
SET @i = @i + 1;
END;
-- Iterar sobre un cursor
DECLARE @nom NVARCHAR(100);
DECLARE cur CURSOR FOR SELECT nom FROM clients;
OPEN cur;
FETCH NEXT FROM cur INTO @nom;
WHILE @@FETCH_STATUS = 0
BEGIN
PRINT 'Client: ' + @nom;
FETCH NEXT FROM cur INTO @nom;
END;
CLOSE cur;
DEALLOCATE cur;
END;
GO
CREATE OR REPLACE PROCEDURE demo_bucles
AS
v_i NUMBER := 1;
BEGIN
-- Bucle WHILE
WHILE v_i <= 5 LOOP
DBMS_OUTPUT.PUT_LINE('WHILE: iteració ' || v_i);
v_i := v_i + 1;
END LOOP;
-- Bucle FOR numèric
FOR v_j IN 1..5 LOOP
DBMS_OUTPUT.PUT_LINE('FOR: iteració ' || v_j);
END LOOP;
-- Bucle FOR sobre cursor implícit
FOR rec IN (SELECT nom FROM clients WHERE ROWNUM <= 5) LOOP
DBMS_OUTPUT.PUT_LINE('Client: ' || rec.nom);
END LOOP;
END demo_bucles;
/
CASE
CREATE OR REPLACE PROCEDURE descriure_estació(p_mes INT)
LANGUAGE plpgsql
AS $$
DECLARE
v_estació TEXT;
BEGIN
v_estació := CASE p_mes
WHEN 12, 1, 2 THEN 'Hivern'
WHEN 3, 4, 5 THEN 'Primavera'
WHEN 6, 7, 8 THEN 'Estiu'
WHEN 9, 10, 11 THEN 'Tardor'
ELSE 'Mes invàlid'
END;
RAISE NOTICE 'Estació: %', v_estació;
END;
$$;
DELIMITER //
CREATE PROCEDURE descriure_estació(IN p_mes INT)
BEGIN
DECLARE v_estació VARCHAR(20);
CASE p_mes
WHEN 12 THEN SET v_estació = 'Hivern';
WHEN 1 THEN SET v_estació = 'Hivern';
WHEN 2 THEN SET v_estació = 'Hivern';
WHEN 3 THEN SET v_estació = 'Primavera';
WHEN 4 THEN SET v_estació = 'Primavera';
WHEN 5 THEN SET v_estació = 'Primavera';
WHEN 6 THEN SET v_estació = 'Estiu';
WHEN 7 THEN SET v_estació = 'Estiu';
WHEN 8 THEN SET v_estació = 'Estiu';
ELSE SET v_estació = 'Tardor / Invàlid';
END CASE;
SELECT v_estació AS estació;
END //
DELIMITER ;
CREATE OR ALTER PROCEDURE descriure_estació
@p_mes INT
AS
BEGIN
DECLARE @estació NVARCHAR(20);
SET @estació = CASE @p_mes
WHEN 12 THEN 'Hivern'
WHEN 1 THEN 'Hivern'
WHEN 2 THEN 'Hivern'
WHEN 3 THEN 'Primavera'
WHEN 4 THEN 'Primavera'
WHEN 5 THEN 'Primavera'
WHEN 6 THEN 'Estiu'
WHEN 7 THEN 'Estiu'
WHEN 8 THEN 'Estiu'
ELSE 'Tardor'
END;
PRINT @estació;
END;
GO
CREATE OR REPLACE PROCEDURE descriure_estació(p_mes NUMBER)
AS
v_estació VARCHAR2(20);
BEGIN
v_estació := CASE p_mes
WHEN 12 THEN 'Hivern'
WHEN 1 THEN 'Hivern'
WHEN 2 THEN 'Hivern'
WHEN 3 THEN 'Primavera'
WHEN 4 THEN 'Primavera'
WHEN 5 THEN 'Primavera'
WHEN 6 THEN 'Estiu'
WHEN 7 THEN 'Estiu'
WHEN 8 THEN 'Estiu'
ELSE 'Tardor'
END;
DBMS_OUTPUT.PUT_LINE('Estació: ' || v_estació);
END descriure_estació;
/
Gestió d'excepcions
La gestió d'errors és essencial en procediments que fan operacions crítiques. Cada motor té el seu mecanisme:
CREATE OR REPLACE PROCEDURE inserir_client(
p_id INT,
p_nom TEXT
)
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO clients(id, nom) VALUES (p_id, p_nom);
RAISE NOTICE 'Client inserit correctament.';
EXCEPTION
WHEN unique_violation THEN
RAISE WARNING 'Error: ja existeix un client amb id = %', p_id;
WHEN not_null_violation THEN
RAISE WARNING 'Error: el nom no pot ser nul.';
WHEN OTHERS THEN
RAISE WARNING 'Error inesperat: % - %', SQLERRM, SQLSTATE;
END;
$$;
DELIMITER //
CREATE PROCEDURE inserir_client(
IN p_id INT,
IN p_nom VARCHAR(100)
)
BEGIN
-- Declarar handlers ABANS de qualsevol altra instrucció
DECLARE EXIT HANDLER FOR 1062
BEGIN
SELECT 'Error: ja existeix un client amb aquest id.' AS error;
END;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
GET DIAGNOSTICS CONDITION 1
@msg = MESSAGE_TEXT, @codi = MYSQL_ERRNO;
SELECT CONCAT('Error ', @codi, ': ', @msg) AS error;
END;
INSERT INTO clients(id, nom) VALUES (p_id, p_nom);
SELECT 'Client inserit correctament.' AS resultat;
END //
DELIMITER ;
CREATE OR ALTER PROCEDURE inserir_client
@p_id INT,
@p_nom NVARCHAR(100)
AS
BEGIN
SET NOCOUNT ON;
BEGIN TRY
INSERT INTO clients(id, nom) VALUES (@p_id, @p_nom);
PRINT 'Client inserit correctament.';
END TRY
BEGIN CATCH
PRINT 'Error ' + CAST(ERROR_NUMBER() AS NVARCHAR)
+ ': ' + ERROR_MESSAGE();
-- Opcional: re-llençar l'error
-- THROW;
END CATCH;
END;
GO
CREATE OR REPLACE PROCEDURE inserir_client(
p_id IN NUMBER,
p_nom IN VARCHAR2
)
AS
BEGIN
INSERT INTO clients(id, nom) VALUES (p_id, p_nom);
DBMS_OUTPUT.PUT_LINE('Client inserit correctament.');
EXCEPTION
WHEN DUP_VAL_ON_INDEX THEN
DBMS_OUTPUT.PUT_LINE('Error: ja existeix un client amb id = ' || p_id);
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('Error inesperat: ' || SQLERRM);
RAISE; -- Re-llença l'excepció
END inserir_client;
/
Cridar i eliminar procediments
Crida amb CALL / EXEC
Eliminar procediments (DROP)
Exemple pràctic: arxivar comandes antigues
Imagina una taula comandes que creix sense parar. Volem arxivar les comandes completades fa més d'un any a una taula comandes_arxiu i eliminar-les de la taula principal.
CREATE OR REPLACE PROCEDURE arxivar_comandes_antigues(
OUT p_files_arxivades INT
)
LANGUAGE plpgsql
AS $$
DECLARE
v_data_limit DATE := CURRENT_DATE - INTERVAL '1 year';
BEGIN
-- Copiar a la taula d'arxiu
INSERT INTO comandes_arxiu
SELECT * FROM comandes
WHERE estat = 'completada'
AND data_comanda < v_data_limit;
GET DIAGNOSTICS p_files_arxivades = ROW_COUNT;
-- Eliminar les arxivades de la taula principal
DELETE FROM comandes
WHERE estat = 'completada'
AND data_comanda < v_data_limit;
RAISE NOTICE 'Arxivades % comandes anteriors a %',
p_files_arxivades, v_data_limit;
-- La transacció es confirma automàticament en sortir del procediment
EXCEPTION
WHEN OTHERS THEN
RAISE WARNING 'Error durant l''arxivat: %', SQLERRM;
RAISE;
END;
$$;
-- Executar:
DO $$
DECLARE v_n INT;
BEGIN
CALL arxivar_comandes_antigues(v_n);
RAISE NOTICE 'Total arxivades: %', v_n;
END;
$$;
DELIMITER //
CREATE PROCEDURE arxivar_comandes_antigues(
OUT p_files_arxivades INT
)
BEGIN
DECLARE v_data_limit DATE;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
SET v_data_limit = DATE_SUB(CURDATE(), INTERVAL 1 YEAR);
START TRANSACTION;
INSERT INTO comandes_arxiu
SELECT * FROM comandes
WHERE estat = 'completada'
AND data_comanda < v_data_limit;
SET p_files_arxivades = ROW_COUNT();
DELETE FROM comandes
WHERE estat = 'completada'
AND data_comanda < v_data_limit;
COMMIT;
END //
DELIMITER ;
-- Executar:
CALL arxivar_comandes_antigues(@n);
SELECT @n AS files_arxivades;
CREATE OR ALTER PROCEDURE arxivar_comandes_antigues
@p_files_arxivades INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
DECLARE @data_limit DATE = DATEADD(YEAR, -1, GETDATE());
BEGIN TRY
BEGIN TRANSACTION;
INSERT INTO comandes_arxiu
SELECT * FROM comandes
WHERE estat = 'completada'
AND data_comanda < @data_limit;
SET @p_files_arxivades = @@ROWCOUNT;
DELETE FROM comandes
WHERE estat = 'completada'
AND data_comanda < @data_limit;
COMMIT TRANSACTION;
PRINT 'Arxivades ' + CAST(@p_files_arxivades AS NVARCHAR) + ' comandes.';
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
GO
-- Executar:
DECLARE @n INT;
EXEC arxivar_comandes_antigues @p_files_arxivades = @n OUTPUT;
SELECT @n AS files_arxivades;
CREATE OR REPLACE PROCEDURE arxivar_comandes_antigues(
p_files_arxivades OUT NUMBER
)
AS
v_data_limit DATE := ADD_MONTHS(SYSDATE, -12);
BEGIN
INSERT INTO comandes_arxiu
SELECT * FROM comandes
WHERE estat = 'completada'
AND data_comanda < v_data_limit;
p_files_arxivades := SQL%ROWCOUNT;
DELETE FROM comandes
WHERE estat = 'completada'
AND data_comanda < v_data_limit;
COMMIT;
DBMS_OUTPUT.PUT_LINE('Arxivades ' || p_files_arxivades || ' comandes.');
EXCEPTION
WHEN OTHERS THEN
ROLLBACK;
DBMS_OUTPUT.PUT_LINE('Error: ' || SQLERRM);
RAISE;
END arxivar_comandes_antigues;
/
-- Executar:
DECLARE
v_n NUMBER;
BEGIN
arxivar_comandes_antigues(v_n);
DBMS_OUTPUT.PUT_LINE('Total: ' || v_n);
END;
/
Miniactivitat — AC0402 · AC0403
Miniactivitat — AC0402 · AC0403 · Procediment de vendes mensuals
Teniu la taula vendes(id, producte_id, quantitat, preu_unitari, data_venda, comercial_id).
Tasca 1: Creeu un procediment resum_vendes_mes que accepti com a paràmetres l'any i el mes, i retorni (via OUT o SELECT) les dades següents:
- Nombre total de vendes d'aquell mes
- Import total facturat
- Import mitjà per venda
- Producte més venut (nom)
- Comercial amb més vendes (nom)
Tasca 2: Afegiu gestió d'errors: si el mes o l'any no són vàlids (mes fora del rang 1-12, any negatiu), el procediment ha de retornar un error informatiu sense executar la consulta.
Tasca 3: Creeu un segon procediment resum_vendes_trimestre(p_any, p_trimestre) que cridi internament resum_vendes_mes per als tres mesos del trimestre i consolidi els resultats.
Entrega: Arxiu SQL amb els procediments i un joc de proves amb CALL (o equivalent) que demostri que funcionen correctament. Escolliu el motor que preferiu per a la implementació principal i afegiu-ne la versió equivalent en un segon motor com a annex.