PHP UD3 TRANSACCIONS

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

Sumario

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();

A conexión pasa a traballar nunha transacción explícita e deixa de confirmar automaticamente as súas modificacións ata realizar commit() ou rollBack().

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 8 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 (asignade unicamente os permisos imprescindibles: SELECT, INSERT, UPDATE e DELETE sobre as táboas necesarias; EXECUTE só se se empregan procedementos almacenados)
  • 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, os ficheiros PHP públicos poden ter permisos 644 e os directorios 755. O ficheiro de credenciais situado fóra da raíz web debe ter permisos máis restritivos (por exemplo, 640) e pertencer a un usuario/grupo axeitado, sen conceder escritura ao servidor web salvo que sexa imprescindible.
  • Ao principio ides faiar en moitas cousas. Lembrade ter posto o display_errors como vimos nas seccións anteriores. Para depurar empregade mensaxes e rexistros sen imprimir credenciais nin datos sensibles. Nas consultas preparadas non se debe construír a SQL concatenando valores do formulario.
  • Lembrar que tamén podedes activar o debugger como está indicado nesta sección da wiki.





Preparación de MySQL 8

A aplicación debe empregar o controlador pdo_mysql e táboas InnoDB. Podemos verificar o motor de cada táboa mediante:

SELECT TABLE_NAME, ENGINE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'php';

Substituiremos php polo nome da base de datos utilizada. Se algunha táboa implicada utiliza un motor sen soporte para transaccións, un ROLLBACK non garante a atomicidade.

A aplicación debe traballar con utf8mb4 e un usuario con privilexios mínimos. Non deben incluírse contrasinais reais nos arquivos públicos 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'); // Substituír polo nome real da base de datos de prácticas.


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,
            PDO::ATTR_EMULATE_PREPARES => false
    ]
);

Ao empregar:

PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_EMULATE_PREPARES => false

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,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );

    $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,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );

    $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:

INICIAR TRANSACCIÓN

INSERT ...

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

UPDATE ...

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.

Exemplo esquemático (substituír TABLE_1, TABLE_2 e as columnas polos nomes reais do esquema antes de executalo):

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. Para evitar actualizacións concorrentes perdidas cando dous usuarios modifican o mesmo libro, convén bloquear a fila do libro mediante SELECT ... FOR UPDATE ao comezo da transacción, antes de borrar as asociacións.




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(
    'BLOQUEAR_LIBRO',
    'SELECT id FROM libros WHERE id = :libro_id FOR UPDATE'
);

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,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );

    /*
     * 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
        ]);

        foreach($datos as $dato){
            $autoresLibro[] = (int) $dato['id'];
        }
        /*  => OUTRA FORMA DE FACER O MESMO SEN EMPREGAR FOREACH
        $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,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );

    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();

    // Bloqueamos o libro para serializar os cambios concorrentes
    // sobre as súas asociacións.
    $stmtBloqueo = $conex->prepare(BLOQUEAR_LIBRO);
    $stmtBloqueo->execute([':libro_id' => $libroId]);
    if ($stmtBloqueo->fetch() === false) {
        throw new RuntimeException('O libro xa non existe.');
    }
    $stmtBloqueo->closeCursor();

    /*
     * 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.


Requisitos previos do esquema

Os exemplos deste exercicio supoñen que existen as táboas libros, exemplares e estados, cos nomes de columnas indicados nas consultas. Deben comprobarse as claves primarias, alleas e as restricións NOT NULL da base real antes de executalos. Os nomes das táboas e os rangos de validación son os deste exercicio, non unha definición universal.

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). Para importes monetarios reais é preferible validar a precisión decimal directamente sobre o texto (por exemplo, cun máximo de dúas cifras decimais), en vez de depender dun cálculo en coma flotante.


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.


Arquivos do exercicio

Empregaremos a mesma separación entre formulario e procesamento que nos exercicios CRUD anteriores:

alta_libro_exemplares.php
campos_exemplares.php
procesar_alta_libro_exemplares.php

O primeiro arquivo recibe o título e o número de exemplares. Nunha segunda petición mostra os campos correspondentes. campos_exemplares.php é unha vista reutilizable, e procesar_alta_libro_exemplares.php executa a transacción.

Arquivo alta_libro_exemplares.php

<?php
$numExemplares = null;
$titulo = '';
$estados = [];
$error = '';

try {
    if (isset($_POST['btnContinuar'])) {
        $titulo = trim($_POST['txtTitulo'] ?? '');
        $numExemplares = filter_input(
            INPUT_POST, 'numExemplares', FILTER_VALIDATE_INT,
            ['options' => ['min_range' => 1, 'max_range' => 9]]
        );

        if ($titulo === '' || mb_strlen($titulo, 'UTF-8') > 300 ||
            $numExemplares === false || $numExemplares === null) {
            throw new InvalidArgumentException('O título ou o número de exemplares non son válidos.');
        }

        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,
            PDO::ATTR_EMULATE_PREPARES => false
        ]);
        $estados = $conex->query(CONSULTAR_ESTADOS)->fetchAll();

        if ($estados === []) {
            throw new RuntimeException('Non hai estados dispoñibles.');
        }
    }
} catch (InvalidArgumentException $e) {
    $error = $e->getMessage();
    $numExemplares = null;
} catch (Throwable $e) {
    error_log($e->getMessage());
    $error = 'Non foi posible preparar o formulario.';
    $numExemplares = null;
}
?>
<!DOCTYPE html>
<html lang="gl">
<head>
    <meta charset="UTF-8">
    <title>Alta de libro e exemplares</title>
</head>
<body>
<h1>Alta de libro e exemplares</h1>
<?php if ($error !== ''): ?>
    <p><?= htmlspecialchars($error, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
<?php endif; ?>
<?php if (isset($_GET['msg'])): ?>
    <p><?= htmlspecialchars((string) $_GET['msg'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
<?php endif; ?>
<?php if (isset($_GET['error'])): ?>
    <p><?= htmlspecialchars((string) $_GET['error'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
<?php endif; ?>

<?php if ($numExemplares === null): ?>
    <form method="post">
        <label for="txtTitulo">Título:</label>
        <input type="text" id="txtTitulo" name="txtTitulo" maxlength="300" required>
        <label for="numExemplares">Número de exemplares (1-9):</label>
        <input type="number" id="numExemplares" name="numExemplares" min="1" max="9" required>
        <button type="submit" name="btnContinuar" value="CONTINUAR">Continuar</button>
    </form>
<?php else: ?>
    <form action="procesar_alta_libro_exemplares.php" method="post">
        <input type="hidden" name="txtTitulo"
               value="<?= htmlspecialchars($titulo, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
        <?php require 'campos_exemplares.php'; ?>
        <button type="submit" name="btnAlta" value="ALTA">Dar de alta</button>
    </form>
<?php endif; ?>
</body>
</html>

Arquivo campos_exemplares.php

<table>
    <thead><tr><th>Exemplar</th><th>Estado</th><th>Imaxe (nome)</th><th>Prezo</th></tr></thead>
    <tbody>
    <?php for ($i = 0; $i < $numExemplares; $i++): ?>
        <tr>
            <td><?= $i + 1 ?></td>
            <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; ?>
    </tbody>
</table>

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.


Arquivo: procesar_alta_libro_exemplares.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,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );

    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.'
        );
    }

    // Os tres arrays deben ter índices consecutivos desde cero.
    if (array_keys($estadosRecibidos) !== range(0, $numExemplares - 1) ||
        array_keys($imaxesRecibidas) !== range(0, $numExemplares - 1) ||
        array_keys($prezosRecibidos) !== range(0, $numExemplares - 1)) {
        throw new RuntimeException('A estrutura dos exemplares non é válida.');
    }

    /*
     * 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;
}

O ficheiro que contén este bloque debe chamarse procesar_alta_libro_exemplares.php, tal como indica o atributo action do formulario. 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

Na segunda fase do formulario de alta_libro_exemplares.php solicítanse os datos de cada exemplar.

Para xerar o formulario empréganse dous bucles:

  • Un bucle for que crea unha fila por cada exemplar indicado polo usuario.
  • Un bucle foreach que percorre os estados dispoñibles para completar o elemento <select> de cada exemplar.

A lista de estados recupérase da base de datos unha única vez mediante CONSULTAR_ESTADOS e almacénase no array $estados.

Deste xeito, reutilizamos os resultados da consulta para todos os exemplares, evitando realizar unha consulta SQL por cada fila do formulario.

Os campos empregan a notación []:

  • lstEstado[]
  • txtImaxe[]
  • txtPrezo[]

Isto permite recibir os datos como arrays en PHP. Os elementos que ocupan a mesma posición nos tres arrays corresponden ao 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).