Módulo 08: Introducción al PL/SQL Dinámico

Objetivos del módulo

  • Comprender qué es el SQL dinámico y cuándo usarlo
  • Dominar EXECUTE IMMEDIATE para sentencias dinámicas
  • Usar variables de enlace (bind variables) para seguridad y rendimiento
  • Ejecutar DDL dinámico (CREATE, ALTER, DROP)
  • Ejecutar DML dinámico (INSERT, UPDATE, DELETE)
  • Ejecutar consultas dinámicas con INTO y cursores
  • Conocer el paquete DBMS_SQL para casos avanzados
  • Proteger el código contra SQL Injection

1. ¿Qué es el SQL dinámico?

El SQL dinámico permite construir y ejecutar sentencias SQL en tiempo de ejecución, a diferencia del SQL estático que se escribe directamente en el código PL/SQL.

Característica SQL Estático SQL Dinámico
Sentencia conocida en compilación
Verificación de sintaxis en compilación
Permite DDL (CREATE, DROP, ALTER)
Nombre de tabla/columna variable
Rendimiento Mejor Ligeramente inferior

📘 Concepto: El SQL estático se compila y verifica cuando creas el procedimiento. El SQL dinámico se compila y verifica cada vez que se ejecuta, lo que aporta flexibilidad pero reduce algo el rendimiento y la seguridad si no se usa correctamente.

¿Cuándo usar SQL dinámico?

  • Cuando necesitas ejecutar DDL (CREATE TABLE, DROP INDEX, ALTER…)
  • Cuando el nombre de la tabla o columna se determina en tiempo de ejecución
  • Cuando la estructura de la consulta cambia según condiciones (cláusulas WHERE opcionales)
  • Cuando necesitas ejecutar SQL almacenado en una variable o tabla

2. EXECUTE IMMEDIATE

EXECUTE IMMEDIATE es la forma más simple y recomendada de ejecutar SQL dinámico:

2.1. Sintaxis general

EXECUTE IMMEDIATE cadena_sql
[INTO variable1, variable2, ...]
[USING [IN | OUT | IN OUT] bind1, bind2, ...];

2.2. DDL dinámico

PL/SQL no permite sentencias DDL de forma estática. Con SQL dinámico, sí:

-- Crear una tabla
DECLARE
    v_tabla VARCHAR2(30) := 'LOG_AUDITORIA';
BEGIN
    EXECUTE IMMEDIATE 
        'CREATE TABLE ' || v_tabla || ' (' ||
        '  id         NUMBER GENERATED ALWAYS AS IDENTITY, ' ||
        '  fecha      DATE DEFAULT SYSDATE, ' ||
        '  usuario    VARCHAR2(30) DEFAULT USER, ' ||
        '  accion     VARCHAR2(200)' ||
        ')';
    
    DBMS_OUTPUT.PUT_LINE('Tabla ' || v_tabla || ' creada correctamente');
END;
/

Eliminar y truncar tablas:

-- Procedimiento genérico para eliminar una tabla si existe
CREATE OR REPLACE PROCEDURE pr_drop_tabla_si_existe
    (p_tabla IN VARCHAR2)
IS
    v_cuenta NUMBER;
BEGIN
    SELECT COUNT(*) INTO v_cuenta
    FROM user_tables
    WHERE table_name = UPPER(p_tabla);
    
    IF v_cuenta > 0 THEN
        EXECUTE IMMEDIATE 'DROP TABLE ' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_tabla) || ' CASCADE CONSTRAINTS';
        DBMS_OUTPUT.PUT_LINE('Tabla ' || p_tabla || ' eliminada');
    ELSE
        DBMS_OUTPUT.PUT_LINE('La tabla ' || p_tabla || ' no existe');
    END IF;
END pr_drop_tabla_si_existe;
/

⚠️ Importante: Las sentencias DDL realizan un COMMIT implícito antes y después de ejecutarse. Si tienes una transacción DML pendiente, se confirmará automáticamente al ejecutar DDL dinámico.

2.3. DML dinámico con bind variables

Siempre usa variables de enlace (bind variables con :nombre) para pasar valores:

-- INSERT dinámico con bind variables (SEGURO)
CREATE OR REPLACE PROCEDURE pr_insertar_log
    (p_accion IN VARCHAR2)
IS
    v_sql VARCHAR2(200);
BEGIN
    v_sql := 'INSERT INTO log_auditoria (accion) VALUES (:accion)';
    EXECUTE IMMEDIATE v_sql USING p_accion;
    COMMIT;
END pr_insertar_log;
/

UPDATE dinámico:

-- Procedimiento que actualiza cualquier columna de texto de una tabla
CREATE OR REPLACE PROCEDURE pr_actualizar_campo
    (p_tabla    IN VARCHAR2,
     p_columna  IN VARCHAR2,
     p_valor    IN VARCHAR2,
     p_id_col   IN VARCHAR2,
     p_id_valor IN NUMBER)
IS
    v_sql VARCHAR2(500);
    v_filas NUMBER;
BEGIN
    -- Validar nombres de tabla y columna contra inyección SQL
    v_sql := 'UPDATE ' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_tabla) ||
             ' SET '   || DBMS_ASSERT.SIMPLE_SQL_NAME(p_columna) || ' = :valor' ||
             ' WHERE ' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_id_col)  || ' = :id';
    
    EXECUTE IMMEDIATE v_sql USING p_valor, p_id_valor;
    v_filas := SQL%ROWCOUNT;
    
    DBMS_OUTPUT.PUT_LINE('Filas actualizadas: ' || v_filas);
END pr_actualizar_campo;
/

DELETE dinámico:

DECLARE
    v_sql VARCHAR2(200);
    v_filas NUMBER;
BEGIN
    v_sql := 'DELETE FROM log_auditoria WHERE fecha < :fecha_limite';
    EXECUTE IMMEDIATE v_sql USING SYSDATE - 30;
    v_filas := SQL%ROWCOUNT;
    
    DBMS_OUTPUT.PUT_LINE('Registros eliminados: ' || v_filas);
    COMMIT;
END;
/

💡 Truco: Las variables de enlace (:nombre + USING) no solo previenen inyección SQL, también mejoran el rendimiento: Oracle puede reutilizar el plan de ejecución (soft parse) en lugar de recompilarlo cada vez.

2.4. Consultas dinámicas con INTO

Para consultas que devuelven una sola fila:

-- Contar registros de cualquier tabla
CREATE OR REPLACE FUNCTION fn_contar_registros
    (p_tabla IN VARCHAR2)
RETURN NUMBER
IS
    v_cuenta NUMBER;
BEGIN
    EXECUTE IMMEDIATE 
        'SELECT COUNT(*) FROM ' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_tabla)
        INTO v_cuenta;
    
    RETURN v_cuenta;
END fn_contar_registros;
/

-- Uso
SELECT fn_contar_registros('CONDUCTOR') AS total FROM DUAL;

Con condiciones y bind variables:

DECLARE
    v_nombre VARCHAR2(100);
    v_sql    VARCHAR2(500);
BEGIN
    v_sql := 'SELECT nombre FROM conductor WHERE ROWNUM = 1 ORDER BY fec_nacimiento';
    
    EXECUTE IMMEDIATE v_sql INTO v_nombre;
    DBMS_OUTPUT.PUT_LINE('Primer conductor: ' || v_nombre);
END;
/

3. Cursores dinámicos

Para consultas que devuelven múltiples filas, se usan cursores dinámicos:

3.1. Cursor REF con OPEN FOR

CREATE OR REPLACE PROCEDURE pr_listar_tabla
    (p_tabla IN VARCHAR2)
IS
    TYPE t_cursor IS REF CURSOR;
    v_cursor t_cursor;
    v_sql    VARCHAR2(500);
    v_nombre VARCHAR2(100);
BEGIN
    v_sql := 'SELECT nombre FROM ' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_tabla) || 
             ' ORDER BY nombre';
    
    OPEN v_cursor FOR v_sql;
    LOOP
        FETCH v_cursor INTO v_nombre;
        EXIT WHEN v_cursor%NOTFOUND;
        DBMS_OUTPUT.PUT_LINE(v_nombre);
    END LOOP;
    CLOSE v_cursor;
END pr_listar_tabla;
/

EXEC pr_listar_tabla('CONDUCTOR');

3.2. SYS_REFCURSOR con bind variables

CREATE OR REPLACE PROCEDURE pr_buscar_multas_periodo
    (p_fecha_ini IN DATE,
     p_fecha_fin IN DATE)
IS
    v_cursor SYS_REFCURSOR;
    v_sql    VARCHAR2(500);
    v_fecha  DATE;
    v_hora   VARCHAR2(5);
    v_importe NUMBER;
BEGIN
    v_sql := 'SELECT fecha_multa, hora_multa, importe ' ||
             'FROM multa ' ||
             'WHERE fecha_multa BETWEEN :fini AND :ffin ' ||
             'ORDER BY fecha_multa';
    
    OPEN v_cursor FOR v_sql USING p_fecha_ini, p_fecha_fin;
    
    DBMS_OUTPUT.PUT_LINE('--- Multas del periodo ---');
    LOOP
        FETCH v_cursor INTO v_fecha, v_hora, v_importe;
        EXIT WHEN v_cursor%NOTFOUND;
        DBMS_OUTPUT.PUT_LINE(v_fecha || ' ' || v_hora || ' - ' || v_importe || ' €');
    END LOOP;
    
    CLOSE v_cursor;
END pr_buscar_multas_periodo;
/

4. DBMS_SQL: SQL dinámico avanzado

El paquete DBMS_SQL ofrece un control más granular para casos complejos donde EXECUTE IMMEDIATE no es suficiente:

  • Número variable de columnas en el SELECT
  • Estructura de la consulta completamente desconocida
  • Procesamientos por lotes masivos

4.1. Flujo básico de DBMS_SQL

DECLARE
    v_cursor_id  INTEGER;
    v_sql        VARCHAR2(500);
    v_filas      INTEGER;
    v_nombre     VARCHAR2(100);
BEGIN
    -- 1. Abrir cursor
    v_cursor_id := DBMS_SQL.OPEN_CURSOR;
    
    -- 2. Parsear la sentencia
    v_sql := 'SELECT nombre FROM conductor WHERE ROWNUM <= :limite';
    DBMS_SQL.PARSE(v_cursor_id, v_sql, DBMS_SQL.NATIVE);
    
    -- 3. Bind variables
    DBMS_SQL.BIND_VARIABLE(v_cursor_id, ':limite', 5);
    
    -- 4. Definir columnas de salida
    DBMS_SQL.DEFINE_COLUMN(v_cursor_id, 1, v_nombre, 100);
    
    -- 5. Ejecutar
    v_filas := DBMS_SQL.EXECUTE(v_cursor_id);
    
    -- 6. Fetch en bucle
    WHILE DBMS_SQL.FETCH_ROWS(v_cursor_id) > 0 LOOP
        DBMS_SQL.COLUMN_VALUE(v_cursor_id, 1, v_nombre);
        DBMS_OUTPUT.PUT_LINE('Conductor: ' || v_nombre);
    END LOOP;
    
    -- 7. Cerrar cursor
    DBMS_SQL.CLOSE_CURSOR(v_cursor_id);
EXCEPTION
    WHEN OTHERS THEN
        IF DBMS_SQL.IS_OPEN(v_cursor_id) THEN
            DBMS_SQL.CLOSE_CURSOR(v_cursor_id);
        END IF;
        RAISE;
END;
/

📘 Concepto: DBMS_SQL es más verboso que EXECUTE IMMEDIATE, pero permite construir consultas donde ni siquiera conoces el número de columnas en tiempo de compilación. Para la mayoría de casos, EXECUTE IMMEDIATE es suficiente y recomendado. Usa DBMS_SQL solo cuando EXECUTE IMMEDIATE no cubra tu necesidad.

4.2. DML masivo con DBMS_SQL

DECLARE
    v_cursor_id INTEGER;
    v_sql       VARCHAR2(200);
    v_filas     INTEGER;
BEGIN
    v_cursor_id := DBMS_SQL.OPEN_CURSOR;
    
    v_sql := 'UPDATE infraccion SET valor_economico = valor_economico * :factor WHERE vigente = :vig';
    DBMS_SQL.PARSE(v_cursor_id, v_sql, DBMS_SQL.NATIVE);
    DBMS_SQL.BIND_VARIABLE(v_cursor_id, ':factor', 1.05);
    DBMS_SQL.BIND_VARIABLE(v_cursor_id, ':vig', 1);
    
    v_filas := DBMS_SQL.EXECUTE(v_cursor_id);
    DBMS_OUTPUT.PUT_LINE('Infracciones actualizadas: ' || v_filas);
    
    DBMS_SQL.CLOSE_CURSOR(v_cursor_id);
    ROLLBACK; -- Deshacemos para no alterar datos de prueba
EXCEPTION
    WHEN OTHERS THEN
        IF DBMS_SQL.IS_OPEN(v_cursor_id) THEN
            DBMS_SQL.CLOSE_CURSOR(v_cursor_id);
        END IF;
        RAISE;
END;
/

5. Seguridad: protección contra SQL Injection

⚠️ Importante: La inyección SQL es uno de los riesgos más graves en el desarrollo de software. En SQL dinámico, NUNCA concatenes directamente valores proporcionados por el usuario en la cadena SQL. Usa siempre bind variables o las funciones del paquete DBMS_ASSERT.

5.1. Ejemplo vulnerable (NUNCA hacer esto)

-- ❌ VULNERABLE a SQL Injection
CREATE OR REPLACE PROCEDURE pr_buscar_INSEGURO
    (p_nombre IN VARCHAR2)
IS
    v_sql VARCHAR2(500);
BEGIN
    -- ¡¡¡PELIGRO!!! Concatenación directa de entrada del usuario
    v_sql := 'SELECT nombre FROM conductor WHERE nombre = ''' || p_nombre || '''';
    -- Un atacante podría enviar: ' OR '1'='1
    -- Resultado: SELECT nombre FROM conductor WHERE nombre = '' OR '1'='1'
    EXECUTE IMMEDIATE v_sql;
END;
/

5.2. Ejemplo seguro con bind variables

-- ✅ SEGURO con bind variables
CREATE OR REPLACE PROCEDURE pr_buscar_SEGURO
    (p_nombre IN VARCHAR2)
IS
    v_sql    VARCHAR2(500);
    v_nombre VARCHAR2(100);
BEGIN
    v_sql := 'SELECT nombre FROM conductor WHERE nombre = :nombre AND ROWNUM = 1';
    EXECUTE IMMEDIATE v_sql INTO v_nombre USING p_nombre;
    DBMS_OUTPUT.PUT_LINE('Encontrado: ' || v_nombre);
EXCEPTION
    WHEN NO_DATA_FOUND THEN
        DBMS_OUTPUT.PUT_LINE('No encontrado');
END pr_buscar_SEGURO;
/

5.3. DBMS_ASSERT para nombres de objetos

Cuando necesitas incluir nombres de tablas o columnas dinámicamente (no se pueden usar con bind variables), utiliza DBMS_ASSERT:

-- Validar nombre de tabla/columna
v_tabla_segura := DBMS_ASSERT.SIMPLE_SQL_NAME(p_tabla);
-- Lanza error si el nombre contiene caracteres no permitidos

-- Validar que un nombre incluye el esquema
v_tabla_segura := DBMS_ASSERT.QUALIFIED_SQL_NAME(p_esquema || '.' || p_tabla);

-- Validar que es un nombre de objeto existente
v_tabla_segura := DBMS_ASSERT.SQL_OBJECT_NAME(p_tabla);

Ejemplo completo seguro:

CREATE OR REPLACE FUNCTION fn_contar_seguro
    (p_tabla IN VARCHAR2,
     p_where IN VARCHAR2 DEFAULT NULL)
RETURN NUMBER
IS
    v_sql    VARCHAR2(500);
    v_cuenta NUMBER;
    v_tabla  VARCHAR2(128);
BEGIN
    -- Validar el nombre de la tabla
    v_tabla := DBMS_ASSERT.SIMPLE_SQL_NAME(p_tabla);
    
    v_sql := 'SELECT COUNT(*) FROM ' || v_tabla;
    
    -- Solo añadir WHERE si se proporciona
    IF p_where IS NOT NULL THEN
        v_sql := v_sql || ' WHERE ' || p_where;
    END IF;
    
    EXECUTE IMMEDIATE v_sql INTO v_cuenta;
    RETURN v_cuenta;
END fn_contar_seguro;
/

-- Uso seguro
SELECT fn_contar_seguro('CONDUCTOR') FROM DUAL;
SELECT fn_contar_seguro('INFRACCION', 'vigente = 1') FROM DUAL;

5.4. Reglas de oro de seguridad

Regla Detalle
Bind variables para valores Siempre usar :nombre + USING para datos
DBMS_ASSERT para identificadores Validar nombres de tablas/columnas
Nunca concatenar entrada del usuario Ni directa ni indirectamente
Principio de mínimo privilegio Solo dar permisos estrictamente necesarios
Validar entrada Comprobar tipo, longitud y rango de los parámetros

6. Patrones útiles de SQL dinámico

6.1. Consulta con WHERE dinámico

CREATE OR REPLACE PROCEDURE pr_busqueda_avanzada
    (p_nombre   IN VARCHAR2 DEFAULT NULL,
     p_marca    IN VARCHAR2 DEFAULT NULL,
     p_color    IN VARCHAR2 DEFAULT NULL)
IS
    v_sql    VARCHAR2(1000);
    v_cursor SYS_REFCURSOR;
    v_mat    VARCHAR2(20);
    v_marca  VARCHAR2(50);
    v_color  VARCHAR2(50);
BEGIN
    v_sql := 'SELECT v.matricula, v.marca, v.color ' ||
             'FROM vehiculo v ' ||
             'JOIN conductor c ON v.DNI = c.DNI ' ||
             'WHERE 1=1';
    
    IF p_nombre IS NOT NULL THEN
        v_sql := v_sql || ' AND UPPER(c.nombre) LIKE ''%'' || UPPER(:nombre) || ''%''';
    ELSE
        v_sql := v_sql || ' AND (1=1 OR :nombre IS NULL)';
    END IF;
    
    IF p_marca IS NOT NULL THEN
        v_sql := v_sql || ' AND UPPER(v.marca) = UPPER(:marca)';
    ELSE
        v_sql := v_sql || ' AND (1=1 OR :marca IS NULL)';
    END IF;
    
    IF p_color IS NOT NULL THEN
        v_sql := v_sql || ' AND UPPER(v.color) = UPPER(:color)';
    ELSE
        v_sql := v_sql || ' AND (1=1 OR :color IS NULL)';
    END IF;
    
    v_sql := v_sql || ' ORDER BY v.marca, v.matricula';
    
    OPEN v_cursor FOR v_sql USING p_nombre, p_marca, p_color;
    
    DBMS_OUTPUT.PUT_LINE('--- Resultados de búsqueda ---');
    LOOP
        FETCH v_cursor INTO v_mat, v_marca, v_color;
        EXIT WHEN v_cursor%NOTFOUND;
        DBMS_OUTPUT.PUT_LINE(v_mat || ' | ' || v_marca || ' | ' || NVL(v_color, 'N/A'));
    END LOOP;
    
    CLOSE v_cursor;
END pr_busqueda_avanzada;
/

-- Búsquedas de ejemplo
EXEC pr_busqueda_avanzada(p_marca => 'Seat');
EXEC pr_busqueda_avanzada(p_color => 'Rojo');
EXEC pr_busqueda_avanzada(p_nombre => 'García', p_marca => 'Seat');

6.2. Creación dinámica de tabla de backup

CREATE OR REPLACE PROCEDURE pr_backup_tabla
    (p_tabla IN VARCHAR2)
IS
    v_tabla_orig   VARCHAR2(128);
    v_tabla_backup VARCHAR2(128);
    v_existe       NUMBER;
BEGIN
    v_tabla_orig   := DBMS_ASSERT.SIMPLE_SQL_NAME(p_tabla);
    v_tabla_backup := v_tabla_orig || '_BKP_' || TO_CHAR(SYSDATE, 'YYYYMMDD');
    
    -- Verificar si ya existe el backup
    SELECT COUNT(*) INTO v_existe
    FROM user_tables
    WHERE table_name = UPPER(v_tabla_backup);
    
    IF v_existe > 0 THEN
        EXECUTE IMMEDIATE 'DROP TABLE ' || v_tabla_backup;
    END IF;
    
    -- Crear backup como copia de la tabla original
    EXECUTE IMMEDIATE 'CREATE TABLE ' || v_tabla_backup || 
                      ' AS SELECT * FROM ' || v_tabla_orig;
    
    DBMS_OUTPUT.PUT_LINE('Backup creado: ' || v_tabla_backup);
    
    -- Mostrar filas copiadas
    DECLARE
        v_filas NUMBER;
    BEGIN
        EXECUTE IMMEDIATE 'SELECT COUNT(*) FROM ' || v_tabla_backup INTO v_filas;
        DBMS_OUTPUT.PUT_LINE('Filas copiadas: ' || v_filas);
    END;
END pr_backup_tabla;
/

-- Uso
EXEC pr_backup_tabla('CONDUCTOR');

7. Ejercicios prácticos

Ejercicio 1: DDL dinámico

Crea un procedimiento que genere una tabla de auditoría para cualquier tabla existente:

CREATE OR REPLACE PROCEDURE pr_crear_tabla_auditoria
    (p_tabla IN VARCHAR2)
IS
    v_tabla    VARCHAR2(128);
    v_audit    VARCHAR2(128);
    v_existe   NUMBER;
BEGIN
    v_tabla := DBMS_ASSERT.SIMPLE_SQL_NAME(p_tabla);
    v_audit := 'AUD_' || v_tabla;
    
    -- Eliminar si ya existe
    SELECT COUNT(*) INTO v_existe
    FROM user_tables WHERE table_name = UPPER(v_audit);
    
    IF v_existe > 0 THEN
        EXECUTE IMMEDIATE 'DROP TABLE ' || v_audit;
    END IF;
    
    EXECUTE IMMEDIATE 
        'CREATE TABLE ' || v_audit || ' (' ||
        '  aud_id     NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ' ||
        '  aud_fecha  TIMESTAMP DEFAULT SYSTIMESTAMP, ' ||
        '  aud_user   VARCHAR2(30) DEFAULT USER, ' ||
        '  aud_accion VARCHAR2(10), ' ||
        '  aud_detalle VARCHAR2(4000)' ||
        ')';
    
    DBMS_OUTPUT.PUT_LINE('Tabla de auditoría creada: ' || v_audit);
END pr_crear_tabla_auditoria;
/

-- Prueba
EXEC pr_crear_tabla_auditoria('CONDUCTOR');

Ejercicio 2: Consulta genérica con paginación

Crea una función que devuelva el número de páginas necesarias para mostrar una tabla completa:

CREATE OR REPLACE FUNCTION fn_paginas_necesarias
    (p_tabla         IN VARCHAR2,
     p_filas_pagina  IN NUMBER DEFAULT 10)
RETURN NUMBER
IS
    v_total NUMBER;
BEGIN
    EXECUTE IMMEDIATE 'SELECT COUNT(*) FROM ' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_tabla)
        INTO v_total;
    
    RETURN CEIL(v_total / p_filas_pagina);
END fn_paginas_necesarias;
/

-- Prueba
SELECT fn_paginas_necesarias('CONDUCTOR', 5) AS paginas FROM DUAL;
SELECT fn_paginas_necesarias('INFRACCION') AS paginas FROM DUAL;

Ejercicio 3: Reporte dinámico

Crea un procedimiento que genere un reporte configurable de multas:

CREATE OR REPLACE PROCEDURE pr_reporte_multas
    (p_orden     IN VARCHAR2 DEFAULT 'fecha_multa',
     p_direccion IN VARCHAR2 DEFAULT 'DESC',
     p_limite    IN NUMBER   DEFAULT 10)
IS
    v_sql     VARCHAR2(1000);
    v_cursor  SYS_REFCURSOR;
    v_fecha   DATE;
    v_importe NUMBER;
    v_matricula VARCHAR2(20);
    v_dir     VARCHAR2(4);
BEGIN
    -- Validar dirección
    v_dir := CASE UPPER(p_direccion) 
        WHEN 'ASC' THEN 'ASC' 
        ELSE 'DESC' 
    END;
    
    v_sql := 'SELECT fecha_multa, importe, matricula FROM multa ' ||
             'ORDER BY ' || DBMS_ASSERT.SIMPLE_SQL_NAME(p_orden) || ' ' || v_dir ||
             ' FETCH FIRST :limite ROWS ONLY';
    
    OPEN v_cursor FOR v_sql USING p_limite;
    
    DBMS_OUTPUT.PUT_LINE(RPAD('FECHA', 12) || RPAD('IMPORTE', 12) || 'MATRÍCULA');
    DBMS_OUTPUT.PUT_LINE(RPAD('-', 36, '-'));
    
    LOOP
        FETCH v_cursor INTO v_fecha, v_importe, v_matricula;
        EXIT WHEN v_cursor%NOTFOUND;
        DBMS_OUTPUT.PUT_LINE(RPAD(v_fecha, 12) || 
                             RPAD(v_importe || ' €', 12) || 
                             v_matricula);
    END LOOP;
    
    CLOSE v_cursor;
END pr_reporte_multas;
/

-- Pruebas
EXEC pr_reporte_multas;
EXEC pr_reporte_multas('importe', 'DESC', 5);
EXEC pr_reporte_multas(p_orden => 'matricula', p_direccion => 'ASC');

Resumen

Concepto Detalle
SQL dinámico Sentencias SQL construidas y ejecutadas en tiempo de ejecución
EXECUTE IMMEDIATE Forma principal de ejecutar SQL dinámico en PL/SQL
INTO Captura el resultado de un SELECT dinámico de una fila
USING Pasa valores a las bind variables (:nombre) de la sentencia
OPEN cursor FOR Abre un cursor dinámico para consultas de múltiples filas
SYS_REFCURSOR Tipo predefinido para cursores dinámicos
DBMS_SQL Paquete para SQL dinámico avanzado (estructura desconocida)
Bind variables :nombre en la sentencia + USING para pasar valores de forma segura
DBMS_ASSERT Paquete para validar nombres de objetos SQL (previene inyección)
SQL Injection Vulnerabilidad por concatenar entrada del usuario en SQL; prevenida con bind variables
DDL dinámico EXECUTE IMMEDIATE 'CREATE/ALTER/DROP...'; realiza COMMIT implícito
WHERE 1=1 Patrón útil para construir cláusulas WHERE dinámicas incrementalmente