Diferencia entre revisiones de «PHP UD3 TRANSACCIONS»

De MediaWiki
Ir a la navegación Ir a la búsqueda
Línea 1: Línea 1:
−
==Introducción==
+
== Introdución ==
  
−
* En certas ocasións imos necesitar facer varias operacións que conleven modificacións nas táboas da base de datos (operacións de INSERT, UPDATE, DELETE). Se queremos que nunha páxina php se <u>fagan varias modificacións</u> e queremos que se hai calquera problema nunha delas, deixe o estado da base de datos xusto a como estaba antes de facer a primeira operación, necesitamos facer uso das '''transaccións'''.
+
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'''.
  
 
<br />
 
<br />
−
* Por exemplo, no exemplo que estamos a desenvolver:
 
−
:* Podemos crear unha páxina PHP cun formulario para dar de alta un libro xunto cos exemplares.
 
−
:* Podemos crear unha páxina PHP que relacione libros xunto cos autores que o escribiron.
 
−
:* Podemos dar de baixa un libro. Isto leva consigo dar de baixa o libro máis os exemplares máis os datos da táboas LIBROS_AUTORES.
 
  
−
* Outro caso típico é o carrito da compra. Cando o usuario remata o proceso de compra e procede a realizar o pedido, necesitaremos gardar os produtos que conforman o pedido na nosa base de datos.
+
=== Que é unha transacción? ===
−
: Queremos que se garden todos os produtos.
 
  
 +
Unha transacción é un conxunto de operacións que se consideran unha única unidade lóxica de traballo.
  
−
: Normalmente imos queres que ditas operacións se fagan todas e en caso de erro, necesitaremos volver ao estado anterior ao comezo do inicio das operacións.
+
O esquema básico é:
  
−
* Isto o podemos facer grazas as '''TRANSACCIÓNS'''.
+
:# iniciar a transacción;
−
: Unha transacción nos garante que o conxunto de operacións que están dentro dunha transacción se farán todas ou non se fará ningunha.
+
:# realizar as operacións;
 +
:# se todo é correcto, executar <code>COMMIT</code>;
 +
:# se se produce un erro, executar <code>ROLLBACK</code>.
  
−
* Normalmente cada operación individual é 'considerada' coma unha transacción en si mesma, de tal forma o ou ten éxito ou os cambios non se gardan na base de datos.
+
Por exemplo:
  
−
* Máis información sobre Transaccións:
+
::<syntaxhighlight lang="text" enclose="div">
−
:* [https://es.wikipedia.org/wiki/Transacci%C3%B3n_(base_de_datos) Definición Wikipedia].
+
INICIO
−
:* [https://es.wikipedia.org/wiki/ACID Características dunha transacción].
 
−
:* [https://dev.mysql.com/doc/refman/5.7/en/commit.html Transaccións en Mysql].
 
  
 +
    INSERT libro
 +
    INSERT exemplar 1
 +
    INSERT exemplar 2
 +
    INSERT exemplar 3
  
 +
SE TODO FOI CORRECTO
 +
    COMMIT
 +
SE HOUBO UN ERRO
 +
    ROLLBACK
 +
</syntaxhighlight>
  
 +
Se falla a inserción do terceiro exemplar, o <code>ROLLBACK</code> desfará tamén as insercións anteriores realizadas dentro da mesma transacción.
  
−
* Cando falamos de transaccións outro factor a ter en conta é o chamado '''Nivel de illamento'''.
+
<br />
−
: Imaxinemos que estamos dentro dunha transacción e estou lendo o número de filas dunha táboa. Se outro usuario engade unha nova fila despois de que eu lera o número, e eu dentro da transacción volvo a ler o número de filas. Terei dúas cantidades diferentes dentro da mesma transacción.
 
−
: Para evitalo podemos facer que a táboa que estou a utilizar dentro da transacción quede 'bloqueada' e non se poida modificar ata que eu remate.
 
−
: Este e outros problemas que poden darse no uso de transaccións se intentan evitar cun nivel de illamento determinado.
 
−
: Cada nivel de illamento soluciona algún dos problemas que se poden dar.
 
  
−
* Máis información sobre Niveis de illamento en Mysql:
+
=== Propiedades ACID ===
−
:* [http://download.nust.na/pub6/mysql/doc/refman/5.0/es/innodb-transaction-isolation.html Niveis de illamento en Mysql].
 
−
:* [http://recetasinformaticas.blogspot.com.es/2007/09/mysql-niveles-de-aislamiento-isolations.html Exemplos de problemas no uso de transaccións].
 
  
 +
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 <code>COMMIT</code>, os cambios deben permanecer gardados.
  
 +
<br />
  
−
* Dos niveis de illamento (que se basean en bloquear rexistros de táboas) se pode provocar outro problema, que é o '''Interbloqueo''' ou '''Deadlock'''.
+
=== Autocommit ===
−
: Isto prodúcese cando unha transacción_1 está esperando o acceso a un recurso que está sendo bloqueado por outra transacción_2 e esta outra transacción_2 está esperando por un recurso que está sendo bloqueado pola transacción_1.
 
  
−
* Máis información sobre os DeadLock [http://mysql.localhost.net.ar/doc/refman/5.0/es/innodb-deadlocks.html neste enlace].
+
Normalmente MySQL traballa con <code>autocommit</code> activado.
  
 +
Isto significa que unha orde individual como:
  
 +
::<syntaxhighlight lang="sql" enclose="div">
 +
UPDATE contas
 +
SET saldo = saldo - 100
 +
WHERE id = 1;
 +
</syntaxhighlight>
  
−
* '''CONSIDERACIÓNS:'''
+
queda confirmada automaticamente cando remata correctamente.
−
:* 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 [https://wiki.cifprodolfoucha.es/index.php?title=PHP_Operaci%C3%B3ns_BD_Mysql#Gardando_os_datos_de_conexi.C3.B3n_nun_lugar_seguro 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 [https://wiki.cifprodolfoucha.es/index.php?title=PHP_Habilitar_depuraci%C3%B3n_en_VS_Code nesta sección da wiki].
 
  
 +
Cando iniciamos explicitamente unha transacción con PDO:
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->beginTransaction();
 +
</syntaxhighlight>
 +
 +
PDO desactiva temporalmente o modo de confirmación automática para esa conexión.
 +
 +
Os cambios non se confirman ata executar:
 +
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->commit();
 +
</syntaxhighlight>
 +
 +
Se executamos:
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->rollBack();
 +
</syntaxhighlight>
  
 +
desfaranse os cambios realizados desde o inicio da transacción.
  
 +
Ao finalizar mediante <code>commit()</code> ou <code>rollBack()</code>, a conexión volve ao funcionamento normal con autocommit.
  
 
<br />
 
<br />
  
−
==Funcionamento==
+
== Requirimentos e consideracións ==
  
−
* En PHP, para poder utilizar transaccións necesitamos:
+
Para empregar transaccións debemos traballar cun motor de almacenamento que as soporte.
−
:* MySQL 5.6 ou superior
 
−
:* Un [https://wiki.cifprodolfoucha.es/index.php?title=Mysql_Motores_de_bases_de_datos motor de base de datos] que soporte transaccións, coma [https://es.wikipedia.org/wiki/InnoDB motor InnoDB].
 
−
 
  
 +
En MySQL empregaremos normalmente:
  
−
* Unha transacción a podemos facer dende a páxina PHP ou dende o xestor Mysql dentro dun procedemento almacenado.
+
::<syntaxhighlight lang="text" enclose="div">
 +
InnoDB
 +
</syntaxhighlight>
 +
 
 +
As táboas implicadas na operación deben empregar un motor transaccional.
 +
 
 +
<br />
  
−
* Para realizar unha transacción o proceso sempre é o mesmo en todas as linguaxes e xestores.
+
Nos exemplos desta unidade empregaremos:
−
:# Iniciar a transacción. Normalmente farase coa orde 'START TRANSACTION' (outros xestores teñen coma orde BEGIN TRANSACTION)
 
−
:# Realizar as operacións que conforman as transaccións.
 
−
:# En caso de erro en algunha das operacións anteriores executar a orde 'ROLLBACK' e saír
 
−
:# En caso de que todo se execute correctamente, entón confirmamos a transacción e os cambios se gardan definitivamente na base de datos, coa orde: 'COMMIT'
 
  
 +
:* 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.
 +
:* <code>error_log()</code> para rexistrar a información técnica.
  
 +
O usuario da base de datos deberá ter unicamente os permisos necesarios para as operacións da aplicación.
  
 
<br />
 
<br />
  
−
* Para iniciar unha transacción temos dúas opcións:
+
=== Arquivo de configuración ===
  
−
:* VERSION ANTIGA: Inhabilitar as transaccións implícitas, coa [http://php.net/manual/es/mysqli.autocommit.php función autocommit]:
+
Como nas seccións anteriores, os datos de conexión e as consultas estarán nun arquivo situado fóra de <code>/var/www/html</code>:
−
::* Versión procedimental:
 
−
::::<syntaxhighlight lang="java" enclose="div" highlight="2" >
 
−
$conex = mysqli_connect(HOST, USER, PASSWORD, BD);
 
−
mysqli_autocommit($conex,false);
 
−
</syntaxhighlight>  
 
  
−
::* Versión obxectual:
+
::<syntaxhighlight lang="text" enclose="div">
−
::::<syntaxhighlight lang="java" enclose="div" highlight="2" >
+
/var/www/confBD/bd.php
−
$conex= new mysqli(HOST, USER, PASSWORD, BD);
+
</syntaxhighlight>
−
$conex->autocommit(false);
 
−
</syntaxhighlight>  
 
  
 +
Por exemplo:
  
−
:: Desta forma as operacións que vaian a continuación desta orde non se farán efectivas ata atopar un commit.
+
::<syntaxhighlight lang="php" enclose="div">
 +
<?php
  
 +
define('SERVIDOR', 'localhost');
 +
define('USUARIO', 'user_php');
 +
define('PASSWORD', 'user_php');
 +
define('BD', 'php');
 +
</syntaxhighlight>
  
 
<br />
 
<br />
−
:* VERSION ACTUAL: Iniciar unha transacción coa [http://php.net/manual/es/mysqli.begin-transaction.php función mysqli::begin_transaction].
 
−
:: '''NECESITAMOS QUE A VERSIÓN PHP SEXA 5.5 OU SUPERIOR'''.
 
  
−
::* Versión procedimental:
+
=== Conexión PDO empregada nos exemplos ===
−
::::<syntaxhighlight lang="java" enclose="div" highlight="2" >
+
 
−
$conex = mysqli_connect(HOST, USER, PASSWORD, BD);
+
::<syntaxhighlight lang="php" enclose="div">
−
mysqli_begin_transaction($conex,MYSQLI_TRANS_START_READ_ONLY);
+
$dsn = sprintf(
−
</syntaxhighlight>  
+
    '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
 +
    ]
 +
);
 +
</syntaxhighlight>
  
−
::* Versión obxectual:
+
Ao empregar:
−
::::<syntaxhighlight lang="java" enclose="div" highlight="2" >
 
−
$conex= new mysqli(HOST, USER, PASSWORD, BD);
 
−
$conex->begin_transaction(MYSQLI_TRANS_START_READ_ONLY);
 
−
</syntaxhighlight>
 
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
 +
</syntaxhighlight>
  
−
::* Versión PDO:
+
un erro SQL provoca unha <code>PDOException</code>, o que facilita realizar o <code>ROLLBACK</code> desde o bloque <code>catch</code>.
−
::::<syntaxhighlight lang="java" enclose="div" highlight="4 >
 
−
$conex = new PDO("mysql:host=" . HOST . ";dbname=" . BD . ";charset=utf8mb4", USER, PASSWORD);
 
  
−
// Desactivar o modo autocommit
+
<br />
−
$conex->beginTransaction();
 
−
</syntaxhighlight>  
 
  
 +
== Funcionamento dunha transacción con PDO ==
  
 +
O patrón básico será:
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
try {
  
−
:: Esta segunda forma leva como parámetro unha bandeira que vai indicar que dentro da transacción non vai poderse facer operacións que modifiquen os contidos das táboas da base de datos.
+
    require '/var/www/confBD/bd.php';
−
:: Se a transacción necesita facer operacións contra a base de datos, levaría o flag MYSQLI_TRANS_START_READ_WRITE.
 
−
:: Máis información [https://dev.mysql.com/doc/refman/5.6/en/commit.html neste enlace].
 
  
 +
    $dsn = sprintf(
 +
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
 +
        SERVIDOR,
 +
        BD
 +
    );
  
−
* Unha vez iniciada a transacción faremos as operacións necesarias sobre a base de datos. En caso de erro nalgunha delas, teremos que facer un rollback coa [http://php.net/manual/es/mysqli.rollback.php función rollback].
+
    $conex = new PDO(
 +
        $dsn,
 +
        USUARIO,
 +
        PASSWORD,
 +
        [
 +
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
 +
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
 +
        ]
 +
    );
  
−
::* Versión procedimental:
+
    $conex->beginTransaction();
−
::::<syntaxhighlight lang="java" enclose="div" highlight="7" >
 
−
$conex = mysqli_connect(HOST, USER, PASSWORD, BD);
 
−
mysqli_begin_transaction($conex,MYSQLI_TRANS_START_READ_ONLY);
 
  
−
.........
+
    // Operación 1
−
OPERACIÓN
+
    // Operación 2
−
Si erro
+
    // Operación 3
−
  mysqli_rollback($conex);
 
−
  die('Houbo un erro...');
 
  
−
</syntaxhighlight>  
+
    $conex->commit();
  
−
::* Versión obxectual:
+
} catch (PDOException $e) {
−
::::<syntaxhighlight lang="java" enclose="div" highlight="7" >
 
−
$conex= new mysqli(HOST, USER, PASSWORD, BD);
 
−
$conex->begin_transaction(MYSQLI_TRANS_START_READ_ONLY);
 
  
−
.........
+
    if (
−
OPERACIÓN
+
        isset($conex) &&
−
Si erro
+
        $conex->inTransaction()
−
  $conex->rollback();
+
    ) {
−
  die('Houbo un erro...');
+
        $conex->rollBack();
 +
    }
  
−
</syntaxhighlight>  
+
    error_log($e->getMessage());
  
 +
    die('Erro relacionado coa base de datos.');
  
−
::* PDO:
+
} catch (Throwable $e) {
−
::::<syntaxhighlight lang="java" enclose="div" highlight="7-9" >
 
−
$conex = new PDO("mysql:host=" . HOST . ";dbname=" . BD . ";charset=utf8mb4", USER, PASSWORD);
 
−
$conex->beginTransaction();
 
  
−
.........
+
     if (
−
OPERACIÓN
+
        isset($conex) &&
−
Si erro
+
        $conex->inTransaction()
−
     if ($conex->inTransaction()) {
+
    ) {
 
         $conex->rollBack();
 
         $conex->rollBack();
 
     }
 
     }
−
    die('Houbo un erro...');
 
  
−
</syntaxhighlight>  
+
    error_log($e->getMessage());
  
 +
    die('Produciuse un erro na aplicación.');
 +
}
 +
</syntaxhighlight>
 +
 +
<br />
  
 +
=== beginTransaction() ===
  
 +
Para iniciar a transacción empregamos:
  
−
* '''Exemplo obxectual''' empregando unha versión de PHP 8.1 onde as ordes contra a base de datos poden provocar unha excepción:
+
::<syntaxhighlight lang="php" enclose="div">
−
:::<syntaxhighlight lang="java" enclose="div" highlight="5,6,11,,19,23" >
+
$conex->beginTransaction();
−
try {
+
</syntaxhighlight>
−
    $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,DB);   // Constantes definidas nun arquivo fora do sitio web
+
 
−
    $conex->set_charset('utf8mb4');
+
A partir dese momento, as modificacións realizadas pola conexión non quedan confirmadas definitivamente ata executar <code>commit()</code>.
−
   
+
 
−
    $conex->begin_transaction(MYSQLI_TRANS_START_READ_WRITE);
+
<br />
−
    $result=$conex->query("INSERT INTO TABLE_1 (col2) VALUES ('valor')");
+
 
−
    if ($result){
+
=== commit() ===
−
      // Operación correcta
+
 
−
      printf ("Novo rexistro có id %d.\n", $conex->insert_id);
+
Se todas as operacións finalizaron correctamente:
−
    }
+
 
−
    $result=$conex->query("INSERT INTO TABLE_2 (col2) VALUES ('valor')");
+
::<syntaxhighlight lang="php" enclose="div">
−
    if ($result){
+
$conex->commit();
−
      // Operación correcta
+
</syntaxhighlight>
−
      printf ("Novo rexistro có id %d.\n", $conex->insert_id);
+
 
−
    }
+
confirma os cambios.
 +
 
 +
O <code>commit()</code> debe executarse unicamente cando todas as operacións que forman a unidade lóxica finalizaron correctamente.
 +
 
 +
<br />
 +
 
 +
=== rollBack() ===
 +
 
 +
Se se produce unha excepción:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->rollBack();
 +
</syntaxhighlight>
 +
 
 +
desfai os cambios realizados dentro da transacción.
 +
 
 +
Antes de chamar a <code>rollBack()</code> resulta conveniente comprobar:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->inTransaction()
 +
</syntaxhighlight>
 +
 
 +
Por exemplo:
  
−
}  // Pecha o try da parte superior da páxina
+
::<syntaxhighlight lang="php" enclose="div">
−
catch (mysqli_sql_exception $e) {
+
if (
−
    if(!empty($conex)) $conex->rollback();
+
     isset($conex) &&
−
    die("Erro mysql: " . $e->getMessage());
+
     $conex->inTransaction()
−
}
+
) {
−
catch (Expcetion $e) {
+
    $conex->rollBack();
−
     if(!empty($conex)) $conex->rollback();
 
−
     die("Erro xeral: " . $e->getMessage());
 
−
}
 
−
finally{
 
−
  if(!empty($conex)){
 
−
      $conex->close();
 
−
  }
 
 
}
 
}
 +
</syntaxhighlight>
 +
 +
Isto evita intentar desfacer unha transacción que xa non está activa.
 +
 +
<br />
 +
 +
=== Exemplo completo ===
 +
 +
No arquivo <code>bd.php</code> poderiamos ter:
 +
 +
::<syntaxhighlight lang="php" enclose="div">
 +
define(
 +
    'INSERIR_PEDIDO',
 +
    'INSERT INTO pedidos (cliente_id)
 +
    VALUES (:cliente_id)'
 +
);
  
−
</syntaxhighlight>  
+
define(
 +
    'INSERIR_LINA_PEDIDO',
 +
    'INSERT INTO lina_pedido (
 +
        pedido_id,
 +
        produto_id,
 +
        cantidade
 +
    )
 +
    VALUES (
 +
        :pedido_id,
 +
        :produto_id,
 +
        :cantidade
 +
    )'
 +
);
 +
</syntaxhighlight>
  
 +
E a páxina PHP:
  
−
* '''Exemplo PDO''' empregando unha versión de PHP 8.1 onde as ordes contra a base de datos poden provocar unha excepción:
+
::<syntaxhighlight lang="php" enclose="div">
−
:::<syntaxhighlight lang="java" enclose="div" highlight="4,9,26-28,32-34,38" >
 
 
<?php
 
<?php
 +
 
try {
 
try {
−
    // Crear conexión PDO
 
−
    $conex = new PDO("mysql:host=" . "SERVIDOR" . ";dbname=" . "DB" . ";charset=utf8mb4","USUARIO","PASSWORD");
 
−
    $conex->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
 
  
 +
    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
 +
        ]
 +
    ];
  
−
    // Comezamos a transacción
 
 
     $conex->beginTransaction();
 
     $conex->beginTransaction();
  
−
     // Primeira inserción
+
     /*
−
     $stmt = $conex->query("INSERT INTO TABLE_1 (col2) VALUES ('valor')");
+
    * Creamos o pedido.
−
     if ($stmt) {
+
    */
−
         printf("Novo rexistro có id %d.<br>", $conex->lastInsertId());
+
     $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']
 +
        ]);
 
     }
 
     }
  
−
     // Segunda inserción
+
     /*
−
     $stmt = $conex->query("INSERT INTO TABLE_2 (col2) VALUES ('valor')");
+
    * Todas as operacións remataron ben.
−
    if ($stmt) {
+
    */
−
        printf("Novo rexistro có id %d.<br>", $conex->lastInsertId());
+
     $conex->commit();
−
    }
+
 
 +
} catch (PDOException $e) {
  
−
}
+
     if (
−
catch (PDOException $e) {
+
        isset($conex) &&
−
    // Reverter se hai unha transacción activa
+
        $conex->inTransaction()
−
     if (!empty($conex) && $conex->inTransaction()) {
+
    ) {
 
         $conex->rollBack();
 
         $conex->rollBack();
 
     }
 
     }
−
     die("Erro PDO: " . htmlentities($e->getMessage()));
+
 
−
}
+
     error_log($e->getMessage());
−
catch (Exception $e) {
+
 
−
     if (!empty($conex) && $conex->inTransaction()) {
+
    die('Erro relacionado coa base de datos.');
 +
 
 +
} catch (Throwable $e) {
 +
 
 +
     if (
 +
        isset($conex) &&
 +
        $conex->inTransaction()
 +
    ) {
 
         $conex->rollBack();
 
         $conex->rollBack();
 
     }
 
     }
−
     die("Erro xeral: " . htmlentities($e->getMessage()));
+
 
 +
     error_log($e->getMessage());
 +
 
 +
    die('Produciuse un erro na aplicación.');
 
}
 
}
−
finally {
+
</syntaxhighlight>
−
    $conex = null; // Pechar conexión (liberar recursos)
+
 
−
}
+
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;
 +
:* <code>lastInsertId()</code> se consulta antes de finalizar a transacción;
 +
:* se calquera <code>execute()</code> lanza unha excepción, non se chega ao <code>commit()</code>;
 +
:* o bloque <code>catch</code> realiza o <code>rollBack()</code>;
 +
:* non se mostra ao usuario o texto interno da excepción.
 +
 
 +
<br />
 +
 
 +
== 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.
  
−
</syntaxhighlight>
+
Por exemplo, non deberiamos facer:
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->beginTransaction();
  
 +
INSERT ...
  
−
* Unha vez rematadas as operacións facemos o '''commit''' coa [http://php.net/manual/es/mysqli.commit.php función commit()]:
+
// Procesamento moi longo.
−
::* Versión procedimental:
+
// Chamadas externas.
−
::::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
// Interacción co usuario.
−
mysqli_commit($conex);
+
// Esperas.
−
</syntaxhighlight>
+
 
 +
UPDATE ...
  
−
::* Versión obxectual:
 
−
::::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
 
$conex->commit();
 
$conex->commit();
−
</syntaxhighlight>  
+
</syntaxhighlight>
 +
 
 +
porque durante unha transacción poden manterse bloqueos sobre filas e índices.
 +
 
 +
O recomendable é:
 +
 
 +
:# validar previamente todos os datos que poidamos validar en PHP;
 +
:# preparar os datos necesarios;
 +
:# iniciar a transacción;
 +
:# realizar unicamente as operacións da base de datos necesarias;
 +
:# confirmar ou desfacer;
 +
:# finalizar a transacción canto antes.
 +
 
 +
<br />
  
−
::* Versión PDO:
+
== Sentenzas DDL e COMMIT implícito ==
−
::::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
 
−
$conex->commit();
+
Debemos evitar executar sentenzas de definición de datos dentro dunha transacción MySQL.
−
</syntaxhighlight>  
+
 
 +
Por exemplo:
 +
 
 +
::<syntaxhighlight lang="sql" enclose="div">
 +
CREATE TABLE ...
 +
ALTER TABLE ...
 +
DROP TABLE ...
 +
</syntaxhighlight>
 +
 
 +
MySQL pode realizar un <code>COMMIT</code> implícito con este tipo de sentenzas.
  
 +
Isto significa que non debemos esperar que:
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->rollBack();
 +
</syntaxhighlight>
  
−
* Se establecimos autocommit(false) deberemos volvelo a establecer en True.
+
poida desfacer sempre cambios anteriores se durante a transacción executamos sentenzas DDL.
−
:::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
−
$conex->autocommit(true);
 
−
</syntaxhighlight>
 
  
 +
As transaccións dos exemplos desta unidade estarán formadas fundamentalmente por operacións:
  
−
* Ao rematar todas as operacións sobre a base de datos pecharemos a conexión na versión procedimental ou poñemos a null na versión PDO.
+
::<syntaxhighlight lang="text" enclose="div">
−
:::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
SELECT
−
$conex->close();
+
INSERT
 +
UPDATE
 +
DELETE
 
</syntaxhighlight>
 
</syntaxhighlight>
  
 +
<br />
 +
 +
== 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:
 +
 +
:* <code>READ UNCOMMITTED</code>
 +
:* <code>READ COMMITTED</code>
 +
:* <code>REPEATABLE READ</code>
 +
:* <code>SERIALIZABLE</code>
 +
 +
En MySQL/InnoDB o nivel por defecto é normalmente:
  
 +
::<syntaxhighlight lang="text" enclose="div">
 +
REPEATABLE READ
 +
</syntaxhighlight>
  
 
<br />
 
<br />
−
O exemplo anterior de forma completa (empregando unha versión de PHP 8.1 onde as ordes contra a base de datos poden provocar unha excepción:):
 
−
'''VERSION OBXECTUAL'''
 
−
:::<syntaxhighlight lang="java" enclose="div" highlight="16,21,26,32" >
 
−
try {
 
−
    $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,DB);  // Constantes definidas nun arquivo fora do sitio web
 
−
    $conex->set_charset('utf8mb4');
 
−
   
 
−
    $conex->begin_transaction(MYSQLI_TRANS_START_READ_WRITE);
 
−
    $result=$conex->query("INSERT INTO TABLE_1 (col2) VALUES ('valor')");
 
−
    if ($result){
 
−
      // Operación correcta
 
−
      printf ("Novo rexistro có id %d.\n", $conex->insert_id);
 
−
    }
 
−
    $result=$conex->query("INSERT INTO TABLE_2 (col2) VALUES ('valor')");
 
−
    if ($result){
 
−
      // Operación correcta
 
−
      printf ("Novo rexistro có id %d.\n", $conex->insert_id);
 
−
    }
 
−
    $conex->commit();
 
  
−
}  // Pecha o try da parte superior da páxina
+
=== Problemas de concorrencia ===
−
catch (mysqli_sql_exception $e) {
+
 
−
    if(!empty($conex)) $conex->rollback();
+
Os niveis de illamento están relacionados con problemas como:
−
    printf("Erro mysql: " . $e->getMessage()); // Se non poñemos die executa o finally. Se non habería que poñer o autocommit aquí tamén
+
 
 +
:* 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.
  
−
}
+
<br />
−
catch (Expcetion $e) {
+
 
−
    if(!empty($conex)) $conex->rollback();
+
=== Modificar o nivel de illamento ===
−
    printf("Erro xeral: " . $e->getMessage());  // Se non poñemos die executa o finally. Se non habería que poñer o autocommit aquí tamén
 
−
}
 
−
finally{
 
−
  if(!empty($conex)){
 
−
      $conex->close();
 
−
  }
 
−
  $conex->autocommit(true);
 
  
−
}
+
Normalmente non necesitaremos cambialo nos primeiros exemplos.
  
−
</syntaxhighlight>
+
Se fose necesario, MySQL permite configurar o nivel dunha transacción mediante SQL.
  
 +
Por exemplo:
  
−
'''VERSION PDO:'''
+
::<syntaxhighlight lang="sql" enclose="div">
−
:::<syntaxhighlight lang="java" enclose="div" highlight="8,23,28,34" >
+
SET TRANSACTION ISOLATION LEVEL
−
<?php
+
READ COMMITTED;
−
    // Crear conexión PDO
+
</syntaxhighlight>
−
    $conex = new PDO("mysql:host=" . "SERVIDOR" . ";dbname=" . "DB" . ";charset=utf8mb4","USUARIO","PASSWORD");
 
−
    $conex->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
 
  
 +
Debe facerse comprendendo previamente as consecuencias sobre a concorrencia e a consistencia.
  
−
    // Comezamos a transacción
+
<br />
−
    $conex->beginTransaction();
 
  
−
    // Primeira inserción
+
== Interbloqueos ou deadlocks ==
−
    $stmt = $conex->query("INSERT INTO TABLE_1 (col2) VALUES ('valor')");
 
−
    if ($stmt) {
 
−
        printf("Novo rexistro có id %d.<br>", $conex->lastInsertId());
 
−
    }
 
  
−
    // Segunda inserción
+
Un '''deadlock''' pode producirse cando dúas transaccións esperan mutuamente por recursos bloqueados pola outra.
−
    $stmt = $conex->query("INSERT INTO TABLE_2 (col2) VALUES ('valor')");
 
−
    if ($stmt) {
 
−
        printf("Novo rexistro có id %d.<br>", $conex->lastInsertId());
 
−
    }
 
  
−
    // Se todo vai ben, confirmamos a transacción
+
Un exemplo simplificado:
−
    $conex->commit();
 
−
}
 
−
catch (PDOException $e) {
 
−
    // Reverter se hai unha transacción activa
 
−
    if (!empty($conex) && $conex->inTransaction()) {
 
−
        $conex->rollBack();
 
−
    }
 
−
    die("Erro PDO: " . htmlentities($e->getMessage()));
 
−
}
 
−
catch (Exception $e) {
 
−
    if (!empty($conex) && $conex->inTransaction()) {
 
−
        $conex->rollBack();
 
−
    }
 
−
    die("Erro xeral: " . htmlentities($e->getMessage()));
 
−
}
 
−
finally {
 
−
    $conex = null; // Pechar conexión (liberar recursos)
 
−
}
 
−
?>
 
  
−
</syntaxhighlight>  
+
::<syntaxhighlight lang="text" enclose="div">
 +
TRANSACCIÓN A
 +
    bloquea fila 1
 +
    espera pola fila 2
  
 +
TRANSACCIÓN B
 +
    bloquea fila 2
 +
    espera pola fila 1
 +
</syntaxhighlight>
  
 +
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.
  
 
<br />
 
<br />
−
==Transaccións en MYSQL dentro de procedementos almacenados==
 
  
 +
== 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:
  
−
* Outra forma de realizar as transaccións é que poidamos enviar os datos a un procedemento almacenado de Mysql e que dende o procedemento se faga a transacción.
+
:* '''alternativa A''': a transacción é controlada por PHP/PDO;
 +
:* '''alternativa B''': a transacción é controlada polo procedemento almacenado.
  
−
* O proceso é moi parecido ao feito dende PHP.
+
Non debemos iniciar unha transacción en PHP e, dentro dela, chamar a un procedemento que executa outro <code>START TRANSACTION</code>.
−
: Lembrar que se o facemos dende o propio procedemento <u>NON TEMOS QUE FACELO</u> dende PHP.
 
  
−
::<syntaxhighlight lang="java" enclose="div" highlight="5-9,11,20" >
+
MySQL non admite transaccións anidadas reais. Un novo <code>START TRANSACTION</code> pode finalizar implicitamente a transacción anterior.
−
DELIMITER $$
 
  
−
CREATE PROCEDURE `PROCEDEMENTO_CON_TRANSACCION`(_param1 int,_param2 varchar(50),_param3 varchar(200))
+
<br />
−
BEGIN
 
−
DECLARE EXIT HANDLER FOR SQLEXCEPTION
 
−
BEGIN
 
−
ROLLBACK;
 
−
SELECT -1 as error;
 
−
END;
 
  
−
START TRANSACTION;
+
=== Alternativa A: transacción controlada por PDO ===
  
−
INSERT INTO TABLE_1(col1,col2)
+
O procedemento almacenado non executa:
−
VALUES (_param2,_param3);  -- Supoñemos que esta táboa devolve un valor clave autonumérico.
 
  
−
INSERT INTO TABLE_2(col1,col2)
+
::<syntaxhighlight lang="sql" enclose="div">
−
VALUES (_param1,last_insert_id());
+
START TRANSACTION
 +
COMMIT
 +
ROLLBACK
 +
</syntaxhighlight>
  
−
+
PHP controla todo:
−
COMMIT;
 
−
SELECT 0 AS error;
 
  
−
END
+
::<syntaxhighlight lang="php" enclose="div">
−
$$
+
$conex->beginTransaction();
−
</syntaxhighlight>  
 
  
−
* Liñas 5-9: Capturamos calquera erro que se produza e nese caso enviamos a PHP un código -1 no select facendo un rollback da transacción.
+
try {
−
* Liña 11: Iniciamos a transacción.
 
−
* Liñas 20 e 21: Confirmamos a transacción se todo foi ben e enviamos un 0 como código ao PHP.
 
  
 +
    // CALL procedemento 1
 +
    // CALL procedemento 2
 +
    // OUTRAS OPERACIÓNS
  
−
* Comentar que se facemos as transaccións dende procedementos almacenados teremos que capturar as posibles excepcións que se produzan para facer o rollback.
+
    $conex->commit();
−
: Máis información: [http://www.mysqltutorial.org/mysql-error-handling-in-stored-procedures/ Captura de erros en Mysql].
 
  
−
* A partires de Mysql 5.6 temos outra forma de capturar as excepcións: [https://dev.mysql.com/doc/refman/5.6/en/get-diagnostics.html Mensaxes e códigos de erro en Mysql '''a partires da versión 5.6''']
+
} catch (Throwable $e) {
  
 +
    if ($conex->inTransaction()) {
 +
        $conex->rollBack();
 +
    }
  
 +
    throw $e;
 +
}
 +
</syntaxhighlight>
  
 +
Esta opción é útil cando a unidade de traballo abrangue varias chamadas ou operacións diferentes realizadas desde PHP.
  
 
<br />
 
<br />
−
* Por exemplo, no caso da base de datos que estamos a desenvolver nesta sección, poderíamos crear un procedemento almacenado que dea de alta a un único autor xunto có libro.
 
−
: Isto leva consigo que o procedemento vai ter que dar de alta datos nas táboas: libros, autores e libro_autor.
 
  
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
=== Alternativa B: transacción controlada polo procedemento ===
 +
 
 +
O procedemento pode encapsular completamente a operación.
 +
 
 +
Por exemplo:
 +
 
 +
::<syntaxhighlight lang="sql" enclose="div">
 
DELIMITER $$
 
DELIMITER $$
  
−
CREATE DEFINER=`root`@`localhost` PROCEDURE `INSERIR_AUTORES_LIBRO`(p_titulo varchar(300),p_nome varchar(50),p_apelidos varchar(200))
+
CREATE PROCEDURE PROCEDEMENTO_CON_TRANSACCION(
 +
    IN p_param1 INT,
 +
    IN p_param2 VARCHAR(50),
 +
    IN p_param3 VARCHAR(200)
 +
)
 
BEGIN
 
BEGIN
−
DECLARE v_id_libro int;
 
  
−
DECLARE EXIT HANDLER FOR SQLEXCEPTION
+
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
−
BEGIN
+
    BEGIN
−
ROLLBACK;
+
        ROLLBACK;
−
SELECT -1 as error;  
+
        RESIGNAL;
−
END;
+
    END;
 +
 
 +
    START TRANSACTION;
  
−
START TRANSACTION;
+
    INSERT INTO TABLE_1 (
−
INSERT INTO libros(titulo)
+
        col1,
−
     VALUES (p_titulo);
+
        col2
−
   
+
    )
−
     SET v_id_libro = last_insert_id();
+
     VALUES (
−
     
+
        p_param2,
 +
        p_param3
 +
     );
  
−
INSERT INTO autores(nome,apelidos)
+
    INSERT INTO TABLE_2 (
−
VALUES (p_nome,p_apelido);
+
        col1,
 +
        col2
 +
    )
 +
    VALUES (
 +
        p_param1,
 +
        LAST_INSERT_ID()
 +
    );
  
−
INSERT INTO LIBROS_AUTORES (libro_id,autor_id)
+
    COMMIT;
−
VALUES (v_id_libro,last_insert_id());
 
−
 
−
COMMIT;
 
−
SELECT 0 AS error;
 
  
 
END$$
 
END$$
 +
 
DELIMITER ;
 
DELIMITER ;
 +
</syntaxhighlight>
 +
 +
Neste caso:
 +
 +
:* <code>START TRANSACTION</code> inicia a transacción dentro do procedemento;
 +
:* o <code>EXIT HANDLER</code> captura calquera excepción SQL;
 +
:* <code>ROLLBACK</code> desfai os cambios;
 +
:* <code>RESIGNAL</code> volve propagar o erro cara a PHP;
 +
:* <code>COMMIT</code> só se executa se todas as operacións anteriores rematan correctamente.
 +
 +
Desde PHP '''non iniciaremos outra transacción''':
 +
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$stmt = $conex->prepare(
 +
    'CALL PROCEDEMENTO_CON_TRANSACCION(
 +
        :param1,
 +
        :param2,
 +
        :param3
 +
    )'
 +
);
 +
 +
$stmt->execute([
 +
    ':param1' => $param1,
 +
    ':param2' => $param2,
 +
    ':param3' => $param3
 +
]);
  
 +
$stmt->closeCursor();
 
</syntaxhighlight>
 
</syntaxhighlight>
  
 +
Se o procedemento lanza novamente o erro mediante <code>RESIGNAL</code>, PDO recibirá unha <code>PDOException</code>.
 +
 +
<br />
 +
 +
== Exercicio proposto 1: asociar libros e autores ==
  
 +
Queremos xestionar unha relación N:M entre libros e autores.
  
 +
A idea é:
  
−
<br />
+
:# seleccionar un libro nun <code>&lt;select&gt;</code>;
−
==Exercicio proposto 1==
+
:# amosar todos os autores;
 +
:# marcar mediante <code>checkbox</code> os autores xa asociados ao libro;
 +
:# permitir marcar ou desmarcar autores;
 +
:# ao premer '''ASOCIAR''', substituír as asociacións actuais polas seleccionadas.
  
−
* Asociar libros a autores.
+
A operación final implica:
  
−
:* A idea é escoller dunha lista (<select>) un libro.
+
:# borrar as asociacións existentes do libro;
−
:: Unha vez seleccionado un libro e premido o botón BUSCAR aparecerán nunha lista de checkboxes todos os autores. O libro debe manterse seleccionado ao amosar todos os autores.
+
:# inserir unha fila por cada autor seleccionado.
−
:* O usuario poderá checkear varios autores e serán asociados a dito libro.
 
−
:: Os autores xa asociados ao libro terán que estar chequeados.
 
−
:* Ao premer o botón de ALTA, se asociarán ao libro seleccionado.
 
−
:: Isto levará consigo as operacións de baixa de todos os autores do libro e a de alta sobre a táboa LIBROS_AUTORES por cada un dos autores seleccionados.
 
  
 +
Estas operacións deben formar unha transacción.
  
−
:<u>Pista:</u> Lembrar que en html, se damos de nome a un input có seguinte formato: dato[] e repetimos este nome en varios input, cando enviemos o dato, en PHP o poderemos recoller en formato de array.
+
<br />
  
−
* Para separar a lóxico, crea as seguintes páxinas:
+
=== Arquivos ===
−
:* alta_libro_autores.php => Amosa o libro e os autores
 
−
:* procesar_alta_libro_autores.php => Procesa a alta e volve a amosar alta_libroautores.php
 
  
 +
Empregaremos:
  
 +
::<syntaxhighlight lang="text" enclose="div">
 +
alta_libro_autores.php
 +
procesar_alta_libro_autores.php
 +
</syntaxhighlight>
  
 
<gallery caption="Libros-Autores" widths="350" heights="300px" perrow="2">
 
<gallery caption="Libros-Autores" widths="350" heights="300px" perrow="2">
−
Image:UD3_op_bd_trans_ex_1_A.jpg| Escollemos do listado un libro.
+
Image:UD3_op_bd_trans_ex_1_A.jpg| Escollemos un libro.
−
Image:UD3_op_bd_trans_ex_1_B.jpg| Aparecen os autores asociados e podemos escoller novos/deseleccionar e premer no botón ASOCIAR.
+
Image:UD3_op_bd_trans_ex_1_B.jpg| Amosamos os autores asociados e modificamos a selección.
 
</gallery>
 
</gallery>
  
 +
<br />
 +
 +
=== Arquivo bd.php ===
 +
 +
::<syntaxhighlight lang="php" enclose="div">
 +
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'
 +
);
 +
</syntaxhighlight>
  
 
<br />
 
<br />
−
===Solución Orientada a obxectos===
 
−
* '''POSIBLE SOLUCIÓN''' (algunha das constantes xa foron creados en solucións anteriores)
 
  
 +
=== Arrays enviados por formularios ===
  
−
'''Arquivo: bd.php'''
+
Cada autor pode representarse cun checkbox:
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
−
define ('SERVIDOR','localhost');
 
−
define ('USUARIO','user_php');
 
−
define ('PASSWORD','user_php');
 
−
define ('BD','php');
 
  
 +
::<syntaxhighlight lang="html" enclose="div">
 +
<input
 +
    type="checkbox"
 +
    name="autores[]"
 +
    value="10"
 +
>
 +
</syntaxhighlight>
  
−
define('CONSULTAR_LIBROS','select id,titulo from libros order by titulo');
+
Ao repetir:
−
define('CONSULTAR_AUTORES','SELECT id,apelidos,nome FROM autores order by apelidos,nome');
 
−
define('CONSULTAR_AUTORES_POR_LIBRO','SELECT id,apelidos,nome FROM autores INNER JOIN libro_autor ON libro_autor.autor_id=autores.id WHERE libro_id=%d order by apelidos,nome');
 
−
define('INSERIR_LIBRO_AUTOR', 'INSERT INTO libro_autor (libro_id, autor_id) VALUES (%d,%d)');
 
−
define('BORRAR_LIBRO_AUTOR_POR_LIBRO','DELETE FROM libro_autor WHERE libro_id=%d');
 
  
−
</syntaxhighlight>  
+
::<syntaxhighlight lang="text" enclose="div">
 +
name="autores[]"
 +
</syntaxhighlight>
  
 +
PHP recibirá os autores seleccionados como un array:
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$_POST['autores']
 +
</syntaxhighlight>
 +
 +
Se o usuario non selecciona ningún autor, o campo pode non chegar na petición.
 +
 +
Por iso empregaremos:
  
−
'''Arquivo: alta_libro_autores.php'''
+
::<syntaxhighlight lang="php" enclose="div">
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
$autoresRecibidos =
 +
    $_POST['autores'] ?? [];
 +
</syntaxhighlight>
 +
 
 +
Isto tamén permite eliminar todas as asociacións dun libro deixando todos os checkbox sen marcar.
 +
 
 +
<br />
 +
 
 +
=== Arquivo alta_libro_autores.php ===
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 
<?php
 
<?php
−
/* Lembrar quitar isto ao probar o scripts sen erros */
 
−
ini_set('display_errors', 1);
 
−
ini_set('display_startup_errors', 1);
 
−
error_reporting(E_ALL);
 
  
−
require ('/var/www/confBD/bd.php');
+
try {
 +
 
 +
    require '/var/www/confBD/bd.php';
  
−
try {
+
    $dsn = sprintf(
−
     $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,BD);  
+
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
−
     $conex->set_charset('utf8mb4');
+
        SERVIDOR,
−
    $resultado = $conex->query(CONSULTAR_LIBROS);
+
        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
 +
        ]);
  
−
    $listaLibros = [0 => ['id' => -1, 'titulo' => 'Escolle un libro']];
+
        $autoresLibro = array_map(
−
    while($fila = $resultado->fetch_assoc()) {
+
            'intval',
−
         $listaLibros[] = $fila;
+
            array_column(
 +
                $stmt->fetchAll(),
 +
                'id'
 +
            )
 +
         );
 
     }
 
     }
−
    $resultado->free();
 
  
 +
} 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 PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+
<!DOCTYPE html>
−
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
<html lang="gl">
−
<html xmlns="http://www.w3.org/1999/xhtml" lang="es" xml:lang="es">
+
<head>
−
    <head>
+
    <meta charset="UTF-8">
−
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+
    <title>Xestión de libros e autores</title>
−
        <title>Xestión de libros-autores</title>
+
</head>
−
     </head>
+
<body>
−
     <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>
  
−
        <form action="<?php echo htmlentities($_SERVER['PHP_SELF']) ?>" method="post">
+
            <?php foreach ($libros as $libro): ?>
−
            <select name='lstLibros'>
 
−
                <?php
 
−
                    $libroSeleccionado=-1;
 
−
                    if(!empty($_POST['btnLibro'])) $libroSeleccionado = $_POST['lstLibros'];    // Vimos de seleccionar un libro
 
  
−
                     foreach ($listaLibros as $libro) {
+
                <?php
−
                        $cadena = '';  // Para facer que o libro se manteña seleccionado ao premer no botón
+
                     $idLibro =
−
                         if($libroSeleccionado == $libro['id']) $cadena = "selected='selected'";
+
                         (int) $libro['id'];
−
                       
 
−
                        printf("<option value=%d %s>%s</option>",htmlentities($libro['id']),$cadena,htmlentities($libro['titulo']));
 
−
                    }
 
 
                 ?>
 
                 ?>
−
            </select>
 
−
            <input type='submit' value='BUSCA AUTORES' name='btnLibro' />
 
−
        </form>
 
  
−
        <?php
+
                <option
−
            if(!empty($_POST['btnLibro']))  {          // Vimos de seleccionar un libro
+
                    value="<?= $idLibro ?>"
−
                $listaAutoresLibro = [];
+
                    <?= $libroId === $idLibro
−
                $consulta = sprintf(CONSULTAR_AUTORES_POR_LIBRO,$conex->real_escape_string($_POST['lstLibros']));
+
                        ? 'selected'
−
                 $resultado = $conex->query($consulta);
+
                        : '' ?>
−
                 while($fila = $resultado->fetch_assoc()) {
+
                >
−
                     $listaAutoresLibro[] = $fila['id'];
+
                    <?= htmlspecialchars(
−
                }
+
                        $libro['titulo'],
−
                $resultado->free();
+
                        ENT_QUOTES | ENT_SUBSTITUTE,
−
           
+
                        'UTF-8'
−
                $resultado = $conex->query(CONSULTAR_AUTORES);
+
                    ) ?>
−
                while($fila = $resultado->fetch_assoc()) {
+
                </option>
−
                    $listaAutores[] = $fila;
+
 
−
                }
+
            <?php endforeach; ?>
−
                $resultado->free();
+
 
−
        ?>
+
        </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>
  
−
        <form action="procesar_alta_libro_autores.php" method="post">
+
                        <td>
−
            <table border="1" style="margin-top:20px">
+
                            <input
−
                <tr>
+
                                type="checkbox"
−
                    <th>Autores</th>
+
                                name="autores[]"
−
                    <th></th>
+
                                value="<?= $autorId ?>"
−
                </tr>
+
                                <?= in_array(
−
                <?php
+
                                    $autorId,
−
                printf("<input type='hidden' name='libro_id' value=%d",htmlentities($_POST['lstLibros']));  // Necesito enviar o id do libro xunto cos id's dos autores
+
                                    $autoresLibro,
−
                foreach($listaAutores as $autor){
+
                                    true
−
                    $cadena = '';
+
                                )
−
                    if (in_array($autor['id'],$listaAutoresLibro)){ // O autor xa está asociado ao libro
+
                                    ? 'checked'
−
                        $cadena = "checked='checked'";
+
                                    : '' ?>
−
                    }
+
                            >
−
                    printf("<tr><td>%s</td>",htmlentities($autor['apelidos'] . ',' . $autor['nome']));
+
                        </td>
−
                     printf("<td><input name=autores[] type='checkbox' value=%d %s /></td></tr>",htmlentities($autor['id']), $cadena);
+
                     </tr>
−
                }
 
  
−
                 ?>
+
                 <?php endforeach; ?>
 +
 
 +
                </tbody>
 
             </table>
 
             </table>
−
             <div style='margin-top:30px'><input type='submit' value='ASOCIAR' name='btnAsociar' /></div>
+
 
 +
             <button
 +
                type="submit"
 +
                name="btnAsociar"
 +
                value="ASOCIAR"
 +
            >
 +
                Asociar
 +
            </button>
 +
 
 
         </form>
 
         </form>
  
 +
    <?php endif; ?>
  
−
        <?php  
+
    <?php if (isset($_GET['msg'])): ?>
−
            }  // Fin do if seleccionar un libro
 
−
        ?>
 
  
 +
        <p>
 +
            <?= htmlspecialchars(
 +
                $_GET['msg'],
 +
                ENT_QUOTES | ENT_SUBSTITUTE,
 +
                'UTF-8'
 +
            ) ?>
 +
        </p>
  
−
        <div>
+
    <?php endif; ?>
−
            <?php
+
 
−
                if(isset($_GET['error'])) printf("<h2>Houbo un erro: %s</h2>",htmlentities($_GET['error']));
+
    <?php if (isset($_GET['error'])): ?>
−
             ?>
+
 
−
         </div>  
+
        <p>
−
       
+
            <?= htmlspecialchars(
−
     </body>  
+
                $_GET['error'],
 +
                ENT_QUOTES | ENT_SUBSTITUTE,
 +
                'UTF-8'
 +
             ) ?>
 +
         </p>
 +
 
 +
     <?php endif; ?>
 +
 
 +
</body>
 
</html>
 
</html>
 +
</syntaxhighlight>
 +
 +
Non empregamos <code>rowCount()</code> para comprobar se un <code>SELECT</code> devolveu filas.
 +
 +
Recuperamos os resultados mediante:
 +
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$stmt->fetchAll()
 +
</syntaxhighlight>
 +
 +
e traballamos directamente co array obtido.
 +
 +
<br />
 +
 +
=== Arquivo procesar_alta_libro_autores.php ===
 +
 +
Esta é a operación realmente transaccional.
 +
 +
::<syntaxhighlight lang="php" enclose="div">
 
<?php
 
<?php
−
}  // Pecha o try da parte superior da páxina
 
−
catch (mysqli_sql_exception $e) {
 
−
    die("Erro mysql: " . $e->getMessage());
 
−
}
 
−
catch (Expcetion $e) {
 
−
    die("Erro xeral: " . $e->getMessage());
 
−
}
 
−
finally{
 
−
  if(!empty($conex)){
 
−
      $conex->close();
 
−
  }
 
−
}
 
−
?>
 
−
</syntaxhighlight>
 
  
 +
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.'
 +
        );
 +
    }
  
−
'''Arquivo: procesar_alta_libro_autores.php'''
+
    /*
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
    * Validamos todos os identificadores
−
<?php
+
    * ANTES de iniciar a transacción.
−
/* Lembrar quitar isto ao probar o scripts sen erros */
+
    */
−
ini_set('display_errors', 1);
+
    $autores = [];
−
ini_set('display_startup_errors', 1);
 
−
error_reporting(E_ALL);
 
  
−
require ('/var/www/confBD/bd.php');
+
    foreach ($autoresRecibidos as $autor) {
  
−
$error="";  // Variable que a usaremos para amosar os posibles erros que poidan darse
+
        $autorId = filter_var(
−
$msg='';    // Variable empregada para enviar o novo id xerado
+
            $autor,
−
try {
+
            FILTER_VALIDATE_INT,
−
    $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,BD); 
+
            [
−
    $conex->set_charset('utf8mb4');
+
                'options' => [
−
    $conex->autocommit(false);
+
                    'min_range' => 1
−
    // Xestionamos a operacion de asociar
+
                ]
−
    if (!empty($_POST['btnAsociar'])){ 
+
            ]
−
        $libro_id = $conex->real_escape_string($_POST['libro_id']);
+
         );
−
        $query = sprintf(BORRAR_LIBRO_AUTOR_POR_LIBRO,$libro_id);  // Borra todos los autores do libro dado
 
−
         $conex->query($query);
 
  
−
         foreach($_POST['autores'] as $autor){
+
         if ($autorId === false) {
−
             $query = sprintf(INSERIR_LIBRO_AUTOR,$libro_id,$conex->real_escape_string($autor));
+
             throw new RuntimeException(
−
             $conex->query($query);
+
                '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();
 
     $conex->commit();
−
}  // Pecha o try da parte superior da páxina. Lembrar que aos usuarios non se lle deben amosar os erros. Isto está posto a nivel informativo para os programadores
+
 
−
catch (mysqli_sql_exception $e) {
+
    header(
−
     if(!empty($conex)) $conex->rollback();
+
        'Location: alta_libro_autores.php?msg=' .
−
     $error=sprintf("error='Erro mysql: %s'",urlencode($e->getMessage()));
+
        urlencode(
−
}
+
            'As asociacións foron actualizadas correctamente.'
−
catch (Expcetion $e) {
+
        )
−
     if(!empty($conex)) $conex->rollback();
+
    );
−
     $error=sprintf("error='Erro xeral: %s'",urlencode($e->getMessage()));
+
    exit;
−
}
+
 
−
finally{
+
} catch (PDOException $e) {
−
     if(!empty($conex)){
+
 
−
         $conex->autocommit(true);
+
     if (
−
         $conex->close();
+
        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;
 
}
 
}
 +
</syntaxhighlight>
 +
 +
Fixarse en que validamos todos os identificadores '''antes''' de iniciar a transacción.
 +
 +
Desta forma a transacción permanece aberta o menor tempo posible.
 +
 +
<br />
  
−
$url = sprintf("alta_libro_autores.php?%s",$error);
+
== Exercicio proposto 2: alta dun libro e varios exemplares ==
−
header("Location:" . $url);
 
  
−
?>     
+
Queremos crear unha páxina que permita:
  
−
</syntaxhighlight>
+
:# indicar o título dun libro;
 +
:# indicar cantos exemplares queremos dar de alta;
 +
:# amosar un segundo formulario con datos para cada exemplar;
 +
:# dar de alta o libro;
 +
:# obter o identificador autonumérico xerado;
 +
:# 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.
 +
 +
<gallery caption="Libros-Exemplares" widths="350" heights="300px" perrow="2">
 +
Image:UD3_op_bd_trans_ex_2_A.jpg| Indicamos título e número de exemplares.
 +
Image:UD3_op_bd_trans_ex_2_B.jpg| Introducimos os datos de cada exemplar.
 +
</gallery>
  
 
<br />
 
<br />
−
===Solución PDO===
 
−
* '''POSIBLE SOLUCIÓN''' (algunha das constantes xa foron creados en solucións anteriores)
 
  
 +
=== Deseño recomendado ===
  
−
'''Arquivo: bd.php'''
+
Neste exercicio hai dúas alternativas válidas:
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
−
define ('SERVIDOR','localhost');
 
−
define ('USUARIO','user_php');
 
−
define ('PASSWORD','user_php');
 
−
define ('BD','php');
 
  
 +
:* realizar toda a transacción desde PDO;
 +
:* crear un único procedemento almacenado que encapsule a transacción completa.
  
−
define('CONSULTAR_LIBROS','select id,titulo from libros order by titulo');
+
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.
−
define('CONSULTAR_AUTORES','SELECT id,apelidos,nome FROM autores order by apelidos,nome');
 
−
define('CONSULTAR_AUTORES_POR_LIBRO','SELECT id,apelidos,nome FROM autores INNER JOIN libro_autor ON libro_autor.autor_id=autores.id
 
−
                                      WHERE libro_id=:libro_id order by apelidos,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');
 
  
−
</syntaxhighlight>
+
Nesta solución principal faremos a transacción desde '''PDO'''.
  
 +
Despois amosaremos unha alternativa cun procedemento almacenado.
  
 +
<br />
  
−
'''Arquivo: alta_libro_autores.php'''
+
=== Consultas necesarias ===
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
 
−
<?php
+
No arquivo <code>bd.php</code>:
−
/* Lembrar quitar isto ao probar o scripts sen erros */
+
 
−
ini_set('display_errors', 1);
+
::<syntaxhighlight lang="php" enclose="div">
−
ini_set('display_startup_errors', 1);
+
define(
−
error_reporting(E_ALL);
+
    '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
 +
    )'
 +
);
 +
</syntaxhighlight>
  
−
require('/var/www/confBD/bd.php');
+
Se o título debe ser único, esta regra debería estar garantida tamén mediante unha restrición <code>UNIQUE</code> na base de datos.
  
−
try {
+
<br />
−
    $dsn = sprintf("mysql:host=%s;dbname=%s;charset=utf8mb4", SERVIDOR, BD);
 
−
    $conex = new PDO($dsn, USUARIO, PASSWORD);
 
−
    $conex->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
 
  
−
    $result = $conex->query(CONSULTAR_LIBROS);
+
=== Validación dos prezos ===
−
    $listaLibros = [0 => ['id' => -1, 'titulo' => 'Escolle un libro']];
 
  
−
    // Comprobar se hai filas
+
Un valor decimal procedente dun formulario chega a PHP como texto.
−
    if ($result && $result->rowCount() > 0) {
 
−
        while ($fila = $result->fetch(PDO::FETCH_ASSOC)) {
 
−
            $listaLibros[] = $fila;
 
−
        }
 
−
        $result = null;
 
−
    }
 
  
−
?>
+
Podemos normalizar unha coma decimal a punto:
  
−
    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
::<syntaxhighlight lang="php" enclose="div">
−
     <html xmlns="http://www.w3.org/1999/xhtml" lang="es" xml:lang="es">
+
$prezoTexto = str_replace(
 +
    ',',
 +
     '.',
 +
    trim($prezoTexto)
 +
);
 +
</syntaxhighlight>
  
−
    <head>
+
e validalo:
−
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
 
−
        <title>Xestión de libros-autores</title>
 
−
    </head>
 
  
−
    <body>
+
::<syntaxhighlight lang="php" enclose="div">
−
        <form action="<?php echo htmlentities($_SERVER['PHP_SELF']) ?>" method="post">
+
$prezo = filter_var(
−
            <select name='lstLibros'>
+
    $prezoTexto,
−
                <?php
+
    FILTER_VALIDATE_FLOAT
−
                $libroSeleccionado = -1;
+
);
−
                if (!empty($_POST['btnLibro'])) $libroSeleccionado = $_POST['lstLibros'];   // Vimos de seleccionar un libro
 
  
−
                foreach ($listaLibros as $libro) {
+
if (
−
                    $cadena = '';  // Para facer que o libro se manteña seleccionado ao premer no botón
+
    $prezo === false ||
−
                    if ($libroSeleccionado == $libro['id']) $cadena = "selected='selected'";
+
    $prezo <= 0
 +
) {
 +
    // Prezo non válido.
 +
}
 +
</syntaxhighlight>
  
−
                    printf("<option value=%d %s>%s</option>", htmlentities($libro['id']), $cadena, htmlentities($libro['titulo']));
+
PDO non dispón de <code>PDO::PARAM_FLOAT</code>.
−
                }
 
−
                ?>
 
−
            </select>
 
−
            <input type='submit' value='BUSCA AUTORES' name='btnLibro' />
 
−
        </form>
 
  
−
        <?php
+
Por iso, unha vez validado o decimal, podemos envialo como cadea:
−
       
 
−
        if (!empty($_POST['btnLibro'])) {          // Vimos de seleccionar un libro
 
  
−
            $result = $conex->query(CONSULTAR_AUTORES);
+
::<syntaxhighlight lang="php" enclose="div">
−
            $listaAutores=[];
+
$stmt->bindValue(
−
            // Comprobar se hai filas
+
    ':prezo',
−
            if ($result && $result->rowCount() > 0) {
+
    (string) $prezo,
−
                while ($fila = $result->fetch(PDO::FETCH_ASSOC)) {
+
    PDO::PARAM_STR
−
                    $listaAutores[] = $fila;
+
);
−
                }
+
</syntaxhighlight>
−
                $result = null;
 
−
            }
 
  
−
           
+
A columna MySQL correspondente seguirá sendo de tipo numérico, por exemplo <code>DECIMAL(6,2)</code>.
−
            $listaAutoresLibro = [];
 
−
            $stmt = $conex->prepare(CONSULTAR_AUTORES_POR_LIBRO);
 
−
            $stmt->bindValue(":libro_id", $_POST['lstLibros'], PDO::PARAM_INT);
 
−
            $result = $stmt->execute();
 
  
−
            // Comprobar se hai filas
+
<br />
−
            if ($result) {
 
−
                while ($fila = $stmt->fetch(PDO::FETCH_ASSOC)) {
 
−
                    $listaAutoresLibro[] = $fila['id'];
 
−
                }
 
−
                $result = null;
 
−
            }
 
−
        ?>
 
  
−
            <form action="procesar_alta_libro_autores.php" method="post">
+
=== Non sanitizamos a imaxe para gardala ===
−
                <table border="1" style="margin-top:20px">
 
−
                    <tr>
 
−
                        <th>Autores</th>
 
−
                        <th></th>
 
−
                    </tr>
 
−
                    <?php
 
−
                    printf("<input type='hidden' name='libro_id' value=%d", htmlentities($_POST['lstLibros']));  // Necesito enviar o id do libro xunto cos id's dos autores
 
−
                    foreach ($listaAutores as $autor) {
 
−
                        $cadena = '';
 
−
                        if (in_array($autor['id'], $listaAutoresLibro)) { // O autor xa está asociado ao libro
 
−
                            $cadena = "checked='checked'";
 
−
                        }
 
−
                        printf("<tr><td>%s</td>", htmlentities($autor['apelidos'] . ',' . $autor['nome']));
 
−
                        printf("<td><input name=autores[] type='checkbox' value=%d %s /></td></tr>", htmlentities($autor['id']), $cadena);
 
−
                    }
 
  
−
                    ?>
+
Se neste exercicio o campo <code>imaxe</code> contén un nome ou ruta textual, validaremos a súa lonxitude e formato.
−
                </table>
 
−
                <div style='margin-top:30px'><input type='submit' value='ASOCIAR' name='btnAsociar' /></div>
 
−
            </form>
 
−
        <?php
 
−
        }  // Fin do if seleccionar un libro
 
−
        ?>
 
  
−
        <div>
+
Por exemplo:
−
            <?php
 
−
                if(isset($_GET['error'])) printf("<h2>Houbo un erro: %s</h2>",htmlentities($_GET['error']));
 
−
            ?>
 
−
        </div>   
 
−
    </body>
 
  
−
    </html>
+
::<syntaxhighlight lang="php" enclose="div">
 +
$imaxe = trim(
 +
    $imaxeRecibida
 +
);
  
−
<?php
+
if (
−
}    // Pecha o try do principio da páxina
+
     $imaxe === '' ||
−
catch (PDOException $e) {
+
     mb_strlen($imaxe, 'UTF-8') > 100
−
     die("Erro PDO: " . $e->getMessage());
+
) {
−
} catch (Exception $e) {
+
     // Dato non válido.
−
     die("Erro xeral: " . $e->getMessage());
 
−
} finally {
 
−
     $conex = null; // Pechar a conexión
 
 
}
 
}
 +
</syntaxhighlight>
 +
 +
Non empregaremos:
  
−
?>
+
::<syntaxhighlight lang="php" enclose="div">
 +
FILTER_SANITIZE_FULL_SPECIAL_CHARS
 +
</syntaxhighlight>
 +
 
 +
para transformar o dato antes de gardalo.
 +
 
 +
Se posteriormente se mostra nun documento HTML, empregaremos <code>htmlspecialchars()</code> nese momento.
 +
 
 +
<br />
 +
 
 +
=== Procesamento transaccional do libro e os exemplares ===
  
−
</syntaxhighlight>
+
Supoñemos que o segundo formulario envía:
  
 +
::<syntaxhighlight lang="text" enclose="div">
 +
txtTitulo
 +
lstEstado[]
 +
txtImaxe[]
 +
txtPrezo[]
 +
</syntaxhighlight>
  
 +
Antes de iniciar a transacción validaremos que os arrays existen e teñen o mesmo número de elementos.
  
−
'''Arquivo: procesar_alta_libro_autores.php'''
+
::<syntaxhighlight lang="php" enclose="div">
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
 
<?php
 
<?php
−
/* Lembrar quitar isto ao probar o scripts sen erros */
 
−
ini_set('display_errors', 1);
 
−
ini_set('display_startup_errors', 1);
 
−
error_reporting(E_ALL);
 
  
−
require('/var/www/confBD/bd.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]
 +
            )
 +
        );
  
−
try {
+
        $prezo = filter_var(
−
    $dsn = sprintf("mysql:host=%s;dbname=%s;charset=utf8mb4", SERVIDOR, BD);
+
            $prezoTexto,
−
    $conex = new PDO($dsn, USUARIO, PASSWORD);
+
            FILTER_VALIDATE_FLOAT
−
    $conex->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
        );
 +
 
 +
        if (
 +
            $prezo === false ||
 +
            $prezo <= 0
 +
        ) {
 +
            throw new RuntimeException(
 +
                'Existe un prezo non válido.'
 +
            );
 +
        }
 +
 
 +
        $exemplares[] = [
 +
            'estado_id' => $estadoId,
 +
            'imaxe' => $imaxe,
 +
            'prezo' => $prezo
 +
        ];
 +
    }
  
−
     // INICIAMOS TRANSACCION
+
     /*
 +
    * A PARTIR DE AQUÍ EMPEZA
 +
    * A TRANSACCIÓN.
 +
    */
 
     $conex->beginTransaction();
 
     $conex->beginTransaction();
  
−
     if (!empty($_POST['btnAsociar'])){ 
+
     /*
−
        $stmt = $conex->prepare(BORRAR_LIBRO_AUTOR_POR_LIBRO);   // Temos que poñer prepare
+
    * Inserimos o libro.
−
        $stmt->bindValue(':libro_id', $_POST['libro_id'], PDO::PARAM_INT);
+
    */
−
        $result = $stmt->execute();
+
    $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($_POST['autores'] as $autor){
+
    foreach (
−
             $stmt = $conex->prepare(INSERIR_LIBRO_AUTOR);   // Temos que poñer prepare
+
        $exemplares as $indice => $exemplar
−
             $stmt->bindValue(':libro_id', $_POST['libro_id'], PDO::PARAM_INT);
+
    ) {
−
            $stmt->bindValue(':autor_id', $autor, PDO::PARAM_INT);
+
 
−
            $result = $stmt->execute();
+
        $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();
 
     $conex->commit();
  
−
}   // Pecha o try do principio da páxina
+
    header(
−
catch (PDOException $e) {
+
        'Location: alta_libro_exemplares.php?msg=' .
−
     if (!empty($conex) && $conex->inTransaction()) {
+
        urlencode(
 +
            'Libro e exemplares dados de alta correctamente.'
 +
        )
 +
    );
 +
    exit;
 +
 
 +
} catch (PDOException $e) {
 +
 
 +
     if (
 +
        isset($conex) &&
 +
        $conex->inTransaction()
 +
    ) {
 
         $conex->rollBack();
 
         $conex->rollBack();
 
     }
 
     }
−
     $error = sprintf("error=PDO:%s", urlencode($e->getMessage()));
+
 
−
} catch (Exception $e) {
+
     error_log($e->getMessage());
−
     if (!empty($conex) && $conex->inTransaction()) {
+
 
 +
    $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();
 
         $conex->rollBack();
 
     }
 
     }
−
     $error = sprintf("error=ERRO XERAL:%s", urlencode($e->getMessage()));
+
 
−
}
+
     error_log($e->getMessage());
−
finally {
+
 
−
     $conex = null; // Pechar a conexión
+
    header(
 +
        'Location: alta_libro_exemplares.php?error=' .
 +
        urlencode('Produciuse un erro na aplicación.')
 +
     );
 +
    exit;
 
}
 
}
−
$url = sprintf("alta_libro_autores.php?%s",$error);
+
</syntaxhighlight>
−
header("Location:" . $url);
 
−
</syntaxhighlight>  
 
  
 +
Neste exemplo unha excepción producida ao inserir calquera exemplar provoca o salto ao <code>catch</code> e o posterior <code>rollBack()</code>.
  
 +
Polo tanto, non debemos escribir lóxica do tipo:
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
if (!$stmt->execute()) {
 +
    $error = '...';
 +
    break;
 +
}
 +
 +
$conex->commit();
 +
</syntaxhighlight>
 +
 +
porque, con <code>PDO::ERRMODE_EXCEPTION</code>, o mecanismo natural é deixar que a excepción interrompa a secuencia e desfacer a transacción no <code>catch</code>.
  
 
<br />
 
<br />
  
−
==Exercicio proposto 2: Transaccións en MYSQL dentro de procedementos almacenados==
+
=== Formulario para os exemplares ===
  
−
* Imos crear unha páxina de nome '''alta_libro_exemplares.php'''.
+
A lista de estados debe consultarse unha única vez:
−
: Dita páxina terá un formulario onde indicaremos o título do libro e o número de exemplares que queremos dar de alta.
 
−
: Ao premer o botón de 'Alta' aparecerán en forma de lista (o podedes implementar cunha táboa) un formulario (unha por cada exemplar) cos datos: imaxe-prezo-estado.
 
−
: Os datos anteriores (título e número de exemplares) non se poderán cambiar.
 
  
−
* Os campos do formulario serán dos seguintes tipo:
+
::<syntaxhighlight lang="php" enclose="div">
−
:* Estado: Será un combobox (select en html) cos estados da táboa estados.
+
$stmt = $conex->query(
−
:* Imaxe: cambiaremos isto máis adiante. Por agora deixade un campo de texto.
+
    CONSULTAR_ESTADOS
−
:* Prezo: Prezo da imaxe.
+
);
  
−
* Como sempre deberedes validar os datos e que teñan o tipo/formato correcto.
+
$estados = $stmt->fetchAll();
−
* No segundo formulario aparecerá un botón de 'Alta' e outro de 'Cancelar'.
+
</syntaxhighlight>
−
:* Ao premer o botón de alta daremos de alta o libro, obteremos o id autoxerado e usaremos ese id para dar de alta aos exemplares.
+
 
−
:* Se prememos o botón 'Cancelar' desaparecerá o segundo formulario e limparase o primeiro para que poidamos dar de alta un novo libro.
+
Despois reutilizamos o array para todos os exemplares:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
<?php for (
 +
    $i = 0;
 +
    $i < $numExemplares;
 +
    $i++
 +
): ?>
  
 +
    <tr>
  
 +
        <td>
 +
            <select
 +
                name="lstEstado[]"
 +
                required
 +
            >
  
−
<gallery caption="Libros-Examplares" widths="350" heights="300px" perrow="2">
+
                <?php foreach (
−
Image:UD3_op_bd_trans_ex_2_A.jpg| Pide título e número de exemplares a dar de alta.
+
                    $estados as $estado
−
Image:UD3_op_bd_trans_ex_2_B.jpg| Por cada exemplar poñemos os datos necesarios e prememos na alta.
+
                ): ?>
−
</gallery>
+
 
 +
                    <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>
  
−
<br />
+
<?php endfor; ?>
 +
</syntaxhighlight>
  
−
===Parte Mysql===
+
Ao usar:
−
* '''POSIBLE SOLUCIÓN''' (algúns dos procedementos xa foron creados en solucións anteriores)
 
  
−
'''MYSQL'''
+
::<syntaxhighlight lang="text" enclose="div">
 +
lstEstado[]
 +
txtImaxe[]
 +
txtPrezo[]
 +
</syntaxhighlight>
  
−
'''Procedemento: CONSULTAR_ESTADOS'''
+
os índices dos tres arrays permiten relacionar os datos do mesmo exemplar.
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
−
DELIMITER $$
 
−
CREATE DEFINER=`root`@`localhost` PROCEDURE `CONSULTAR_ESTADOS`()
 
−
BEGIN
 
−
SELECT id,descripcion
 
−
FROM estados
 
−
ORDER BY descripcion;
 
  
−
END$$
+
<br />
−
DELIMITER ;
 
−
</syntaxhighlight>  
 
  
 +
== 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.
  
−
'''Procedemento: INSERIR_LIBRO'''
+
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.
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
−
DELIMITER $$
 
  
−
CREATE DEFINER=`root`@`localhost` PROCEDURE `INSERIR_LIBRO`(p_titulo varchar(300))
+
Por iso temos dúas opcións:
−
etiqueta:BEGIN  -- Asociamos unha etiqueta ao código do procedemento para que poidamos facer algo parecido a un exit
 
−
    declare existe int;
 
−
     
 
−
SELECT count(*)
 
−
INTO existe
 
−
FROM libros
 
−
WHERE upper(titulo)=upper(p_titulo);
 
−
 
−
IF existe=1 THEN
 
−
begin
 
−
  SELECT -1 as valor_devolto;
 
−
  leave etiqueta; -- Para que saia do procedemento
 
−
end;
 
−
        END IF;  -- Fin do IF
 
  
−
INSERT INTO libros (titulo)
+
:* manter a transacción en PDO, como no exemplo anterior;
−
VALUES (p_titulo);
+
:* deseñar un procedemento capaz de recibir e procesar todos os exemplares dentro dunha única chamada.
  
−
SELECT last_insert_id() as valor_devolto;
+
A primeira opción é máis sinxela para este exercicio didáctico.
−
END$$
 
−
DELIMITER ;
 
−
</syntaxhighlight>
 
  
 +
<br />
  
 +
=== Exemplo sinxelo de transacción dentro dun procedemento ===
  
 +
Para un caso cun número fixo de operacións:
  
−
'''Procedemento: INSERIR_EXEMPLAR'''
+
::<syntaxhighlight lang="sql" enclose="div">
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
 
DELIMITER $$
 
DELIMITER $$
−
CREATE DEFINER=`root`@`localhost` PROCEDURE `INSERIR_EXEMPLAR`(p_libro_id int,p_id_exemplar int,p_imaxe varchar(100),p_prezo decimal(6,2),p_estado_id int)
+
 
 +
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
 
BEGIN
−
INSERT INTO exemplares (libro_id,id_exemplar,imaxe,prezo,estado_id)
 
−
VALUES (p_libro_id,p_id_exemplar,p_imaxe,p_prezo,p_estado_id);
 
  
−
END$$
+
    DECLARE v_libro_id INT;
−
DELIMITER ;
 
−
</syntaxhighlight>
 
  
 +
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
 +
    BEGIN
 +
        ROLLBACK;
 +
        RESIGNAL;
 +
    END;
  
 +
    START TRANSACTION;
  
−
<br />
+
    INSERT INTO libros (titulo)
−
===Orientada a obxectos===
+
    VALUES (p_titulo);
  
−
'''Arquivo bd.php''':
+
    SET v_libro_id =
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
        LAST_INSERT_ID();
−
define('PROCEDEMENTO_INSERIR_LIBRO',"INSERIR_LIBRO('%s')");
 
−
define('PROCEDEMENTO_INSERIR_EXEMPLAR',"INSERIR_EXEMPLAR(%d,%d,'%s',%f,%d)");
 
−
define('PROCEDEMENTO_CONSULTAR_ESTADOS','CONSULTAR_ESTADOS()');
 
−
</syntaxhighlight>
 
  
 +
    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 ;
 +
</syntaxhighlight>
 +
 +
Neste caso PHP simplemente chama ao procedemento e non inicia outra transacción.
  
 
<br />
 
<br />
−
'''Arquivo alta_libro_exemplares.php'''
 
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
−
<?php
 
−
/* Lembrar quitar isto ao probar o scripts sen erros */
 
−
ini_set('display_errors', 1);
 
−
ini_set('display_startup_errors', 1);
 
−
error_reporting(E_ALL);
 
  
−
require ('/var/www/confBD/bd.php');
+
== mysqli como ampliación ==
 +
 
 +
PDO será o mecanismo principal empregado nos apuntes.
  
−
$error="";  // Variable que a usaremos para amosar os posibles erros que poidan darse
+
Con <code>mysqli</code> o patrón equivalente é:
−
$msg='';    // Variable empregada para enviar o novo id xerado
 
  
−
// *********************************ALTA ************************************************************************//
+
::<syntaxhighlight lang="php" enclose="div">
−
if (!empty($_POST['btnAlta'])){ // DAMOS DE ALTA A EXAMPLARES E O LIBRO
+
<?php
−
    foreach ($_POST['lstEstado'] as $indice => $estado){    // Usamos o índice para buscar os datos no resto dos campos => Fixarse que é nu foreach
 
−
        $prezo = $_POST['txtPrezo'][$indice];
 
−
        $imaxe = $_POST['txtImaxe'][$indice];
 
  
−
        // Xa temos os datos de cada un dos exemplares
+
try {
−
        // Verifacamos que cumpran os criterios de tipo e formato => PRIMEIRO VALIDAMOS. SE SON CORRECTOS TODOS INICIAMOS A OPERACION DE ALTA
+
 
−
        if (!filter_var($estado,FILTER_VALIDATE_INT) || $estado<0){
+
    require '/var/www/confBD/bd.php';
−
            $error .= "O estado é incorrecto...";
 
−
            break;
 
−
        }
 
−
        if (filter_var(str_replace(',', '.', $prezo),FILTER_VALIDATE_FLOAT)==false || $prezo<=0 || !preg_match("/[0-9]{1,6}(,[0-9]{1,2})?/",$prezo)){
 
−
            $error .= "O prezo é incorrecto...";
 
−
            break;
 
−
        }
 
−
        $imaxe=filter_var($imaxe,FILTER_SANITIZE_FULL_SPECIAL_CHARS, FILTER_FLAG_NO_ENCODE_QUOTES);
 
−
        if(((strlen($imaxe))>100) || (strlen($imaxe)==0)) {    // O número de caracteres está establecido na táboa da base de datos
 
−
            $error .= "O nome da imaxe é demasiado grande ou non foi posto...";
 
−
            break;
 
−
        }
 
−
        // Gardamos os datos validados para dalos de alta todos xuntos
 
−
        $dato['prezo'] = $prezo;
 
−
        $dato['imaxe'] = $imaxe;
 
−
        $dato['estado'] = $estado;
 
−
        $datos[] = $dato;
 
−
    }  // Fin do foreach
 
−
   
 
−
   
 
−
    if(empty($error)){  // Non houbo error, empezamos a operación de alta con transaccións
 
−
        try{
 
  
−
            $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,BD); 
+
    mysqli_report(
−
            $conex->set_charset('utf8mb4');
+
        MYSQLI_REPORT_ERROR |
−
           
+
        MYSQLI_REPORT_STRICT
−
            $conex->autocommit(false);  // Iniciamos a transcción
+
    );
−
           
 
−
            // Damos de alta ao libro e obtemos o id xerado
 
−
            $titulo = $conex->real_escape_string($_POST['txtTitulo']);
 
−
            $proc=sprintf(PROCEDEMENTO_INSERIR_LIBRO,$titulo);
 
−
            $proc = sprintf("CALL %s",$proc);
 
−
            if ($conex->multi_query($proc)){
 
−
                $result = $conex->store_result();
 
−
                if ($result && ($result->num_rows>0)){
 
−
                    $fila=$result->fetch_assoc();
 
−
                    switch($fila['valor_devolto']){
 
−
                        case -1:
 
−
                            throw new Exception("Ese libro xa est&aacute; dado de alta na base de datos...<br />");
 
−
                        default:
 
−
                            $id_libro = $fila['valor_devolto'];    // XA TEMOS O ID AUTONUMERICO DO LIBRO
 
−
                    }
 
−
                    $result->free();
 
−
                    while($conex->next_result() && $conex->store_result());
 
−
                }
 
−
                else{  // Sempre ten que devolver algo como temos implementado o proc.
 
−
                    throw new Exception('Houbo un erro na chamada á base de datos<br />');
 
−
                }
 
−
            }
 
−
           
 
−
           
 
−
            // Temos que dar de alta aos exemplares
 
−
            $cont_num_exemplar=1;  // Número de exemplar
 
−
            foreach ($datos as $dato){
 
−
                $prezo = $conex->real_escape_string($dato['prezo']);
 
−
                $estado = $conex->real_escape_string($dato['estado']);
 
−
                $imaxe = $conex->real_escape_string($dato['imaxe']);
 
−
                $proc=sprintf(PROCEDEMENTO_INSERIR_EXEMPLAR,$id_libro,$cont_num_exemplar,$imaxe,$prezo,$estado);
 
−
                $proc = sprintf("CALL %s",$proc);
 
−
                if ($conex->multi_query($proc)){
 
−
                    $result = $conex->store_result();  // Neste caso o procedemento non devolve nada => vale 'false'
 
−
                    if ($conex->errno>0){
 
−
                        $error .= 'Houbo un erro na alta dos exemplares';
 
−
                        break;  //Saimos do for
 
−
                    }
 
−
                    while($conex->next_result() && $conex->store_result());
 
−
                }                 
 
−
                $cont_num_exemplar+=1;
 
−
            }  // Fin do For por cada exemplar
 
  
−
            $conex->commit();
+
     $conex = new mysqli(
−
      
+
         SERVIDOR,
−
        }  // Pecha o try da parte superior da páxina. Lembrar que aos usuarios non se lle deben amosar os erros. Isto está posto a nivel informativo para os programadores
+
         USUARIO,
−
        catch (mysqli_sql_exception $e) {
+
         PASSWORD,
−
            if(!empt($conex)) $conex->rollback();
+
         BD
−
            $error=sprintf("error='Erro mysql: %s'",htmlentities($e->getMessage()));
+
    );
−
         }
 
−
         catch (Expcetion $e) {
 
−
            if(!empt($conex)) $conex->rollback();
 
−
            $error=sprintf("error='Erro xeral: %s'",htmlentities($e->getMessage()));
 
−
         }
 
−
         finally{
 
−
            if(!empty($conex)){
 
−
                $conex->close();
 
−
            }
 
−
        }           
 
−
       
 
−
    }  // Fin de (empty($error)
 
−
}
 
  
−
// *********************************FIN DE ALTA ************************************************************************//
+
    $conex->set_charset(
−
?>
+
        'utf8mb4'
 +
    );
  
 +
    $conex->begin_transaction();
  
−
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+
     /*
−
     "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
    * Operacións preparadas.
−
<html xmlns="http://www.w3.org/1999/xhtml" lang="es" xml:lang="es">
+
    */
−
    <head>
 
−
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
 
−
        <title>Transaccións</title>
 
−
    </head>
 
  
−
     <body>
+
     $conex->commit();
−
        <?php
 
−
            $titulo='';
 
−
            $num_exemplares='';
 
−
            if (!empty($_POST['btnEnviar'])) {  // Prememos o botón que indica o título e o número de exemplares
 
−
                $titulo = filter_var($_POST['txtTitulo'],FILTER_SANITIZE_FULL_SPECIAL_CHARS, FILTER_FLAG_NO_ENCODE_QUOTES);
 
−
                if (strlen($titulo)>200){  // Non pode ter unha lonxitude maior que o campo da base de datos
 
−
                    $error='A lonxitude do título non pode ser maior que 200 caracteres...<br />';
 
−
                }
 
−
                $num_exemplares = $_POST['txtNumExemplares'];
 
−
                if (!filter_var($num_exemplares,FILTER_VALIDATE_INT) || $num_exemplares > 9 || $num_exemplares < 1){
 
−
                    $error.="O número de exemplares ten que ser numérico e non pode ser maior que 9<br />";
 
−
                }
 
−
                if (strlen($titulo)==0) { 
 
−
                    $error.='O título é obrigatorio...<br />';
 
−
                }
 
−
            }
 
−
        ?>
 
−
       
 
−
        <?php
 
−
            $inhabilitar='';
 
−
            if (!empty($_POST['btnEnviar']) && (empty($error))) {  // Facemos que a caixa de texto e todo e demais quede inhabilitado
 
−
                $inhabilitar="disabled='disabled'";                  // Unha vez enviamos os datos do titulo e num exemplares
 
−
            }
 
−
        ?>
 
−
        <form action="<?php echo htmlentities($_SERVER['PHP_SELF']) ?>" method='post'>
 
−
            <div>Titulo Libro: <input <?php echo $inhabilitar ?> <?php printf(" value='%s'",$titulo); ?> type='text' size='100' maxlength="300" name='txtTitulo' /></div>
 
−
            <div>Num. Exemplares: <input <?php echo $inhabilitar ?>  <?php printf(" value='%s'",$num_exemplares); ?> type='number' min='1' max='9' size="2" maxlength="1" name='txtNumExemplares' /> (&lt;10) </div>
 
−
            <input <?php echo $inhabilitar ?> type="submit" value='Enviar' name='btnEnviar' />   
 
−
        </form>
 
  
−
   
+
} catch (mysqli_sql_exception $e) {
−
       
 
−
        <?php
 
−
        if ($titulo && $num_exemplares){   // Enviamos datos
 
−
            // NON IMOS A FACER UNHA CONSULTA A TABOA ESTADOS POR CADA UN DOS EXEMPLARES. NON TEN SENTIDO.
 
−
            // PODERIAMOS PERCORRER O ARRAY DE RESULTADOS E AO REMATAR VOLVER AO PRINCIPIO: http://php.net/manual/es/mysqli-result.data-seek.php
 
  
−
            try {
+
    if (isset($conex)) {
−
                $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,BD); 
 
−
                $conex->set_charset('utf8mb4');
 
−
           
 
−
                $proc = sprintf("CALL %s",PROCEDEMENTO_CONSULTAR_ESTADOS);
 
−
                if ($conex->multi_query($proc)){
 
−
                    $result = $conex->store_result();
 
−
                    if ($result && ($result->num_rows>0)){
 
−
                        while($valor = $result->fetch_assoc()){
 
−
                            $estados[]=$valor;
 
−
                        }
 
−
                        $result->free();
 
−
                        while($conex->next_result() && $conex->store_result());
 
−
                    }
 
−
                }
 
−
            }  // Pecha o try da parte superior da páxina. Lembrar que aos usuarios non se lle deben amosar os erros. Isto está posto a nivel informativo para os programadores
 
−
            catch (mysqli_sql_exception $e) {
 
−
                $error.=sprintf("Erro mysql: %s",htmlentities($e->getMessage()));
 
−
            }
 
−
            catch (Expcetion $e) {
 
−
                $error.=sprintf("Erro xeral: %s",htmlentities($e->getMessage()));
 
−
            }
 
−
            finally{
 
−
                if(!empty($conex)){
 
−
                    $conex->close();
 
−
                }
 
−
            }
 
  
−
            printf("<form action=\"%s\" method='post'>",htmlentities($_SERVER['PHP_SELF']));
+
        try {
−
             printf("<input type='hidden' value='%s' name='txtTitulo' />",htmlentities($_POST['txtTitulo'])); // Como temos dous formularios, temos que gardar o texto do título nun campo oculto
+
             $conex->rollback();
−
            printf ("<table>");
+
        } catch (Throwable) {
−
            for($cont=0;$cont<$num_exemplares;$cont++){
+
             // Non ocultamos a excepción orixinal.
−
                echo "<th>Estado</th><th>Imaxe</th><th>Prezo</th>";
 
−
                echo "<tr>";
 
−
                echo "<td>";    // ESTADO
 
−
                    printf ("<select name='lstEstado[]>");  // Poñemos o nome en forma de array xa que imos recibir varios exemplares con datos.
 
−
                    foreach ($estados as $estado){
 
−
                        printf("<option value='%d'>%s</option>",htmlentities($estado['id']),htmlentities($estado['descripcion']));
 
−
                    }
 
−
                    printf ("</select>");
 
−
                echo "</td>";
 
−
                echo "<td>";    // IMAXE
 
−
                    printf("<input type='text' size='50' maxlength='70' name='txtImaxe[]' />");
 
−
                echo "</td>";
 
−
                echo "<td>";    // PREZO
 
−
                    printf("<input type='text' size='9' maxlength='9' name='txtPrezo[]' /> Euros (XXXXXX,YY)");
 
−
                echo "</td>";
 
−
                echo "</tr>";
 
−
             }
 
−
           
 
−
            printf ("</table>");
 
−
            printf("<input type='submit' value='ALTA' name='btnAlta' />");   
 
−
            printf("<input type='reset' value='CANCELAR' name='btnCancelar' />");   
 
−
            printf("</form>");
 
−
           
 
 
         }
 
         }
−
        ?>
+
    }
−
        <?php
 
−
          if (!empty($error)) {
 
−
            printf("<h4>%s</h4>", $error);
 
−
          }
 
−
        ?>
 
  
−
     </body>   
+
     error_log($e->getMessage());
−
</html>
 
  
 +
    die('Erro relacionado coa base de datos.');
  
−
</syntaxhighlight>
+
} catch (Throwable $e) {
  
 +
    if (isset($conex)) {
  
 +
        try {
 +
            $conex->rollback();
 +
        } catch (Throwable) {
 +
            // Non ocultamos a excepción orixinal.
 +
        }
 +
    }
  
−
<br />
+
    error_log($e->getMessage());
−
===Empregando PDO===
 
  
−
'''Arquivo bd.php''':
+
    die('Produciuse un erro na aplicación.');
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
}
−
define('PROCEDEMENTO_INSERIR_LIBRO',"INSERIR_LIBRO(:titulo)");
+
</syntaxhighlight>
−
define('PROCEDEMENTO_INSERIR_EXEMPLAR',"INSERIR_EXEMPLAR(:id_libro,:id_exemplar,:imaxe,:prezo,:estado)");
 
−
define('PROCEDEMENTO_CONSULTAR_ESTADOS','CONSULTAR_ESTADOS()');
 
−
</syntaxhighlight>  
 
  
 +
<br />
  
 +
=== Exemplo mysqli con consultas preparadas ===
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->begin_transaction();
  
−
<br />
+
$stmt = $conex->prepare(
−
'''Arquivo alta_libro_exemplares.php'''
+
    'INSERT INTO pedidos (cliente_id)
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
    VALUES (?)'
−
<?php
+
);
−
/* Lembrar quitar isto ao probar o scripts sen erros */
 
−
ini_set('display_errors', 1);
 
−
ini_set('display_startup_errors', 1);
 
−
error_reporting(E_ALL);
 
  
−
require('/var/www/confBD/bd.php');
+
$stmt->bind_param(
 +
    'i',
 +
    $clienteId
 +
);
  
 +
$stmt->execute();
  
−
$error = "";  // Variable que a usaremos para amosar os posibles erros que poidan darse
+
$pedidoId =
−
$msg = '';   // Variable empregada para enviar o novo id xerado
+
    $conex->insert_id;
  
−
// *********************************ALTA ************************************************************************//
+
$stmtLina = $conex->prepare(
−
if (!empty($_POST['btnAlta'])) { // DAMOS DE ALTA A EXAMPLARES E O LIBRO
+
    'INSERT INTO lina_pedido (
−
    foreach ($_POST['lstEstado'] as $indice => $estado) {    // Usamos o índice para buscar os datos no resto dos campos => Fixarse que é nu foreach
+
        pedido_id,
−
        $prezo = $_POST['txtPrezo'][$indice];
+
        produto_id,
−
        $imaxe = $_POST['txtImaxe'][$indice];
+
        cantidade
 +
    )
 +
    VALUES (?, ?, ?)'
 +
);
  
−
        // Xa temos os datos de cada un dos exemplares
+
foreach ($produtos as $produto) {
−
        // Verifacamos que cumpran os criterios de tipo e formato => PRIMEIRO VALIDAMOS. SE SON CORRECTOS TODOS INICIAMOS A OPERACION DE ALTA
 
−
        if (!filter_var($estado, FILTER_VALIDATE_INT) || $estado < 0) {
 
−
            $error .= "O estado é incorrecto...";
 
−
            break;
 
−
        }
 
−
        if (filter_var(str_replace(',', '.', $prezo), FILTER_VALIDATE_FLOAT) == false || $prezo <= 0 || !preg_match("/[0-9]{1,6}(,[0-9]{1,2})?/", $prezo)) {
 
−
            $error .= "O prezo é incorrecto...";
 
−
            break;
 
−
        }
 
−
        $imaxe = filter_var($imaxe, FILTER_SANITIZE_FULL_SPECIAL_CHARS, FILTER_FLAG_NO_ENCODE_QUOTES);
 
−
        if (((strlen($imaxe)) > 100) || (strlen($imaxe) == 0)) {   // O número de caracteres está establecido na táboa da base de datos
 
−
            $error .= "O nome da imaxe é demasiado grande ou non foi posto...";
 
−
            break;
 
−
        }
 
−
        // Gardamos os datos validados para dalos de alta todos xuntos
 
−
        $dato['prezo'] = $prezo;
 
−
        $dato['imaxe'] = $imaxe;
 
−
        $dato['estado'] = $estado;
 
−
        $datos[] = $dato;
 
−
    }  // Fin do foreach
 
  
−
     if (empty($error)) {  // Non houbo error, empezamos a operación de alta con transaccións
+
     $produtoId =
−
         try {
+
         $produto['produto_id'];
−
            $dsn = sprintf("mysql:host=%s;dbname=%s;charset=utf8mb4", SERVIDOR, BD);
 
−
            $conex = new PDO($dsn, USUARIO, PASSWORD);
 
−
            $conex->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
 
  
−
            $conex->beginTransaction(); // INICIAMOS TRANSACCION
+
    $cantidade =
 +
        $produto['cantidade'];
  
−
            $stmt = $conex->prepare("CALL " . PROCEDEMENTO_INSERIR_LIBRO);    // Temos que poñer prepare
+
    $stmtLina->bind_param(
−
            $stmt->bindValue(':titulo', $_POST['txtTitulo'], PDO::PARAM_STR);
+
        'iii',
−
            $result = $stmt->execute();
+
        $pedidoId,
 +
        $produtoId,
 +
        $cantidade
 +
    );
  
−
            // Damos de alta ao libro e obtemos o id xerado
+
    $stmtLina->execute();
−
            if ($result) {
+
}
−
                $fila = $stmt->fetch(PDO::FETCH_ASSOC);
 
−
                switch ($fila['valor_devolto']) {
 
−
                    case -1:
 
−
                        throw new Exception("Ese libro xa est&aacute; dado de alta na base de datos...<br />");
 
−
                    default:
 
−
                        $id_libro = $fila['valor_devolto'];    // XA TEMOS O ID AUTONUMERICO DO LIBRO
 
−
                }
 
−
                $stmt = null;
 
−
            } else {  // Sempre ten que devolver algo como temos implementado o proc.
 
−
                throw new Exception('Houbo un erro na chamada á base de datos<br />');
 
−
            }
 
  
−
            // Temos que dar de alta aos exemplares
+
$conex->commit();
−
            $cont_num_exemplar = 1;   // Número de exemplar
+
</syntaxhighlight>
−
            foreach ($datos as $dato) {
 
  
−
                $stmt = $conex->prepare("CALL " . PROCEDEMENTO_INSERIR_EXEMPLAR);    // Temos que poñer prepare
+
Non é necesario empregar o patrón antigo:
−
                $stmt->bindValue(':id_libro', $id_libro, PDO::PARAM_INT);
 
−
                $stmt->bindValue(':id_exemplar', $cont_num_exemplar, PDO::PARAM_INT);
 
−
                $stmt->bindValue(':imaxe', $dato['imaxe'], PDO::PARAM_STR);
 
−
                $stmt->bindValue(':prezo', str_replace(',', '.', $dato['prezo']), PDO::PARAM_STR); // NON EXISTE DOUBLE EN PARAM_ por iso temos que reemplazar a coma polo punto
 
−
                $stmt->bindValue(':estado', $dato['estado'], PDO::PARAM_INT);
 
  
−
                $result = $stmt->execute();
+
::<syntaxhighlight lang="php" enclose="div">
−
                if (!$result) {  // FIXARSE QUE NON LANZAMOS EXCEPCION => DEIXA DADOS DE ALTA ATA O MOMENTO DO ERRO
+
$conex->autocommit(false);
−
                    $error .= 'Houbo un erro na alta dos exemplares';
 
−
                    break;
 
−
                }
 
  
−
                $cont_num_exemplar += 1;
+
// ...
−
            }  // Fin do For por cada exemplar
 
  
−
            $conex->commit();
+
$conex->autocommit(true);
−
        }  // Pecha o try da parte superior da páxina. Lembrar que aos usuarios non se lle deben amosar os erros. Isto está posto a nivel informativo para os programadores
+
</syntaxhighlight>
−
        catch (PDOException $e) {
 
−
            if (!empty($conex) && $conex->inTransaction()) {
 
−
                $conex->rollBack();
 
−
            }
 
−
            $error = sprintf("erro PDO:%s", $e->getMessage());
 
−
        } catch (Exception $e) {
 
−
            if (!empty($conex) && $conex->inTransaction()) {
 
−
                $conex->rollBack();
 
−
            }
 
−
            $error = sprintf("erro XERAL:%s", $e->getMessage());
 
−
        } finally {
 
−
            $conex = null; // Pechar a conexión
 
−
        }
 
−
    }  // Fin de (empty($error)
 
−
}
 
  
−
// *********************************FIN DE ALTA ************************************************************************//
+
como forma principal de explicar as transaccións.
−
?>
 
  
−
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
Para código novo resulta máis claro iniciar explicitamente a unidade de traballo con:
−
<html xmlns="http://www.w3.org/1999/xhtml" lang="es" xml:lang="es">
 
  
−
<head>
+
::<syntaxhighlight lang="php" enclose="div">
−
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+
$conex->begin_transaction();
−
    <title>Transaccións</title>
+
</syntaxhighlight>
−
</head>
 
  
−
<body>
+
e rematala con:
−
    <?php
 
−
    $titulo = '';
 
−
    $num_exemplares = '';
 
−
    if (!empty($_POST['btnEnviar'])) {  // Prememos o botón que indica o título e o número de exemplares
 
−
        $titulo = filter_var($_POST['txtTitulo'], FILTER_SANITIZE_FULL_SPECIAL_CHARS, FILTER_FLAG_NO_ENCODE_QUOTES);
 
−
        if (strlen($titulo) > 200) {  // Non pode ter unha lonxitude maior que o campo da base de datos
 
−
            $error = 'A lonxitude do título non pode ser maior que 200 caracteres...<br />';
 
−
        }
 
−
        $num_exemplares = $_POST['txtNumExemplares'];
 
−
        if (!filter_var($num_exemplares, FILTER_VALIDATE_INT) || $num_exemplares > 9 || $num_exemplares < 1) {
 
−
            $error .= "O número de exemplares ten que ser numérico e non pode ser maior que 9<br />";
 
−
        }
 
−
        if (strlen($titulo) == 0) {
 
−
            $error .= 'O título é obrigatorio...<br />';
 
−
        }
 
−
    }
 
−
    ?>
 
  
−
    <?php
+
::<syntaxhighlight lang="php" enclose="div">
−
    $inhabilitar = '';
+
$conex->commit();
−
    if (!empty($_POST['btnEnviar']) && (empty($error))) {  // Facemos que a caixa de texto e todo e demais quede inhabilitado
+
</syntaxhighlight>
−
        $inhabilitar = "disabled='disabled'";                  // Unha vez enviamos os datos do titulo e num exemplares
 
−
    }
 
−
    ?>
 
−
    <form action="<?php echo htmlentities($_SERVER['PHP_SELF']) ?>" method='post'>
 
−
        <div>Titulo Libro: <input <?php echo $inhabilitar ?> <?php printf(" value='%s'", $titulo); ?> type='text' size='100' maxlength="300" name='txtTitulo' /></div>
 
−
        <div>Num. Exemplares: <input <?php echo $inhabilitar ?> <?php printf(" value='%s'", $num_exemplares); ?> type='number' min='1' max='9' size="2" maxlength="1" name='txtNumExemplares' /> (&lt;10) </div>
 
−
        <input <?php echo $inhabilitar ?> type="submit" value='Enviar' name='btnEnviar' />
 
−
    </form>
 
  
 +
ou:
  
−
    <?php
+
::<syntaxhighlight lang="php" enclose="div">
−
    if ($titulo && $num_exemplares) {    // Enviamos datos
+
$conex->rollback();
−
        // NON IMOS A FACER UNHA CONSULTA A TABOA ESTADOS POR CADA UN DOS EXEMPLARES. NON TEN SENTIDO.
+
</syntaxhighlight>
−
        // PODERIAMOS PERCORRER O ARRAY DE RESULTADOS E AO REMATAR VOLVER AO PRINCIPIO: http://php.net/manual/es/mysqli-result.data-seek.php
 
  
−
        try {
+
<br />
−
            $dsn = sprintf("mysql:host=%s;dbname=%s;charset=utf8mb4", SERVIDOR, BD);
 
−
            $conex = new PDO($dsn, USUARIO, PASSWORD);
 
−
            $conex->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
 
  
−
            $proc = sprintf("CALL %s", PROCEDEMENTO_CONSULTAR_ESTADOS);
+
== Exercicios propostos ==
−
            $result = $conex->query($proc);
 
−
            if ($result && $result->rowCount() > 0) {
 
−
                while ($fila = $result->fetch(PDO::FETCH_ASSOC)) {
 
−
                    $estados[] = $fila;
 
−
                }
 
−
                $result = null;
 
−
            }
 
−
        } catch (PDOException $e) {
 
−
            $error .= sprintf("Erro mysql: %s", htmlentities($e->getMessage()));
 
−
        } catch (Exception $e) {
 
−
            $error .= sprintf("Erro xeral: %s", htmlentities($e->getMessage()));
 
−
        } finally {
 
−
            $conex = null; // Pechar a conexión
 
−
        } // Pecha o try da parte superior da páxina. Lembrar que aos usuarios non se lle deben amosar os erros. Isto está posto a nivel informativo para os programadores
 
  
−
        printf("<form action=\"%s\" method='post'>", htmlentities($_SERVER['PHP_SELF']));
+
=== Subida de imaxes ===
−
        printf("<input type='hidden' value='%s' name='txtTitulo' />", htmlentities($_POST['txtTitulo'])); // Como temos dous formularios, temos que gardar o texto do título nun campo oculto
 
−
        printf("<table>");
 
−
        for ($cont = 0; $cont < $num_exemplares; $cont++) {
 
−
            echo "<th>Estado</th><th>Imaxe</th><th>Prezo</th>";
 
−
            echo "<tr>";
 
−
            echo "<td>";    // ESTADO
 
−
            printf("<select name='lstEstado[]>");  // Poñemos o nome en forma de array xa que imos recibir varios exemplares con datos.
 
−
            foreach ($estados as $estado) {
 
−
                printf("<option value='%d'>%s</option>", htmlentities($estado['id']), htmlentities($estado['descripcion']));
 
−
            }
 
−
            printf("</select>");
 
−
            echo "</td>";
 
−
            echo "<td>";    // IMAXE
 
−
            printf("<input type='text' size='50' maxlength='70' name='txtImaxe[]' />");
 
−
            echo "</td>";
 
−
            echo "<td>";    // PREZO
 
−
            printf("<input type='text' size='9' maxlength='9' name='txtPrezo[]' /> Euros (XXXXXX,YY)");
 
−
            echo "</td>";
 
−
            echo "</tr>";
 
−
        }
 
  
−
        printf("</table>");
+
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.
−
        printf("<input type='submit' value='ALTA' name='btnAlta' />");
 
−
        printf("<input type='reset' value='CANCELAR' name='btnCancelar' />");
 
−
        printf("</form>");
 
−
    }
 
−
    ?>
 
−
    <?php
 
−
    if (!empty($error)) {
 
−
        printf("<h4>%s</h4>", $error);
 
−
    }
 
−
    ?>
 
  
−
</body>
+
A subida do ficheiro e a transacción da base de datos son dous mecanismos diferentes.
  
−
</html>
+
Un <code>ROLLBACK</code> da base de datos non elimina automaticamente un ficheiro que xa se gardase no sistema de ficheiros.
−
</syntaxhighlight>  
 
  
 +
Polo tanto haberá que deseñar tamén como desfacer ou limpar os ficheiros creados se a operación da base de datos falla.
  
 +
<br />
  
 +
=== Asociar autores durante a alta do libro ===
  
−
<br />
+
Ampliar o exercicio de alta dun libro para permitir seleccionar tamén os autores.
−
* '''Exercicio proposto'''
 
−
: Tedes [http://wiki.cifprodolfoucha.es/index.php?title=PHP_Paso_de_datos_con_formularios#Gardando_imaxes_no_servidor neste enlace] da WIKI a forma de subir unha imaxe ao servidor. Modificade o código anterior para facelo.
 
  
 +
A operación completa podería incluír:
  
 +
:# inserir o libro;
 +
:# obter o identificador xerado;
 +
:# inserir os exemplares;
 +
:# inserir as relacións en <code>LIBRO_AUTOR</code>;
 +
:# confirmar todas as operacións mediante un único <code>COMMIT</code>.
  
−
* '''Exercicio proposto'''
+
Se falla calquera operación deberá realizarse <code>ROLLBACK</code>.
−
: Amplía a funcionalidade da páxina anterior para poder asociar a os autores cos libros na mesma operación de alta.
 
−
: Para iso fai que os autores se carguen nunha lista (<select con size en HTML>) ordenados alfabeticamente e que se poidan seleccionar varios.
 
−
: En caso de que os autores non estean dados de alta, poderás premer nun enlace que abrirá unha xanela nova (sen o toolbar, botóns,...) e que permitirá dar de alta a un novo autor. Ao pechar a xanela deberán refrescarse os datos da lista para amosar o novo autor, mantendo os datos previamente introducidos.
 
  
 +
<br />
  
 +
=== 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:
  
 +
:# eliminar as asociacións anteriores;
 +
:# inserir as asociacións seleccionadas;
 +
:# realizar todo dentro dunha única transacción.
  
 +
<br />
  
 +
== Resumo ==
  
 +
* Unha transacción permite agrupar varias modificacións nunha única unidade lóxica.
 +
* As operacións confírmanse mediante <code>COMMIT</code>.
 +
* Se se produce un erro, desfanse mediante <code>ROLLBACK</code>.
 +
* Con PDO iniciamos a transacción mediante <code>beginTransaction()</code>.
 +
* <code>beginTransaction()</code> desactiva temporalmente o autocommit para esa conexión.
 +
* Antes de facer <code>rollBack()</code> podemos comprobar <code>inTransaction()</code>.
 +
* PDO será o mecanismo principal empregado nos exemplos.
 +
* As consultas con valores variables realizaranse con <code>prepare()</code>.
 +
* 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 <code>PDO::ERRMODE_EXCEPTION</code> lanza unha <code>PDOException</code>.
 +
* As excepcións permiten interromper a secuencia e realizar o <code>ROLLBACK</code> no <code>catch</code>.
 +
* Non mostraremos directamente <code>$e->getMessage()</code> aos usuarios.
 +
* Empregaremos <code>error_log()</code> para rexistrar o detalle técnico.
 +
* Debemos empregar un motor transaccional como InnoDB.
 +
* As sentenzas DDL poden provocar un <code>COMMIT</code> implícito en MySQL.
 +
* InnoDB soporta os niveis <code>READ UNCOMMITTED</code>, <code>READ COMMITTED</code>, <code>REPEATABLE READ</code> e <code>SERIALIZABLE</code>.
 +
* O nivel habitual por defecto de InnoDB é <code>REPEATABLE READ</code>.
 +
* 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 <code>EXIT HANDLER</code>, <code>ROLLBACK</code> e <code>RESIGNAL</code>.
 +
* 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 <code>$_POST['campo'] ?? []</code>.
 +
* Para inserir varias filas coa mesma consulta é recomendable preparar a sentenza unha soa vez e reutilizala.
 +
* <code>lastInsertId()</code> permite obter o identificador xerado para empregalo nas seguintes operacións da mesma unidade de traballo.
 +
* Un <code>ROLLBACK</code> da base de datos non desfai automaticamente cambios externos, como ficheiros gardados no sistema de ficheiros.
 +
* <code>mysqli</code> queda como ampliación e tamén debe empregar consultas preparadas.
  
−
<br> -- [[Usuario:angelfg|Ángel D. Fernández González]] -- (2025).
+
<br> -- [[Usuario:angelfg|Ángel D. Fernández González]] -- (2026).

Revisión del 07:51 23 sep 2026

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.

O usuario da base de datos deberá ter unicamente os permisos necesarios para as operacións da aplicación.


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).