PHP UD3 TRANSACCIONS

De MediaWiki
Ir a la navegación Ir a la búsqueda

Introdución

En certas ocasións unha operación da aplicación require realizar varias modificacións relacionadas na base de datos.

Por exemplo:

  • dar de alta un libro e varios exemplares;
  • asociar un libro con varios autores;
  • borrar un libro xunto cos datos dependentes que corresponda eliminar;
  • gardar un pedido e todas as súas liñas.

Nestes casos normalmente queremos que se cumpra unha regra:

ou se realizan correctamente todas as operacións, ou non se garda ningunha.

Para conseguir este comportamento empregamos unha transacción.


Que é unha transacción?

Unha transacción é un conxunto de operacións que se consideran unha única unidade lóxica de traballo.

O esquema básico é:

  1. iniciar a transacción;
  2. realizar as operacións;
  3. se todo é correcto, executar COMMIT;
  4. se se produce un erro, executar ROLLBACK.

Por exemplo:

INICIO

    INSERT libro
    INSERT exemplar 1
    INSERT exemplar 2
    INSERT exemplar 3

SE TODO FOI CORRECTO
    COMMIT
SE HOUBO UN ERRO
    ROLLBACK

Se falla a inserción do terceiro exemplar, o ROLLBACK desfará tamén as insercións anteriores realizadas dentro da mesma transacción.


Propiedades ACID

As transaccións adoitan explicarse mediante as propiedades ACID:

  • Atomicidade (Atomicity): todas as operacións da transacción se realizan ou non se realiza ningunha.
  • Consistencia (Consistency): unha transacción debe levar a base de datos dun estado válido a outro estado válido.
  • Illamento (Isolation): as transaccións concorrentes deben interferir entre si de forma controlada.
  • Durabilidade (Durability): unha vez realizado o COMMIT, os cambios deben permanecer gardados.


Autocommit

Normalmente MySQL traballa con autocommit activado.

Isto significa que unha orde individual como:

UPDATE contas
SET saldo = saldo - 100
WHERE id = 1;

queda confirmada automaticamente cando remata correctamente.

Cando iniciamos explicitamente unha transacción con PDO:

$conex->beginTransaction();

PDO desactiva temporalmente o modo de confirmación automática para esa conexión.

Os cambios non se confirman ata executar:

$conex->commit();

Se executamos:

$conex->rollBack();

desfaranse os cambios realizados desde o inicio da transacción.

Ao finalizar mediante commit() ou rollBack(), a conexión volve ao funcionamento normal con autocommit.


Requirimentos e consideracións

Para empregar transaccións debemos traballar cun motor de almacenamento que as soporte.

En MySQL empregaremos normalmente:

InnoDB

As táboas implicadas na operación deben empregar un motor transaccional.


Nos exemplos desta unidade empregaremos:

  • PHP 8.5.
  • PDO como mecanismo principal de acceso á base de datos.
  • MySQL/MariaDB con táboas InnoDB.
  • consultas preparadas para os valores variables.
  • excepcións para detectar erros.
  • error_log() para rexistrar a información técnica.



  • CONSIDERACIÓNS:
  • Crear un usuario/password con permisos de acceso a dita base de datos (sempre dádelle os mínimos permisos posibles, neste caso, serán os de SELECT/UPDATE/DELETE/INSERT/EXECUTE de toda a base de datos)
  • Crear nun arquivo separado do sitio web todos os datos necesarios para conectarse á base de datos e onde van estar definidas as constantes coas ordes SQL, tal como vimos nun punto anterior desta sección.
  • Lembrar poñer as ordes para amosar os erros que se poidan dar. Nunha aplicación final esas ordes hai que quitalas.
  • En Linux, lembrar dar permiso 644 (rw,r,r) aos arquivos php e cambiar o propietario a www-data:www-data. Nos cartafoles permisos 755 cando rematedes.
  • Ao principio ides faiar en moitas cousas. Lembrade ter posto o display_errors como vimos nas seccións anteriores. Tamén axuda moito facer un echo da sentenza a enviar á base de datos antes de enviala para ver que estea todo ben (espazos en branco, comillas,...).
  • Lembrar que tamén podedes activar o debugger como está indicado nesta sección da wiki.





Arquivo de configuración

Como nas seccións anteriores, os datos de conexión e as consultas estarán nun arquivo situado fóra de /var/www/html:

/var/www/confBD/bd.php

Por exemplo:

<?php

define('SERVIDOR', 'localhost');
define('USUARIO', 'user_php');
define('PASSWORD', 'user_php');
define('BD', 'php');


Conexión PDO empregada nos exemplos

$dsn = sprintf(
    'mysql:host=%s;dbname=%s;charset=utf8mb4',
    SERVIDOR,
    BD
);

$conex = new PDO(
    $dsn,
    USUARIO,
    PASSWORD,
    [
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
    ]
);

Ao empregar:

PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION

un erro SQL provoca unha PDOException, o que facilita realizar o ROLLBACK desde o bloque catch.


Funcionamento dunha transacción con PDO

O patrón básico será:

try {

    require '/var/www/confBD/bd.php';

    $dsn = sprintf(
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
        SERVIDOR,
        BD
    );

    $conex = new PDO(
        $dsn,
        USUARIO,
        PASSWORD,
        [
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
        ]
    );

    $conex->beginTransaction();

    // Operación 1
    // Operación 2
    // Operación 3

    $conex->commit();

} catch (PDOException $e) {

    if (
        isset($conex) &&
        $conex->inTransaction()
    ) {
        $conex->rollBack();
    }

    error_log($e->getMessage());

    die('Erro relacionado coa base de datos.');

} catch (Throwable $e) {

    if (
        isset($conex) &&
        $conex->inTransaction()
    ) {
        $conex->rollBack();
    }

    error_log($e->getMessage());

    die('Produciuse un erro na aplicación.');
}


beginTransaction()

Para iniciar a transacción empregamos:

$conex->beginTransaction();

A partir dese momento, as modificacións realizadas pola conexión non quedan confirmadas definitivamente ata executar commit().


commit()

Se todas as operacións finalizaron correctamente:

$conex->commit();

confirma os cambios.

O commit() debe executarse unicamente cando todas as operacións que forman a unidade lóxica finalizaron correctamente.


rollBack()

Se se produce unha excepción:

$conex->rollBack();

desfai os cambios realizados dentro da transacción.

Antes de chamar a rollBack() resulta conveniente comprobar:

$conex->inTransaction()

Por exemplo:

if (
    isset($conex) &&
    $conex->inTransaction()
) {
    $conex->rollBack();
}

Isto evita intentar desfacer unha transacción que xa non está activa.


Exemplo completo

No arquivo bd.php poderiamos ter:

define(
    'INSERIR_PEDIDO',
    'INSERT INTO pedidos (cliente_id)
     VALUES (:cliente_id)'
);

define(
    'INSERIR_LINA_PEDIDO',
    'INSERT INTO lina_pedido (
         pedido_id,
         produto_id,
         cantidade
     )
     VALUES (
         :pedido_id,
         :produto_id,
         :cantidade
     )'
);

E a páxina PHP:

<?php

try {

    require '/var/www/confBD/bd.php';

    $dsn = sprintf(
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
        SERVIDOR,
        BD
    );

    $conex = new PDO(
        $dsn,
        USUARIO,
        PASSWORD,
        [
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
        ]
    );

    $clienteId = 10;

    $produtos = [
        [
            'produto_id' => 5,
            'cantidade' => 2
        ],
        [
            'produto_id' => 8,
            'cantidade' => 1
        ]
    ];

    $conex->beginTransaction();

    /*
     * Creamos o pedido.
     */
    $stmtPedido = $conex->prepare(
        INSERIR_PEDIDO
    );

    $stmtPedido->execute([
        ':cliente_id' => $clienteId
    ]);

    /*
     * Recuperamos o identificador antes
     * de finalizar a transacción.
     */
    $pedidoId =
        (int) $conex->lastInsertId();

    /*
     * Preparamos unha única vez a consulta
     * que imos executar varias veces.
     */
    $stmtLina = $conex->prepare(
        INSERIR_LINA_PEDIDO
    );

    foreach ($produtos as $produto) {

        $stmtLina->execute([
            ':pedido_id' => $pedidoId,
            ':produto_id' =>
                $produto['produto_id'],
            ':cantidade' =>
                $produto['cantidade']
        ]);
    }

    /*
     * Todas as operacións remataron ben.
     */
    $conex->commit();

} catch (PDOException $e) {

    if (
        isset($conex) &&
        $conex->inTransaction()
    ) {
        $conex->rollBack();
    }

    error_log($e->getMessage());

    die('Erro relacionado coa base de datos.');

} catch (Throwable $e) {

    if (
        isset($conex) &&
        $conex->inTransaction()
    ) {
        $conex->rollBack();
    }

    error_log($e->getMessage());

    die('Produciuse un erro na aplicación.');
}

Fixarse en varios detalles:

  • as consultas variables son preparadas;
  • a consulta das liñas se prepara unha soa vez e se reutiliza dentro do bucle;
  • lastInsertId() se consulta antes de finalizar a transacción;
  • se calquera execute() lanza unha excepción, non se chega ao commit();
  • o bloque catch realiza o rollBack();
  • non se mostra ao usuario o texto interno da excepción.


Que operacións deben incluírse na transacción?

Unha transacción debe ser o máis curta posible, pero debe conter todas as operacións que forman unha única unidade lóxica.

Por exemplo, non deberiamos facer:

$conex->beginTransaction();

INSERT ...

// Procesamento moi longo.
// Chamadas externas.
// Interacción co usuario.
// Esperas.

UPDATE ...

$conex->commit();

porque durante unha transacción poden manterse bloqueos sobre filas e índices.

O recomendable é:

  1. validar previamente todos os datos que poidamos validar en PHP;
  2. preparar os datos necesarios;
  3. iniciar a transacción;
  4. realizar unicamente as operacións da base de datos necesarias;
  5. confirmar ou desfacer;
  6. finalizar a transacción canto antes.


Sentenzas DDL e COMMIT implícito

Debemos evitar executar sentenzas de definición de datos dentro dunha transacción MySQL.

Por exemplo:

CREATE TABLE ...
ALTER TABLE ...
DROP TABLE ...

MySQL pode realizar un COMMIT implícito con este tipo de sentenzas.

Isto significa que non debemos esperar que:

$conex->rollBack();

poida desfacer sempre cambios anteriores se durante a transacción executamos sentenzas DDL.

As transaccións dos exemplos desta unidade estarán formadas fundamentalmente por operacións:

SELECT
INSERT
UPDATE
DELETE


Niveis de illamento

Cando varias transaccións se executan simultaneamente debemos considerar como ven unhas transaccións os cambios realizados polas outras.

Isto está relacionado co nivel de illamento.

InnoDB soporta os niveis:

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

En MySQL/InnoDB o nivel por defecto é normalmente:

REPEATABLE READ


Problemas de concorrencia

Os niveis de illamento están relacionados con problemas como:

  • lecturas sucias;
  • lecturas non repetibles;
  • lecturas fantasma;
  • bloqueos entre transaccións.

Non debemos entender o illamento simplemente como "bloquear toda a táboa".

InnoDB utiliza mecanismos de concorrencia, instantáneas e bloqueos sobre rexistros e índices dependendo:

  • do nivel de illamento;
  • do tipo de consulta;
  • dos índices empregados;
  • das filas que se modifican ou bloquean.


Modificar o nivel de illamento

Normalmente non necesitaremos cambialo nos primeiros exemplos.

Se fose necesario, MySQL permite configurar o nivel dunha transacción mediante SQL.

Por exemplo:

SET TRANSACTION ISOLATION LEVEL
READ COMMITTED;

Debe facerse comprendendo previamente as consecuencias sobre a concorrencia e a consistencia.


Interbloqueos ou deadlocks

Un deadlock pode producirse cando dúas transaccións esperan mutuamente por recursos bloqueados pola outra.

Un exemplo simplificado:

TRANSACCIÓN A
    bloquea fila 1
    espera pola fila 2

TRANSACCIÓN B
    bloquea fila 2
    espera pola fila 1

InnoDB pode detectar o interbloqueo e desfacer unha das transaccións para permitir que a outra continúe.

Por este motivo, unha aplicación con moita concorrencia debe estar preparada para que unha transacción poida fallar e, cando resulte apropiado, repetir a operación.

Para reducir os deadlocks resulta útil:

  • acceder ás táboas e filas nunha orde consistente;
  • manter as transaccións curtas;
  • utilizar índices axeitados;
  • bloquear unicamente os datos necesarios.


Transaccións en procedementos almacenados

Unha transacción tamén pode controlarse desde un procedemento almacenado MySQL.

Neste caso debemos decidir onde se encontra o límite da transacción:

  • alternativa A: a transacción é controlada por PHP/PDO;
  • alternativa B: a transacción é controlada polo procedemento almacenado.

Non debemos iniciar unha transacción en PHP e, dentro dela, chamar a un procedemento que executa outro START TRANSACTION.

MySQL non admite transaccións anidadas reais. Un novo START TRANSACTION pode finalizar implicitamente a transacción anterior.


Alternativa A: transacción controlada por PDO

O procedemento almacenado non executa:

START TRANSACTION
COMMIT
ROLLBACK

PHP controla todo:

$conex->beginTransaction();

try {

    // CALL procedemento 1
    // CALL procedemento 2
    // OUTRAS OPERACIÓNS

    $conex->commit();

} catch (Throwable $e) {

    if ($conex->inTransaction()) {
        $conex->rollBack();
    }

    throw $e;
}

Esta opción é útil cando a unidade de traballo abrangue varias chamadas ou operacións diferentes realizadas desde PHP.


Alternativa B: transacción controlada polo procedemento

O procedemento pode encapsular completamente a operación.

Por exemplo:

DELIMITER $$

CREATE PROCEDURE PROCEDEMENTO_CON_TRANSACCION(
    IN p_param1 INT,
    IN p_param2 VARCHAR(50),
    IN p_param3 VARCHAR(200)
)
BEGIN

    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    INSERT INTO TABLE_1 (
        col1,
        col2
    )
    VALUES (
        p_param2,
        p_param3
    );

    INSERT INTO TABLE_2 (
        col1,
        col2
    )
    VALUES (
        p_param1,
        LAST_INSERT_ID()
    );

    COMMIT;

END$$

DELIMITER ;

Neste caso:

  • START TRANSACTION inicia a transacción dentro do procedemento;
  • o EXIT HANDLER captura calquera excepción SQL;
  • ROLLBACK desfai os cambios;
  • RESIGNAL volve propagar o erro cara a PHP;
  • COMMIT só se executa se todas as operacións anteriores rematan correctamente.

Desde PHP non iniciaremos outra transacción:

$stmt = $conex->prepare(
    'CALL PROCEDEMENTO_CON_TRANSACCION(
        :param1,
        :param2,
        :param3
    )'
);

$stmt->execute([
    ':param1' => $param1,
    ':param2' => $param2,
    ':param3' => $param3
]);

$stmt->closeCursor();

Se o procedemento lanza novamente o erro mediante RESIGNAL, PDO recibirá unha PDOException.


Exercicio proposto 1: asociar libros e autores

Queremos xestionar unha relación N:M entre libros e autores.

A idea é:

  1. seleccionar un libro nun <select>;
  2. amosar todos os autores;
  3. marcar mediante checkbox os autores xa asociados ao libro;
  4. permitir marcar ou desmarcar autores;
  5. ao premer ASOCIAR, substituír as asociacións actuais polas seleccionadas.

A operación final implica:

  1. borrar as asociacións existentes do libro;
  2. inserir unha fila por cada autor seleccionado.

Estas operacións deben formar unha transacción.




Arquivos

Empregaremos:

alta_libro_autores.php
procesar_alta_libro_autores.php



  • Arquivo bd.php
define(
    'CONSULTAR_LIBROS',
    'SELECT id, titulo
     FROM libros
     ORDER BY titulo'
);

define(
    'CONSULTAR_AUTORES',
    'SELECT id, apelidos, nome
     FROM autores
     ORDER BY apelidos, nome'
);

define(
    'CONSULTAR_AUTORES_POR_LIBRO',
    'SELECT
         autores.id,
         autores.apelidos,
         autores.nome
     FROM autores
     INNER JOIN libro_autor
         ON libro_autor.autor_id = autores.id
     WHERE libro_autor.libro_id = :libro_id
     ORDER BY autores.apelidos, autores.nome'
);

define(
    'INSERIR_LIBRO_AUTOR',
    'INSERT INTO libro_autor (
         libro_id,
         autor_id
     )
     VALUES (
         :libro_id,
         :autor_id
     )'
);

define(
    'BORRAR_LIBRO_AUTOR_POR_LIBRO',
    'DELETE FROM libro_autor
     WHERE libro_id = :libro_id'
);




  • Arrays enviados por formularios

Cada autor pode representarse cun checkbox:

<input
    type="checkbox"
    name="autores[]"
    value="10"
>

Ao repetir:

name="autores[]"

PHP recibirá os autores seleccionados como un array:

$_POST['autores']

Se o usuario non selecciona ningún autor, o campo pode non chegar na petición.

Por iso empregaremos:

$autoresRecibidos =
    $_POST['autores'] ?? [];

Isto tamén permite eliminar todas as asociacións dun libro deixando todos os checkbox sen marcar.




  • Arquivo alta_libro_autores.php
<?php

try {

    require '/var/www/confBD/bd.php';

    $dsn = sprintf(
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
        SERVIDOR,
        BD
    );

    $conex = new PDO(
        $dsn,
        USUARIO,
        PASSWORD,
        [
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
        ]
    );

    /*
     * Lista de libros.
     */
    $stmt = $conex->query(
        CONSULTAR_LIBROS
    );

    $libros = $stmt->fetchAll();

    $libroId = null;
    $autores = [];
    $autoresLibro = [];

    /*
     * Se o usuario seleccionou un libro,
     * cargamos todos os autores e as
     * asociacións existentes.
     */
    if (isset($_POST['btnLibro'])) {

        $libroId = filter_input(
            INPUT_POST,
            'lstLibros',
            FILTER_VALIDATE_INT,
            [
                'options' => [
                    'min_range' => 1
                ]
            ]
        );

        if (
            $libroId === false ||
            $libroId === null
        ) {
            throw new RuntimeException(
                'O libro seleccionado non é válido.'
            );
        }

        $stmt = $conex->query(
            CONSULTAR_AUTORES
        );

        $autores = $stmt->fetchAll();

        $stmt = $conex->prepare(
            CONSULTAR_AUTORES_POR_LIBRO
        );

        $stmt->execute([
            ':libro_id' => $libroId
        ]);

        $autoresLibro = array_map(
            'intval',
            array_column(
                $stmt->fetchAll(),
                'id'
            )
        );
    }

} catch (PDOException $e) {

    error_log($e->getMessage());

    die('Erro relacionado coa base de datos.');

} catch (Throwable $e) {

    error_log($e->getMessage());

    die('Produciuse un erro na aplicación.');
}
?>
<!DOCTYPE html>
<html lang="gl">
<head>
    <meta charset="UTF-8">
    <title>Xestión de libros e autores</title>
</head>
<body>

    <h1>Asociar libros e autores</h1>

    <form method="post">

        <label for="lstLibros">
            Libro:
        </label>

        <select
            id="lstLibros"
            name="lstLibros"
            required
        >

            <option value="">
                Escolle un libro
            </option>

            <?php foreach ($libros as $libro): ?>

                <?php
                    $idLibro =
                        (int) $libro['id'];
                ?>

                <option
                    value="<?= $idLibro ?>"
                    <?= $libroId === $idLibro
                        ? 'selected'
                        : '' ?>
                >
                    <?= htmlspecialchars(
                        $libro['titulo'],
                        ENT_QUOTES | ENT_SUBSTITUTE,
                        'UTF-8'
                    ) ?>
                </option>

            <?php endforeach; ?>

        </select>

        <button
            type="submit"
            name="btnLibro"
            value="BUSCAR"
        >
            Buscar autores
        </button>

    </form>

    <?php if ($libroId !== null): ?>

        <form
            action="procesar_alta_libro_autores.php"
            method="post"
        >

            <input
                type="hidden"
                name="libro_id"
                value="<?= $libroId ?>"
            >

            <table>
                <thead>
                    <tr>
                        <th>Autor</th>
                        <th>Asociado</th>
                    </tr>
                </thead>

                <tbody>

                <?php foreach ($autores as $autor): ?>

                    <?php
                        $autorId =
                            (int) $autor['id'];
                    ?>

                    <tr>
                        <td>
                            <?= htmlspecialchars(
                                $autor['apelidos'] .
                                ', ' .
                                $autor['nome'],
                                ENT_QUOTES |
                                ENT_SUBSTITUTE,
                                'UTF-8'
                            ) ?>
                        </td>

                        <td>
                            <input
                                type="checkbox"
                                name="autores[]"
                                value="<?= $autorId ?>"
                                <?= in_array(
                                    $autorId,
                                    $autoresLibro,
                                    true
                                )
                                    ? 'checked'
                                    : '' ?>
                            >
                        </td>
                    </tr>

                <?php endforeach; ?>

                </tbody>
            </table>

            <button
                type="submit"
                name="btnAsociar"
                value="ASOCIAR"
            >
                Asociar
            </button>

        </form>

    <?php endif; ?>

    <?php if (isset($_GET['msg'])): ?>

        <p>
            <?= htmlspecialchars(
                $_GET['msg'],
                ENT_QUOTES | ENT_SUBSTITUTE,
                'UTF-8'
            ) ?>
        </p>

    <?php endif; ?>

    <?php if (isset($_GET['error'])): ?>

        <p>
            <?= htmlspecialchars(
                $_GET['error'],
                ENT_QUOTES | ENT_SUBSTITUTE,
                'UTF-8'
            ) ?>
        </p>

    <?php endif; ?>

</body>
</html>

Non empregamos rowCount() para comprobar se un SELECT devolveu filas.

Recuperamos os resultados mediante:

$stmt->fetchAll()

e traballamos directamente co array obtido.




  • Arquivo procesar_alta_libro_autores.php

Esta é a operación realmente transaccional.

<?php

try {

    require '/var/www/confBD/bd.php';

    $dsn = sprintf(
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
        SERVIDOR,
        BD
    );

    $conex = new PDO(
        $dsn,
        USUARIO,
        PASSWORD,
        [
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
        ]
    );

    if (
        !isset($_POST['btnAsociar']) ||
        $_POST['btnAsociar'] !== 'ASOCIAR'
    ) {
        throw new RuntimeException(
            'Operación non válida.'
        );
    }

    /*
     * Validamos o libro.
     */
    $libroId = filter_input(
        INPUT_POST,
        'libro_id',
        FILTER_VALIDATE_INT,
        [
            'options' => [
                'min_range' => 1
            ]
        ]
    );

    if (
        $libroId === false ||
        $libroId === null
    ) {
        throw new RuntimeException(
            'O libro non é válido.'
        );
    }

    /*
     * Pode chegar un array baleiro
     * se non hai ningún autor seleccionado.
     */
    $autoresRecibidos =
        $_POST['autores'] ?? [];

    if (!is_array($autoresRecibidos)) {
        throw new RuntimeException(
            'Os autores non teñen un formato válido.'
        );
    }

    /*
     * Validamos todos os identificadores
     * ANTES de iniciar a transacción.
     */
    $autores = [];

    foreach ($autoresRecibidos as $autor) {

        $autorId = filter_var(
            $autor,
            FILTER_VALIDATE_INT,
            [
                'options' => [
                    'min_range' => 1
                ]
            ]
        );

        if ($autorId === false) {
            throw new RuntimeException(
                'Existe un autor non válido.'
            );
        }

        $autores[] = $autorId;
    }

    /*
     * Evitamos repetidos recibidos
     * nunha petición manipulada.
     */
    $autores = array_values(
        array_unique($autores)
    );

    /*
     * INICIO DA TRANSACCIÓN.
     */
    $conex->beginTransaction();

    /*
     * Eliminamos as asociacións anteriores.
     */
    $stmtBorrar = $conex->prepare(
        BORRAR_LIBRO_AUTOR_POR_LIBRO
    );

    $stmtBorrar->execute([
        ':libro_id' => $libroId
    ]);

    /*
     * Preparamos unha única vez o INSERT.
     */
    $stmtInserir = $conex->prepare(
        INSERIR_LIBRO_AUTOR
    );

    foreach ($autores as $autorId) {

        $stmtInserir->execute([
            ':libro_id' => $libroId,
            ':autor_id' => $autorId
        ]);
    }

    /*
     * Se chegamos aquí, todas as operacións
     * se realizaron correctamente.
     */
    $conex->commit();

    header(
        'Location: alta_libro_autores.php?msg=' .
        urlencode(
            'As asociacións foron actualizadas correctamente.'
        )
    );
    exit;

} catch (PDOException $e) {

    if (
        isset($conex) &&
        $conex->inTransaction()
    ) {
        $conex->rollBack();
    }

    error_log($e->getMessage());

    header(
        'Location: alta_libro_autores.php?error=' .
        urlencode('Erro relacionado coa base de datos.')
    );
    exit;

} catch (Throwable $e) {

    if (
        isset($conex) &&
        $conex->inTransaction()
    ) {
        $conex->rollBack();
    }

    error_log($e->getMessage());

    header(
        'Location: alta_libro_autores.php?error=' .
        urlencode('Produciuse un erro na aplicación.')
    );
    exit;
}

Fixarse en que validamos todos os identificadores antes de iniciar a transacción.

Desta forma a transacción permanece aberta o menor tempo posible.


Exercicio proposto 2: alta dun libro e varios exemplares

Queremos crear unha páxina que permita:

  1. indicar o título dun libro;
  2. indicar cantos exemplares queremos dar de alta;
  3. amosar un segundo formulario con datos para cada exemplar;
  4. dar de alta o libro;
  5. obter o identificador autonumérico xerado;
  6. inserir todos os exemplares asociados ao novo libro.

Os datos de cada exemplar serán:

  • estado;
  • imaxe;
  • prezo.

A operación debe ser atómica:

se falla a alta de calquera exemplar, non debe quedar gardado nin o libro nin ningún dos exemplares.


Deseño recomendado

Neste exercicio hai dúas alternativas válidas:

  • realizar toda a transacción desde PDO;
  • crear un único procedemento almacenado que encapsule a transacción completa.

Non é recomendable crear un procedemento para inserir o libro, outro para inserir cada exemplar e ademais iniciar unha transacción tanto en PHP como dentro dos procedementos.

Nesta solución principal faremos a transacción desde PDO.

Despois amosaremos unha alternativa cun procedemento almacenado.


Consultas necesarias

No arquivo bd.php:

define(
    'CONSULTAR_ESTADOS',
    'SELECT id, descripcion
     FROM estados
     ORDER BY descripcion'
);

define(
    'INSERIR_LIBRO',
    'INSERT INTO libros (titulo)
     VALUES (:titulo)'
);

define(
    'INSERIR_EXEMPLAR',
    'INSERT INTO exemplares (
         libro_id,
         id_exemplar,
         imaxe,
         prezo,
         estado_id
     )
     VALUES (
         :libro_id,
         :id_exemplar,
         :imaxe,
         :prezo,
         :estado_id
     )'
);

Se o título debe ser único, esta regra debería estar garantida tamén mediante unha restrición UNIQUE na base de datos.


Validación dos prezos

Un valor decimal procedente dun formulario chega a PHP como texto.

Podemos normalizar unha coma decimal a punto:

$prezoTexto = str_replace(
    ',',
    '.',
    trim($prezoTexto)
);

e validalo:

$prezo = filter_var(
    $prezoTexto,
    FILTER_VALIDATE_FLOAT
);

if (
    $prezo === false ||
    $prezo <= 0
) {
    // Prezo non válido.
}

PDO non dispón de PDO::PARAM_FLOAT.

Por iso, unha vez validado o decimal, podemos envialo como cadea:

$stmt->bindValue(
    ':prezo',
    (string) $prezo,
    PDO::PARAM_STR
);

A columna MySQL correspondente seguirá sendo de tipo numérico, por exemplo DECIMAL(6,2).


Non sanitizamos a imaxe para gardala

Se neste exercicio o campo imaxe contén un nome ou ruta textual, validaremos a súa lonxitude e formato.

Por exemplo:

$imaxe = trim(
    $imaxeRecibida
);

if (
    $imaxe === '' ||
    mb_strlen($imaxe, 'UTF-8') > 100
) {
    // Dato non válido.
}

Non empregaremos:

FILTER_SANITIZE_FULL_SPECIAL_CHARS

para transformar o dato antes de gardalo.

Se posteriormente se mostra nun documento HTML, empregaremos htmlspecialchars() nese momento.


Procesamento transaccional do libro e os exemplares

Supoñemos que o segundo formulario envía:

txtTitulo
lstEstado[]
txtImaxe[]
txtPrezo[]

Antes de iniciar a transacción validaremos que os arrays existen e teñen o mesmo número de elementos.

<?php

try {

    require '/var/www/confBD/bd.php';

    $dsn = sprintf(
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
        SERVIDOR,
        BD
    );

    $conex = new PDO(
        $dsn,
        USUARIO,
        PASSWORD,
        [
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
        ]
    );

    if (
        !isset($_POST['btnAlta']) ||
        $_POST['btnAlta'] !== 'ALTA'
    ) {
        throw new RuntimeException(
            'Operación non válida.'
        );
    }

    /*
     * Validamos o título.
     */
    $titulo = trim(
        $_POST['txtTitulo'] ?? ''
    );

    if (
        $titulo === '' ||
        mb_strlen($titulo, 'UTF-8') > 300
    ) {
        throw new RuntimeException(
            'O título non é válido.'
        );
    }

    /*
     * Recuperamos os arrays.
     */
    $estadosRecibidos =
        $_POST['lstEstado'] ?? [];

    $imaxesRecibidas =
        $_POST['txtImaxe'] ?? [];

    $prezosRecibidos =
        $_POST['txtPrezo'] ?? [];

    if (
        !is_array($estadosRecibidos) ||
        !is_array($imaxesRecibidas) ||
        !is_array($prezosRecibidos)
    ) {
        throw new RuntimeException(
            'Os datos dos exemplares non son válidos.'
        );
    }

    $numExemplares =
        count($estadosRecibidos);

    if (
        $numExemplares < 1 ||
        $numExemplares > 9 ||
        count($imaxesRecibidas) !==
            $numExemplares ||
        count($prezosRecibidos) !==
            $numExemplares
    ) {
        throw new RuntimeException(
            'O número de exemplares non é válido.'
        );
    }

    /*
     * VALIDAMOS TODO ANTES DA TRANSACCIÓN.
     */
    $exemplares = [];

    for (
        $indice = 0;
        $indice < $numExemplares;
        $indice++
    ) {

        $estadoId = filter_var(
            $estadosRecibidos[$indice],
            FILTER_VALIDATE_INT,
            [
                'options' => [
                    'min_range' => 1
                ]
            ]
        );

        if ($estadoId === false) {
            throw new RuntimeException(
                'Existe un estado non válido.'
            );
        }

        $imaxe = trim(
            $imaxesRecibidas[$indice]
        );

        if (
            $imaxe === '' ||
            mb_strlen(
                $imaxe,
                'UTF-8'
            ) > 100
        ) {
            throw new RuntimeException(
                'Existe un nome de imaxe non válido.'
            );
        }

        $prezoTexto = str_replace(
            ',',
            '.',
            trim(
                $prezosRecibidos[$indice]
            )
        );

        $prezo = filter_var(
            $prezoTexto,
            FILTER_VALIDATE_FLOAT
        );

        if (
            $prezo === false ||
            $prezo <= 0
        ) {
            throw new RuntimeException(
                'Existe un prezo non válido.'
            );
        }

        $exemplares[] = [
            'estado_id' => $estadoId,
            'imaxe' => $imaxe,
            'prezo' => $prezo
        ];
    }

    /*
     * A PARTIR DE AQUÍ EMPEZA
     * A TRANSACCIÓN.
     */
    $conex->beginTransaction();

    /*
     * Inserimos o libro.
     */
    $stmtLibro = $conex->prepare(
        INSERIR_LIBRO
    );

    $stmtLibro->execute([
        ':titulo' => $titulo
    ]);

    $libroId =
        (int) $conex->lastInsertId();

    /*
     * Preparamos unha única vez
     * o INSERT dos exemplares.
     */
    $stmtExemplar = $conex->prepare(
        INSERIR_EXEMPLAR
    );

    foreach (
        $exemplares as $indice => $exemplar
    ) {

        $stmtExemplar->bindValue(
            ':libro_id',
            $libroId,
            PDO::PARAM_INT
        );

        $stmtExemplar->bindValue(
            ':id_exemplar',
            $indice + 1,
            PDO::PARAM_INT
        );

        $stmtExemplar->bindValue(
            ':imaxe',
            $exemplar['imaxe'],
            PDO::PARAM_STR
        );

        $stmtExemplar->bindValue(
            ':prezo',
            (string) $exemplar['prezo'],
            PDO::PARAM_STR
        );

        $stmtExemplar->bindValue(
            ':estado_id',
            $exemplar['estado_id'],
            PDO::PARAM_INT
        );

        $stmtExemplar->execute();
    }

    /*
     * Todo foi correcto.
     */
    $conex->commit();

    header(
        'Location: alta_libro_exemplares.php?msg=' .
        urlencode(
            'Libro e exemplares dados de alta correctamente.'
        )
    );
    exit;

} catch (PDOException $e) {

    if (
        isset($conex) &&
        $conex->inTransaction()
    ) {
        $conex->rollBack();
    }

    error_log($e->getMessage());

    $codigoMySQL =
        $e->errorInfo[1] ?? null;

    if ($codigoMySQL === 1062) {

        $mensaxe =
            'Xa existe un rexistro cos mesmos datos clave.';

    } elseif ($codigoMySQL === 1452) {

        $mensaxe =
            'Algún dos datos relacionados non existe.';

    } else {

        $mensaxe =
            'Erro relacionado coa base de datos.';
    }

    header(
        'Location: alta_libro_exemplares.php?error=' .
        urlencode($mensaxe)
    );
    exit;

} catch (Throwable $e) {

    if (
        isset($conex) &&
        $conex->inTransaction()
    ) {
        $conex->rollBack();
    }

    error_log($e->getMessage());

    header(
        'Location: alta_libro_exemplares.php?error=' .
        urlencode('Produciuse un erro na aplicación.')
    );
    exit;
}

Neste exemplo unha excepción producida ao inserir calquera exemplar provoca o salto ao catch e o posterior rollBack().

Polo tanto, non debemos escribir lóxica do tipo:

if (!$stmt->execute()) {
    $error = '...';
    break;
}

$conex->commit();

porque, con PDO::ERRMODE_EXCEPTION, o mecanismo natural é deixar que a excepción interrompa a secuencia e desfacer a transacción no catch.


Formulario para os exemplares

A lista de estados debe consultarse unha única vez:

$stmt = $conex->query(
    CONSULTAR_ESTADOS
);

$estados = $stmt->fetchAll();

Despois reutilizamos o array para todos os exemplares:

<?php for (
    $i = 0;
    $i < $numExemplares;
    $i++
): ?>

    <tr>

        <td>
            <select
                name="lstEstado[]"
                required
            >

                <?php foreach (
                    $estados as $estado
                ): ?>

                    <option
                        value="<?= (int) $estado['id'] ?>"
                    >
                        <?= htmlspecialchars(
                            $estado['descripcion'],
                            ENT_QUOTES |
                            ENT_SUBSTITUTE,
                            'UTF-8'
                        ) ?>
                    </option>

                <?php endforeach; ?>

            </select>
        </td>

        <td>
            <input
                type="text"
                name="txtImaxe[]"
                maxlength="100"
                required
            >
        </td>

        <td>
            <input
                type="number"
                name="txtPrezo[]"
                min="0.01"
                step="0.01"
                required
            >
        </td>

    </tr>

<?php endfor; ?>

Ao usar:

lstEstado[]
txtImaxe[]
txtPrezo[]

os índices dos tres arrays permiten relacionar os datos do mesmo exemplar.


Alternativa: procedemento almacenado para libro e exemplares

Se queremos que a transacción sexa controlada por MySQL, o ideal é que a unidade de traballo completa estea encapsulada no procedemento.

Para un número variable de exemplares isto require deseñar como se enviarán todos os datos ao servidor.

Un procedemento que unicamente insire o libro e outro que insire individualmente cada exemplar non encapsulan por si sós a unidade completa se PHP é quen realiza múltiples chamadas.

Por iso temos dúas opcións:

  • manter a transacción en PDO, como no exemplo anterior;
  • deseñar un procedemento capaz de recibir e procesar todos os exemplares dentro dunha única chamada.

A primeira opción é máis sinxela para este exercicio didáctico.


Exemplo sinxelo de transacción dentro dun procedemento

Para un caso cun número fixo de operacións:

DELIMITER $$

CREATE PROCEDURE INSERIR_LIBRO_E_EXEMPLAR(
    IN p_titulo VARCHAR(300),
    IN p_imaxe VARCHAR(100),
    IN p_prezo DECIMAL(6,2),
    IN p_estado_id INT
)
BEGIN

    DECLARE v_libro_id INT;

    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;

    INSERT INTO libros (titulo)
    VALUES (p_titulo);

    SET v_libro_id =
        LAST_INSERT_ID();

    INSERT INTO exemplares (
        libro_id,
        id_exemplar,
        imaxe,
        prezo,
        estado_id
    )
    VALUES (
        v_libro_id,
        1,
        p_imaxe,
        p_prezo,
        p_estado_id
    );

    COMMIT;

END$$

DELIMITER ;

Neste caso PHP simplemente chama ao procedemento e non inicia outra transacción.


mysqli como ampliación

PDO será o mecanismo principal empregado nos apuntes.

Con mysqli o patrón equivalente é:

<?php

try {

    require '/var/www/confBD/bd.php';

    mysqli_report(
        MYSQLI_REPORT_ERROR |
        MYSQLI_REPORT_STRICT
    );

    $conex = new mysqli(
        SERVIDOR,
        USUARIO,
        PASSWORD,
        BD
    );

    $conex->set_charset(
        'utf8mb4'
    );

    $conex->begin_transaction();

    /*
     * Operacións preparadas.
     */

    $conex->commit();

} catch (mysqli_sql_exception $e) {

    if (isset($conex)) {

        try {
            $conex->rollback();
        } catch (Throwable) {
            // Non ocultamos a excepción orixinal.
        }
    }

    error_log($e->getMessage());

    die('Erro relacionado coa base de datos.');

} catch (Throwable $e) {

    if (isset($conex)) {

        try {
            $conex->rollback();
        } catch (Throwable) {
            // Non ocultamos a excepción orixinal.
        }
    }

    error_log($e->getMessage());

    die('Produciuse un erro na aplicación.');
}


Exemplo mysqli con consultas preparadas

$conex->begin_transaction();

$stmt = $conex->prepare(
    'INSERT INTO pedidos (cliente_id)
     VALUES (?)'
);

$stmt->bind_param(
    'i',
    $clienteId
);

$stmt->execute();

$pedidoId =
    $conex->insert_id;

$stmtLina = $conex->prepare(
    'INSERT INTO lina_pedido (
         pedido_id,
         produto_id,
         cantidade
     )
     VALUES (?, ?, ?)'
);

foreach ($produtos as $produto) {

    $produtoId =
        $produto['produto_id'];

    $cantidade =
        $produto['cantidade'];

    $stmtLina->bind_param(
        'iii',
        $pedidoId,
        $produtoId,
        $cantidade
    );

    $stmtLina->execute();
}

$conex->commit();

Non é necesario empregar o patrón antigo:

$conex->autocommit(false);

// ...

$conex->autocommit(true);

como forma principal de explicar as transaccións.

Para código novo resulta máis claro iniciar explicitamente a unidade de traballo con:

$conex->begin_transaction();

e rematala con:

$conex->commit();

ou:

$conex->rollback();


Exercicios propostos

Subida de imaxes

Modificar o exercicio de libros e exemplares para que a imaxe deixe de ser simplemente un campo de texto e poida subirse un ficheiro ao servidor.

A subida do ficheiro e a transacción da base de datos son dous mecanismos diferentes.

Un ROLLBACK da base de datos non elimina automaticamente un ficheiro que xa se gardase no sistema de ficheiros.

Polo tanto haberá que deseñar tamén como desfacer ou limpar os ficheiros creados se a operación da base de datos falla.


Asociar autores durante a alta do libro

Ampliar o exercicio de alta dun libro para permitir seleccionar tamén os autores.

A operación completa podería incluír:

  1. inserir o libro;
  2. obter o identificador xerado;
  3. inserir os exemplares;
  4. inserir as relacións en LIBRO_AUTOR;
  5. confirmar todas as operacións mediante un único COMMIT.

Se falla calquera operación deberá realizarse ROLLBACK.


Actualización dunha relación N:M

Seleccionar unha atracción e amosar todos os artistas mediante checkbox.

Os artistas actualmente relacionados aparecerán marcados.

Ao gardar:

  1. eliminar as asociacións anteriores;
  2. inserir as asociacións seleccionadas;
  3. realizar todo dentro dunha única transacción.


Resumo

  • Unha transacción permite agrupar varias modificacións nunha única unidade lóxica.
  • As operacións confírmanse mediante COMMIT.
  • Se se produce un erro, desfanse mediante ROLLBACK.
  • Con PDO iniciamos a transacción mediante beginTransaction().
  • beginTransaction() desactiva temporalmente o autocommit para esa conexión.
  • Antes de facer rollBack() podemos comprobar inTransaction().
  • PDO será o mecanismo principal empregado nos exemplos.
  • As consultas con valores variables realizaranse con prepare().
  • Os datos deben validarse antes de iniciar a transacción sempre que sexa posible.
  • As transaccións deben manterse abertas o menor tempo posible.
  • Un erro SQL con PDO::ERRMODE_EXCEPTION lanza unha PDOException.
  • As excepcións permiten interromper a secuencia e realizar o ROLLBACK no catch.
  • Non mostraremos directamente $e->getMessage() aos usuarios.
  • Empregaremos error_log() para rexistrar o detalle técnico.
  • Debemos empregar un motor transaccional como InnoDB.
  • As sentenzas DDL poden provocar un COMMIT implícito en MySQL.
  • InnoDB soporta os niveis READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ e SERIALIZABLE.
  • O nivel habitual por defecto de InnoDB é REPEATABLE READ.
  • Os deadlocks poden producirse mesmo con transaccións correctamente deseñadas e unha aplicación concorrente debe estar preparada para tratalos.
  • MySQL non admite transaccións anidadas reais.
  • Se a transacción é controlada polo procedemento almacenado, PHP non debe iniciar outra arredor da mesma operación.
  • Nun procedemento almacenado podemos empregar un EXIT HANDLER, ROLLBACK e RESIGNAL.
  • Nunha relación N:M, borrar as asociacións antigas e inserir as novas é un caso típico para empregar unha transacción.
  • Se un conxunto de checkboxes pode quedar totalmente desmarcado, empregaremos $_POST['campo'] ?? [].
  • Para inserir varias filas coa mesma consulta é recomendable preparar a sentenza unha soa vez e reutilizala.
  • lastInsertId() permite obter o identificador xerado para empregalo nas seguintes operacións da mesma unidade de traballo.
  • Un ROLLBACK da base de datos non desfai automaticamente cambios externos, como ficheiros gardados no sistema de ficheiros.
  • mysqli queda como ampliación e tamén debe empregar consultas preparadas.


-- Ángel D. Fernández González -- (2026).