Diferencia entre revisiones de «PHP UD3 TRANSACCIONS»

De MediaWiki
Ir a la navegación Ir a la búsqueda
(Página creada con «==Introducció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, DE…»)
 
 
(No se muestran 13 ediciones intermedias del mismo usuario)
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.
  
 +
O esquema básico é:
  
−
: 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.
+
:# iniciar a transacción;
 +
:# realizar as operacións;
 +
:# se todo é correcto, executar <code>COMMIT</code>;
 +
:# se se produce un erro, executar <code>ROLLBACK</code>.
  
−
* Isto o podemos facer grazas as '''TRANSACCIÓNS'''.
+
Por exemplo:
−
: 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.
 
  
−
* 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.
+
::<syntaxhighlight lang="text" enclose="div">
 +
INICIO
  
−
* Máis información sobre Transaccións:
+
    INSERT libro
−
:* [https://es.wikipedia.org/wiki/Transacci%C3%B3n_(base_de_datos) Definición Wikipedia].
+
    INSERT exemplar 1
−
:* [https://es.wikipedia.org/wiki/ACID Características dunha transacción].
+
    INSERT exemplar 2
−
:* [https://dev.mysql.com/doc/refman/5.7/en/commit.html Transaccións en Mysql].
+
    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.
  
 +
<br />
  
−
* Cando falamos de transaccións outro factor a ter en conta é o chamado '''Nivel de illamento'''.
+
=== Propiedades ACID ===
−
: 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:
+
As transaccións adoitan explicarse mediante as 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].
 
  
 +
:* '''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 />
  
 +
=== Autocommit ===
  
−
* Dos niveis de illamento (que se basean en bloquear rexistros de táboas) se pode provocar outro problema, que é o '''Interbloqueo''' ou '''Deadlock'''.
+
Normalmente MySQL traballa con <code>autocommit</code> activado.
−
: 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].
+
Isto significa que unha orde individual como:
  
 +
::<syntaxhighlight lang="sql" enclose="div">
 +
UPDATE contas
 +
SET saldo = saldo - 100
 +
WHERE id = 1;
 +
</syntaxhighlight>
  
 +
queda confirmada automaticamente cando remata correctamente.
  
 +
Cando iniciamos explicitamente unha transacción con PDO:
 +
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->beginTransaction();
 +
</syntaxhighlight>
 +
 +
A conexión pasa a traballar nunha transacción explícita e deixa de confirmar automaticamente as súas modificacións ata realizar <code>commit()</code> ou <code>rollBack()</code>.
 +
 +
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 />
−
* '''<u>Nota importante:</u>''' Lembrar que xa expliquei [https://wiki.cifprodolfoucha.es/index.php?title=PHP_Operaci%C3%B3ns_BD_Mysql#Control_de_errores ao abrir a conexión, no control de erros] así como tamén nas operacións de consulta/alta/baixa/modificar que se <u>estamos a empregar á versión PHP 8.1 ou superior</u>, vanse lanzar excepcións que teremos que teremos que capturar con catch.
 
  
 +
== Requirimentos e consideracións ==
  
 +
Para empregar transaccións debemos traballar cun motor de almacenamento que as soporte.
 +
 +
En MySQL empregaremos normalmente:
 +
 +
::<syntaxhighlight lang="text" enclose="div">
 +
InnoDB
 +
</syntaxhighlight>
 +
 +
As táboas implicadas na operación deben empregar un motor transaccional.
  
 
<br />
 
<br />
−
==Funcionamento==
 
  
−
* En PHP, para poder utilizar transaccións necesitamos:
+
Nos exemplos desta unidade empregaremos:
−
:* 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].
+
:* PHP 8.5.
−
+
:* PDO como mecanismo principal de acceso á base de datos.
 +
:* MySQL 8 con táboas InnoDB.
 +
:* consultas preparadas para os valores variables.
 +
:* excepcións para detectar erros.
 +
:* <code>error_log()</code> para rexistrar a información técnica.
 +
 
 +
 
 +
 
 +
 
 +
* '''CONSIDERACIÓNS:'''
 +
:* Crear un usuario/password con permisos de acceso a dita base de datos (asignade unicamente os permisos imprescindibles: SELECT, INSERT, UPDATE e DELETE sobre as táboas necesarias; EXECUTE só se se empregan procedementos almacenados)
 +
:* Crear nun arquivo separado do sitio web todos os datos necesarios para conectarse á base de datos e onde van estar definidas as constantes coas ordes SQL, tal como [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, os ficheiros PHP públicos poden ter permisos 644 e os directorios 755. O ficheiro de credenciais situado fóra da raíz web debe ter permisos máis restritivos (por exemplo, 640) e pertencer a un usuario/grupo axeitado, sen conceder escritura ao servidor web salvo que sexa imprescindible.
 +
:* Ao principio ides faiar en moitas cousas. Lembrade ter posto o display_errors como vimos nas seccións anteriores. Para depurar empregade mensaxes e rexistros sen imprimir credenciais nin datos sensibles. Nas consultas preparadas non se debe construír a SQL concatenando valores do formulario.
 +
:* Lembrar que tamén podedes activar o debugger como está indicado [https://wiki.cifprodolfoucha.es/index.php?title=PHP_Habilitar_depuraci%C3%B3n_en_VS_Code nesta sección da wiki].
  
  
−
* Unha transacción a podemos facer dende a páxina PHP ou dende o xestor Mysql dentro dun procedemento almacenado.
 
  
−
* Para realizar unha transacción o proceso sempre é o mesmo en todas as linguaxes e xestores.
 
−
:# 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'
 
  
  
Línea 76: Línea 141:
 
<br />
 
<br />
  
−
* Para iniciar unha transacción temos dúas opcións:
+
=== Preparación de MySQL 8 ===
  
−
:* Inhabilitar as transaccións implícitas, coa [http://php.net/manual/es/mysqli.autocommit.php función autocommit]:
+
A aplicación debe empregar o controlador <code>pdo_mysql</code> e táboas <code>InnoDB</code>. Podemos verificar o motor de cada táboa mediante:
−
::* Versión procedimental:
 
−
::::<syntaxhighlight lang="java" line enclose="div" highlight="2" >
 
−
$conex = mysqli_connect(HOST, USER, PASSWORD, BD);
 
−
mysqli_autocommit($conex,false);
 
−
</syntaxhighlight>  
 
  
−
::* Versión obxectual:
+
::<syntaxhighlight lang="sql" enclose="div">
−
::::<syntaxhighlight lang="java" line enclose="div" highlight="2" >
+
SELECT TABLE_NAME, ENGINE
−
$conex= new mysqli(HOST, USER, PASSWORD, BD);
+
FROM information_schema.TABLES
−
$conex->autocommit(false);
+
WHERE TABLE_SCHEMA = 'php';
−
</syntaxhighlight>  
+
</syntaxhighlight>
  
−
:: Desta forma as operacións que vaian a continuación desta orde non se farán efectivas ata atopar un commit.
+
Substituiremos <code>php</code> polo nome da base de datos utilizada. Se algunha táboa implicada utiliza un motor sen soporte para transaccións, un <code>ROLLBACK</code> non garante a atomicidade.
  
−
:* Iniciar unha transacción coa [http://php.net/manual/es/mysqli.begin-transaction.php función mysqli::begin_transaction].
+
A aplicación debe traballar con <code>utf8mb4</code> e un usuario con privilexios mínimos. Non deben incluírse contrasinais reais nos arquivos públicos da wiki.
−
:: '''NECESITAMOS QUE A VERSIÓN PHP SEXA 5.5 OU SUPERIOR'''.
 
  
−
::* Versión procedimental:
+
=== Arquivo de configuración ===
−
::::<syntaxhighlight lang="java" line enclose="div" highlight="2" >
 
−
$conex = mysqli_connect(HOST, USER, PASSWORD, BD);
 
−
mysqli_begin_transaction($conex,MYSQLI_TRANS_START_READ_ONLY);
 
−
</syntaxhighlight>
 
  
−
::* Versión obxectual:
+
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>:
−
::::<syntaxhighlight lang="java" line enclose="div" highlight="2" >
 
−
$conex= new mysqli(HOST, USER, PASSWORD, BD);
 
−
$conex->begin_transaction(MYSQLI_TRANS_START_READ_ONLY);
 
−
</syntaxhighlight>  
 
  
 +
::<syntaxhighlight lang="text" enclose="div">
 +
/var/www/confBD/bd.php
 +
</syntaxhighlight>
  
−
:: 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.
+
Por exemplo:
−
:: 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].
 
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
<?php
  
−
* 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].
+
define('SERVIDOR', 'localhost');
 +
define('USUARIO', 'user_php');
 +
define('PASSWORD', 'user_php');
 +
define('BD', 'php'); // Substituír polo nome real da base de datos de prácticas.
 +
</syntaxhighlight>
  
−
::* Versión procedimental:
+
<br />
−
::::<syntaxhighlight lang="java" line enclose="div" highlight="7" >
 
−
$conex = mysqli_connect(HOST, USER, PASSWORD, BD);
 
−
mysqli_begin_transaction($conex,MYSQLI_TRANS_START_READ_ONLY);
 
  
−
.........
+
=== Conexión PDO empregada nos exemplos ===
−
OPERACIÓN
 
−
Si erro
 
−
  mysqli_rollback($conex);
 
−
  die('Houbo un erro...');
 
  
−
</syntaxhighlight>  
+
::<syntaxhighlight lang="php" enclose="div">
 +
$dsn = sprintf(
 +
    'mysql:host=%s;dbname=%s;charset=utf8mb4',
 +
    SERVIDOR,
 +
    BD
 +
);
  
−
::* Versión obxectual:
+
$conex = new PDO(
−
::::<syntaxhighlight lang="java" line enclose="div" highlight="7" >
+
    $dsn,
−
$conex= new mysqli(HOST, USER, PASSWORD, BD);
+
    USUARIO,
−
$conex->begin_transaction(MYSQLI_TRANS_START_READ_ONLY);
+
    PASSWORD,
 +
    [
 +
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
 +
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 +
            PDO::ATTR_EMULATE_PREPARES => false
 +
    ]
 +
);
 +
</syntaxhighlight>
  
−
.........
+
Ao empregar:
−
OPERACIÓN
 
−
Si erro
 
−
  $conex->rollback();
 
−
  die('Houbo un erro...');
 
  
−
</syntaxhighlight>  
+
::<syntaxhighlight lang="php" enclose="div">
 +
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 +
            PDO::ATTR_EMULATE_PREPARES => false
 +
</syntaxhighlight>
  
 +
un erro SQL provoca unha <code>PDOException</code>, o que facilita realizar o <code>ROLLBACK</code> desde o bloque <code>catch</code>.
  
−
* Exemplo (obxectual) empregando unha versión de PHP 8.1 onde as ordes contra a base de datos poden provocar unha excepción:
+
<br />
−
:::<syntaxhighlight lang="java" line enclose="div" highlight="5,6,11,,19,23" >
+
 
 +
== Funcionamento dunha transacción con PDO ==
 +
 
 +
O patrón básico será:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 
try {
 
try {
−
     $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,DB);   // Constantes definidas nun arquivo fora do sitio web
+
 
−
     $conex->set_charset('utf8mb4');
+
    require '/var/www/confBD/bd.php';
−
      
+
 
−
     $conex->begin_transaction(MYSQLI_TRANS_START_READ_WRITE);
+
    $dsn = sprintf(
−
     $result=$conex->query("INSERT INTO TABLE_1 (col2) VALUES ('valor')");
+
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
−
     if ($result){
+
        SERVIDOR,
−
      // Operación correcta
+
        BD
−
      printf ("Novo rexistro có id %d.\n", $conex->insert_id);
+
    );
 +
 
 +
     $conex = new PDO(
 +
        $dsn,
 +
        USUARIO,
 +
        PASSWORD,
 +
        [
 +
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
 +
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 +
            PDO::ATTR_EMULATE_PREPARES => false
 +
        ]
 +
    );
 +
 
 +
     $conex->beginTransaction();
 +
 
 +
    // Operación 1
 +
    // Operación 2
 +
     // Operación 3
 +
 
 +
     $conex->commit();
 +
 
 +
} catch (PDOException $e) {
 +
 
 +
     if (
 +
        isset($conex) &&
 +
        $conex->inTransaction()
 +
     ) {
 +
        $conex->rollBack();
 
     }
 
     }
−
     $result=$conex->query("INSERT INTO TABLE_2 (col2) VALUES ('valor')");
+
 
−
     if ($result){
+
     error_log($e->getMessage());
−
      // Operación correcta
+
 
−
      printf ("Novo rexistro có id %d.\n", $conex->insert_id);
+
    die('Erro relacionado coa base de datos.');
 +
 
 +
} catch (Throwable $e) {
 +
 
 +
     if (
 +
        isset($conex) &&
 +
        $conex->inTransaction()
 +
    ) {
 +
        $conex->rollBack();
 
     }
 
     }
  
−
}  // Pecha o try da parte superior da páxina
+
     error_log($e->getMessage());
−
catch (mysqli_sql_exception $e) {
+
 
−
     if(!empty($conex)) $conex->rollback();
+
     die('Produciuse un erro na aplicación.');
−
    die("Erro mysql: " . $e->getMessage());
 
−
}
 
−
catch (Expcetion $e) {
 
−
    if(!empty($conex)) $conex->rollback();
 
−
     die("Erro xeral: " . $e->getMessage());
 
−
}
 
−
finally{
 
−
  if(!empty($conex)){
 
−
      $conex->close();
 
−
  }
 
 
}
 
}
 +
</syntaxhighlight>
 +
 +
<br />
 +
 +
=== beginTransaction() ===
 +
 +
Para iniciar a transacción empregamos:
 +
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->beginTransaction();
 +
</syntaxhighlight>
  
−
</syntaxhighlight>  
+
A partir dese momento, as modificacións realizadas pola conexión non quedan confirmadas definitivamente ata executar <code>commit()</code>.
  
 +
<br />
  
 +
=== commit() ===
  
−
* Unha vez rematadas as operacións facemos o '''commit''' coa [http://php.net/manual/es/mysqli.commit.php función commit()]:
+
Se todas as operacións finalizaron correctamente:
−
::* Versión procedimental:
 
−
::::<syntaxhighlight lang="java" line enclose="div" highlight="" >
 
−
mysqli_commit($conex);
 
−
</syntaxhighlight>
 
  
−
::* Versión obxectual:
+
::<syntaxhighlight lang="php" enclose="div">
−
::::<syntaxhighlight lang="java" line enclose="div" highlight="" >
 
 
$conex->commit();
 
$conex->commit();
−
</syntaxhighlight>  
+
</syntaxhighlight>
  
 +
confirma os cambios.
  
 +
O <code>commit()</code> debe executarse unicamente cando todas as operacións que forman a unidade lóxica finalizaron correctamente.
  
 +
<br />
  
−
* Se establecimos autocommit(false) deberemos volvelo a establecer en True.
+
=== rollBack() ===
−
:::<syntaxhighlight lang="java" line enclose="div" highlight="" >
 
−
$conex->autocommit(true);
 
−
</syntaxhighlight>
 
  
 +
Se se produce unha excepción:
  
−
* Ao rematar todas as operacións sobre a base de datos pecharemos a conexión.
+
::<syntaxhighlight lang="php" enclose="div">
−
:::<syntaxhighlight lang="java" line enclose="div" highlight="" >
+
$conex->rollBack();
−
$conex->close();
 
 
</syntaxhighlight>
 
</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:
 +
 +
::<syntaxhighlight lang="php" enclose="div">
 +
if (
 +
    isset($conex) &&
 +
    $conex->inTransaction()
 +
) {
 +
    $conex->rollBack();
 +
}
 +
</syntaxhighlight>
 +
 +
Isto evita intentar desfacer unha transacción que xa non está activa.
  
 
<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:):
+
 
−
:::<syntaxhighlight lang="java" line enclose="div" highlight="16,21,26,32" >
+
=== 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)'
 +
);
 +
 
 +
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:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
<?php
 +
 
 
try {
 
try {
−
     $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,DB);   // Constantes definidas nun arquivo fora do sitio web
+
 
−
     $conex->set_charset('utf8mb4');
+
    require '/var/www/confBD/bd.php';
−
      
+
 
−
     $conex->begin_transaction(MYSQLI_TRANS_START_READ_WRITE);
+
    $dsn = sprintf(
−
     $result=$conex->query("INSERT INTO TABLE_1 (col2) VALUES ('valor')");
+
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
−
     if ($result){
+
        SERVIDOR,
−
      // Operación correcta
+
        BD
−
      printf ("Novo rexistro có id %d.\n", $conex->insert_id);
+
    );
 +
 
 +
     $conex = new PDO(
 +
        $dsn,
 +
        USUARIO,
 +
        PASSWORD,
 +
        [
 +
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
 +
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 +
            PDO::ATTR_EMULATE_PREPARES => false
 +
        ]
 +
    );
 +
 
 +
    $clienteId = 10;
 +
 
 +
    $produtos = [
 +
        [
 +
            'produto_id' => 5,
 +
            'cantidade' => 2
 +
        ],
 +
        [
 +
            'produto_id' => 8,
 +
            'cantidade' => 1
 +
        ]
 +
    ];
 +
 
 +
    $conex->beginTransaction();
 +
 
 +
    /*
 +
    * Creamos o pedido.
 +
    */
 +
     $stmtPedido = $conex->prepare(
 +
        INSERIR_PEDIDO
 +
    );
 +
 
 +
    $stmtPedido->execute([
 +
        ':cliente_id' => $clienteId
 +
    ]);
 +
 
 +
     /*
 +
    * Recuperamos o identificador antes
 +
    * de finalizar a transacción.
 +
    */
 +
     $pedidoId =
 +
        (int) $conex->lastInsertId();
 +
 
 +
    /*
 +
    * Preparamos unha única vez a consulta
 +
    * que imos executar varias veces.
 +
    */
 +
     $stmtLina = $conex->prepare(
 +
        INSERIR_LINA_PEDIDO
 +
    );
 +
 
 +
    foreach ($produtos as $produto) {
 +
 
 +
        $stmtLina->execute([
 +
            ':pedido_id' => $pedidoId,
 +
            ':produto_id' =>
 +
                $produto['produto_id'],
 +
            ':cantidade' =>
 +
                $produto['cantidade']
 +
        ]);
 +
    }
 +
 
 +
    /*
 +
    * Todas as operacións remataron ben.
 +
    */
 +
    $conex->commit();
 +
 
 +
} catch (PDOException $e) {
 +
 
 +
     if (
 +
        isset($conex) &&
 +
        $conex->inTransaction()
 +
    ) {
 +
        $conex->rollBack();
 
     }
 
     }
−
     $result=$conex->query("INSERT INTO TABLE_2 (col2) VALUES ('valor')");
+
 
−
     if ($result){
+
     error_log($e->getMessage());
−
      // Operación correcta
+
 
−
      printf ("Novo rexistro có id %d.\n", $conex->insert_id);
+
    die('Erro relacionado coa base de datos.');
 +
 
 +
} catch (Throwable $e) {
 +
 
 +
     if (
 +
        isset($conex) &&
 +
        $conex->inTransaction()
 +
    ) {
 +
        $conex->rollBack();
 
     }
 
     }
−
    $conex->commit();
 
  
−
}  // Pecha o try da parte superior da páxina
+
     error_log($e->getMessage());
−
catch (mysqli_sql_exception $e) {
 
−
    if(!empty($conex)) $conex->rollback();
 
−
     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
 
  
 +
    die('Produciuse un erro na aplicación.');
 
}
 
}
−
catch (Expcetion $e) {
+
</syntaxhighlight>
−
    if(!empty($conex)) $conex->rollback();
+
 
−
    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
+
Fixarse en varios detalles:
−
}
+
 
−
finally{
+
:* as consultas variables son preparadas;
−
  if(!empty($conex)){
+
:* a consulta das liñas se prepara unha soa vez e se reutiliza dentro do bucle;
−
      $conex->close();
+
:* <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>;
−
  $conex->autocommit(true);
+
:* 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.
 +
 
 +
Por exemplo, non deberiamos facer:
 +
 
 +
::<syntaxhighlight lang="text" enclose="div">
 +
INICIAR TRANSACCIÓN
 +
 
 +
INSERT ...
 +
 
 +
// Procesamento moi longo.
 +
// Chamadas externas.
 +
// Interacción co usuario.
 +
// Esperas.
 +
 
 +
UPDATE ...
 +
 
 +
COMMIT
 +
</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 />
 +
 
 +
== Sentenzas DDL e COMMIT implícito ==
 +
 
 +
Debemos evitar executar sentenzas de definición de datos dentro dunha transacción MySQL.
 +
 
 +
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>
 +
 
 +
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:
 +
 
 +
::<syntaxhighlight lang="text" enclose="div">
 +
SELECT
 +
INSERT
 +
UPDATE
 +
DELETE
 +
</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 />
  
−
}
+
=== Problemas de concorrencia ===
  
−
</syntaxhighlight>
+
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.
  
 
<br />
 
<br />
−
==Transaccións en MYSQL dentro de procedementos almacenados==
 
  
 +
=== Modificar o nivel de illamento ===
  
−
* 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.
+
Normalmente non necesitaremos cambialo nos primeiros exemplos.
  
−
* O proceso é moi parecido ao feito dende PHP.
+
Se fose necesario, MySQL permite configurar o nivel dunha transacción mediante SQL.
−
: Lembrar que se o facemos dende o propio procedemento <u>NON TEMOS QUE FACELO</u> dende PHP.
 
  
−
::<syntaxhighlight lang="java" line enclose="div" highlight="5-9,11,20" >
+
Por exemplo:
−
DELIMITER $$
+
 
 +
::<syntaxhighlight lang="sql" enclose="div">
 +
SET TRANSACTION ISOLATION LEVEL
 +
READ COMMITTED;
 +
</syntaxhighlight>
 +
 
 +
Debe facerse comprendendo previamente as consecuencias sobre a concorrencia e a consistencia.
 +
 
 +
<br />
 +
 
 +
== Interbloqueos ou deadlocks ==
 +
 
 +
Un '''deadlock''' pode producirse cando dúas transaccións esperan mutuamente por recursos bloqueados pola outra.
  
−
CREATE PROCEDURE `PROCEDEMENTO_CON_TRANSACCION`(_param1 int,_param2 varchar(50),_param3 varchar(200))
+
Un exemplo simplificado:
−
BEGIN
 
−
DECLARE EXIT HANDLER FOR SQLEXCEPTION
 
−
BEGIN
 
−
ROLLBACK;
 
−
SELECT -1 as error;
 
−
END;
 
  
−
START TRANSACTION;
+
::<syntaxhighlight lang="text" enclose="div">
 +
TRANSACCIÓN A
 +
    bloquea fila 1
 +
    espera pola fila 2
  
−
INSERT INTO TABLE_1(col1,col2)
+
TRANSACCIÓN B
−
VALUES (_param2,_param3);  -- Supoñemos que esta táboa devolve un valor clave autonumérico.
+
    bloquea fila 2
 +
    espera pola fila 1
 +
</syntaxhighlight>
  
−
INSERT INTO TABLE_2(col1,col2)
+
InnoDB pode detectar o interbloqueo e desfacer unha das transaccións para permitir que a outra continúe.
−
VALUES (_param1,last_insert_id());
 
  
−
+
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.
−
COMMIT;
 
−
SELECT 0 AS error;
 
  
−
END
+
Para reducir os deadlocks resulta útil:
−
$$
 
−
</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.
+
:* acceder ás táboas e filas nunha orde consistente;
−
* Liña 11: Iniciamos a transacción.
+
:* manter as transaccións curtas;
−
* Liñas 20 e 21: Confirmamos a transacción se todo foi ben e enviamos un 0 como código ao PHP.
+
:* utilizar índices axeitados;
 +
:* bloquear unicamente os datos necesarios.
  
 +
<br />
  
−
* Comentar que se facemos as transaccións dende procedementos almacenados teremos que capturar as posibles excepcións que se produzan para facer o rollback.
+
== Transaccións en procedementos almacenados ==
−
: 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''']
+
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 <code>START TRANSACTION</code>.
 +
 +
MySQL non admite transaccións anidadas reais. Un novo <code>START TRANSACTION</code> pode finalizar implicitamente a transacción anterior.
  
 
<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" line enclose="div" highlight="" >
+
=== Alternativa A: transacción controlada por PDO ===
 +
 
 +
O procedemento almacenado non executa:
 +
 
 +
::<syntaxhighlight lang="sql" enclose="div">
 +
START TRANSACTION
 +
COMMIT
 +
ROLLBACK
 +
</syntaxhighlight>
 +
 
 +
PHP controla todo:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->beginTransaction();
 +
 
 +
try {
 +
 
 +
    // CALL procedemento 1
 +
    // CALL procedemento 2
 +
    // OUTRAS OPERACIÓNS
 +
 
 +
    $conex->commit();
 +
 
 +
} 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 />
 +
 
 +
=== Alternativa B: transacción controlada polo procedemento ===
 +
 
 +
O procedemento pode encapsular completamente a operación.
 +
 
 +
Exemplo esquemático (substituír <code>TABLE_1</code>, <code>TABLE_2</code> e as columnas polos nomes reais do esquema antes de executalo):
 +
 
 +
::<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>
 +
 +
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 é:
 +
 +
:# seleccionar un libro nun <code>&lt;select&gt;</code>;
 +
:# 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.
 +
 +
A operación final implica:
 +
 +
:# borrar as asociacións existentes do libro;
 +
:# inserir unha fila por cada autor seleccionado.
 +
 +
Estas operacións deben formar unha transacción. Para evitar actualizacións concorrentes perdidas cando dous usuarios modifican o mesmo libro, convén bloquear a fila do libro mediante <code>SELECT ... FOR UPDATE</code> ao comezo da transacción, antes de borrar as asociacións.
 +
  
 +
 +
 +
 +
<br />
 +
=== Arquivos ===
 +
 +
Empregaremos:
 +
 +
::<syntaxhighlight lang="text" enclose="div">
 +
alta_libro_autores.php
 +
procesar_alta_libro_autores.php
 
</syntaxhighlight>
 
</syntaxhighlight>
  
 +
<gallery caption="Libros-Autores" widths="350" heights="300px" perrow="2">
 +
Image:UD3_op_bd_trans_ex_1_A.jpg| Escollemos un libro.
 +
Image:UD3_op_bd_trans_ex_1_B.jpg| Amosamos os autores asociados e modificamos a selección.
 +
</gallery>
  
  
  
 
<br />
 
<br />
−
==Exercicio proposto 1==
 
  
−
* Asociar libros a autores.
+
* '''Arquivo bd.php'''
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
define(
 +
    'CONSULTAR_LIBROS',
 +
    'SELECT id, titulo
 +
    FROM libros
 +
    ORDER BY titulo'
 +
);
  
−
:* A idea é escoller dunha lista (<select>) un libro.
+
define(
−
:: 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.
+
    'CONSULTAR_AUTORES',
−
:* O usuario poderá checkear varios autores e serán asociados a dito libro.
+
    'SELECT id, apelidos, nome
−
:: Os autores xa asociados ao libro terán que estar chequeados.
+
    FROM autores
−
:* Ao premer o botón de ALTA, se asociarán ao libro seleccionado.
+
    ORDER BY apelidos, nome'
−
:: 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.
+
);
  
 +
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'
 +
);
  
−
:<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.
+
define(
 +
    'BLOQUEAR_LIBRO',
 +
    'SELECT id FROM libros WHERE id = :libro_id FOR UPDATE'
 +
);
  
−
* Para separar a lóxico, crea as seguintes páxinas:
+
define(
−
:* alta_libro_autores.php => Amosa o libro e os autores
+
    'INSERIR_LIBRO_AUTOR',
−
:* procesar_alta_libro_autores.php => Procesa a alta e volve a amosar alta_libroautores.php
+
    '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>
  
  
Línea 364: Línea 864:
  
 
<br />
 
<br />
−
* '''POSIBLE SOLUCIÓN''' (algunha das constantes xa foron creados en solucións anteriores)
 
  
 +
* '''Arrays enviados por formularios'''
 +
 +
Cada autor pode representarse cun checkbox:
 +
 +
::<syntaxhighlight lang="html" enclose="div">
 +
<input
 +
    type="checkbox"
 +
    name="autores[]"
 +
    value="10"
 +
>
 +
</syntaxhighlight>
 +
 +
Ao repetir:
 +
 +
::<syntaxhighlight lang="text" enclose="div">
 +
name="autores[]"
 +
</syntaxhighlight>
 +
 +
PHP recibirá os autores seleccionados como un array:
 +
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$_POST['autores']
 +
</syntaxhighlight>
  
−
'''Arquivo: bd.inc.php'''
+
Se o usuario non selecciona ningún autor, o campo pode non chegar na petición.
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
−
define ('SERVIDOR','localhost');
 
−
define ('USUARIO','user_php');
 
−
define ('PASSWORD','user_php');
 
−
define ('BD','php');
 
  
 +
Por iso empregaremos:
  
−
define('CONSULTAR_LIBROS','select id,titulo from libros order by titulo');
+
::<syntaxhighlight lang="php" enclose="div">
−
define('CONSULTAR_AUTORES','SELECT id,apelidos,nome FROM autores order by apelidos,nome');
+
$autoresRecibidos =
−
define('CONSULTAR_AUTORES_POR_LIBRO','SELECT id,apelidos,nome FROM autores INNER JOIN libro_autor WHERE libro_id=%d order by apelidos,nome');
+
    $_POST['autores'] ?? [];
−
define('INSERIR_LIBRO_AUTOR' 'INSERT INTO libro_autor (libro_id, autor_id) VALUES (%d,%d)');
+
</syntaxhighlight>
−
define('BORRAR_LIBRO_AUTOR_POR_LIBRO','DELETE FROM libro_autor WHERE libro_id=%d');
 
  
−
</syntaxhighlight>
+
Isto tamén permite eliminar todas as asociacións dun libro deixando todos os checkbox sen marcar.
  
  
  
−
'''Arquivo: alta_libro_autores.php'''
+
 
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
<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.inc.php');
+
try {
 +
 
 +
    require '/var/www/confBD/bd.php';
 +
 
 +
    $dsn = sprintf(
 +
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
 +
        SERVIDOR,
 +
        BD
 +
    );
 +
 
 +
    $conex = new PDO(
 +
        $dsn,
 +
        USUARIO,
 +
        PASSWORD,
 +
        [
 +
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
 +
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 +
            PDO::ATTR_EMULATE_PREPARES => false
 +
        ]
 +
    );
 +
 
 +
    /*
 +
    * Lista de libros.
 +
    */
 +
    $stmt = $conex->query(
 +
        CONSULTAR_LIBROS
 +
    );
 +
 
 +
    $libros = $stmt->fetchAll();
 +
 
 +
    $libroId = null;
 +
    $autores = [];
 +
    $autoresLibro = [];
 +
 
 +
    /*
 +
    * Se o usuario seleccionou un libro,
 +
    * cargamos todos os autores e as
 +
    * asociacións existentes.
 +
    */
 +
    if (isset($_POST['btnLibro'])) {
 +
 
 +
        $libroId = filter_input(
 +
            INPUT_POST,
 +
            'lstLibros',
 +
            FILTER_VALIDATE_INT,
 +
            [
 +
                'options' => [
 +
                    'min_range' => 1
 +
                ]
 +
            ]
 +
        );
 +
 
 +
        if (
 +
            $libroId === false ||
 +
            $libroId === null
 +
        ) {
 +
            throw new RuntimeException(
 +
                'O libro seleccionado non é válido.'
 +
            );
 +
        }
 +
 
 +
        $stmt = $conex->query(
 +
            CONSULTAR_AUTORES
 +
        );
 +
 
 +
        $autores = $stmt->fetchAll();
  
−
try {
+
        $stmt = $conex->prepare(
−
    $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,BD);  
+
            CONSULTAR_AUTORES_POR_LIBRO
−
    $conex->set_charset('utf8mb4');
+
        );
−
    $resultado = $conex->query(CONSULTAR_LIBROS);
+
 
 +
        $stmt->execute([
 +
            ':libro_id' => $libroId
 +
        ]);
  
−
    $listaLibros = [0 => ['id' => -1, 'titulo' => 'Escolle un libro']];
+
        foreach($datos as $dato){
−
    while($fila = $resultado->fetch_assoc()) {
+
            $autoresLibro[] = (int) $dato['id'];
−
         $listaLibros[] = $fila;
+
        }
 +
        /*  => OUTRA FORMA DE FACER O MESMO SEN EMPREGAR FOREACH
 +
        $autoresLibro = array_map(
 +
            'intval',
 +
            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>
 
  
−
        <form action="<?php echo htmlentities($_SERVER['PHP_SELF']) ?>" method="post">
+
    <h1>Asociar libros e autores</h1>
−
            <select name='lstLibros'>
 
−
                <?php
 
−
                    $libroSeleccionado=-1;
 
−
                    if(!empty($_POST['btnLibro'])) $libroSeleccionado = $_POST['lstLibros'];    // Vimos de seleccionar un libro
 
  
−
                    foreach ($listaLibros as $libro) {
+
    <form method="post">
−
                        $cadena = '';  // Para facer que o libro se manteña seleccionado ao premer no botón
+
 
−
                        if($libroSeleccionado == $libro['id']) $cadena = "selected='selected'";
+
        <label for="lstLibros">
−
                       
+
            Libro:
−
                        printf("<option value=%d %s>%s</option>",htmlentities($libro['id']),$cadena,htmlentities($libro['titulo']));
+
        </label>
−
                    }
+
 
 +
        <select
 +
            id="lstLibros"
 +
            name="lstLibros"
 +
            required
 +
        >
 +
 
 +
            <option value="">
 +
                Escolle un libro
 +
            </option>
 +
 
 +
            <?php foreach ($libros as $libro): ?>
 +
 
 +
                <?php
 +
                    $idLibro =
 +
                        (int) $libro['id'];
 
                 ?>
 
                 ?>
−
            </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,htmlentities($_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>
 +
 
 +
                        <td>
 +
                            <input
 +
                                type="checkbox"
 +
                                name="autores[]"
 +
                                value="<?= $autorId ?>"
 +
                                <?= in_array(
 +
                                    $autorId,
 +
                                    $autoresLibro,
 +
                                    true
 +
                                )
 +
                                    ? 'checked'
 +
                                    : '' ?>
 +
                            >
 +
                        </td>
 +
                    </tr>
  
−
        <form action="procesar_alta_libro_autores.php" method="post">
+
                 <?php endforeach; ?>
−
            <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);
 
−
                }
 
  
−
                 ?>
+
                 </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 if (isset($_GET['msg'])): ?>
 +
 +
        <p>
 +
            <?= htmlspecialchars(
 +
                $_GET['msg'],
 +
                ENT_QUOTES | ENT_SUBSTITUTE,
 +
                'UTF-8'
 +
            ) ?>
 +
        </p>
 +
 +
    <?php endif; ?>
 +
 +
    <?php if (isset($_GET['error'])): ?>
  
−
         <?php
+
         <p>
−
             }  // Fin do if seleccionar un libro
+
            <?= htmlspecialchars(
−
         ?>
+
                $_GET['error'],
 +
                ENT_QUOTES | ENT_SUBSTITUTE,
 +
                'UTF-8'
 +
             ) ?>
 +
         </p>
  
 +
    <?php endif; ?>
  
−
        <div>
+
</body>
−
            <?php
 
−
                if(isset($_GET['id'])) printf("<h3>Estado con id %d dado de alta correctamente.</h3>",htmlentities($_GET['id']));
 
−
                if(isset($_GET['error'])) printf("<h2>Houbo un erro: %s</h2>",htmlentities($_GET['error']));
 
−
            ?>
 
−
        </div>   
 
−
       
 
−
    </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) {
+
try {
−
     die("Erro mysql: " . $e->getMessage());
+
 
 +
    require '/var/www/confBD/bd.php';
 +
 
 +
    $dsn = sprintf(
 +
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
 +
        SERVIDOR,
 +
        BD
 +
    );
 +
 
 +
    $conex = new PDO(
 +
        $dsn,
 +
        USUARIO,
 +
        PASSWORD,
 +
        [
 +
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
 +
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 +
            PDO::ATTR_EMULATE_PREPARES => false
 +
        ]
 +
    );
 +
 
 +
    if (
 +
        !isset($_POST['btnAsociar']) ||
 +
        $_POST['btnAsociar'] !== 'ASOCIAR'
 +
    ) {
 +
        throw new RuntimeException(
 +
            'Operación non válida.'
 +
        );
 +
    }
 +
 
 +
    /*
 +
    * Validamos o libro.
 +
    */
 +
    $libroId = filter_input(
 +
        INPUT_POST,
 +
        'libro_id',
 +
        FILTER_VALIDATE_INT,
 +
        [
 +
            'options' => [
 +
                'min_range' => 1
 +
            ]
 +
        ]
 +
    );
 +
 
 +
    if (
 +
        $libroId === false ||
 +
        $libroId === null
 +
    ) {
 +
        throw new RuntimeException(
 +
            'O libro non é válido.'
 +
        );
 +
    }
 +
 
 +
    /*
 +
    * Pode chegar un array baleiro
 +
    * se non hai ningún autor seleccionado.
 +
    */
 +
    $autoresRecibidos =
 +
        $_POST['autores'] ?? [];
 +
 
 +
    if (!is_array($autoresRecibidos)) {
 +
        throw new RuntimeException(
 +
            'Os autores non teñen un formato válido.'
 +
        );
 +
    }
 +
 
 +
    /*
 +
    * Validamos todos os identificadores
 +
    * ANTES de iniciar a transacción.
 +
    */
 +
    $autores = [];
 +
 
 +
    foreach ($autoresRecibidos as $autor) {
 +
 
 +
        $autorId = filter_var(
 +
            $autor,
 +
            FILTER_VALIDATE_INT,
 +
            [
 +
                'options' => [
 +
                    'min_range' => 1
 +
                ]
 +
            ]
 +
        );
 +
 
 +
        if ($autorId === false) {
 +
            throw new RuntimeException(
 +
                'Existe un autor non válido.'
 +
            );
 +
        }
 +
 
 +
        $autores[] = $autorId;
 +
    }
 +
 
 +
    /*
 +
    * Evitamos repetidos recibidos
 +
    * nunha petición manipulada.
 +
    */
 +
    $autores = array_values(
 +
        array_unique($autores)
 +
    );
 +
 
 +
    /*
 +
    * INICIO DA TRANSACCIÓN.
 +
    */
 +
    $conex->beginTransaction();
 +
 
 +
    // Bloqueamos o libro para serializar os cambios concorrentes
 +
    // sobre as súas asociacións.
 +
    $stmtBloqueo = $conex->prepare(BLOQUEAR_LIBRO);
 +
    $stmtBloqueo->execute([':libro_id' => $libroId]);
 +
    if ($stmtBloqueo->fetch() === false) {
 +
        throw new RuntimeException('O libro xa non existe.');
 +
    }
 +
    $stmtBloqueo->closeCursor();
 +
 
 +
    /*
 +
    * Eliminamos as asociacións anteriores.
 +
    */
 +
    $stmtBorrar = $conex->prepare(
 +
        BORRAR_LIBRO_AUTOR_POR_LIBRO
 +
    );
 +
 
 +
    $stmtBorrar->execute([
 +
        ':libro_id' => $libroId
 +
    ]);
 +
 
 +
    /*
 +
    * Preparamos unha única vez o INSERT.
 +
    */
 +
    $stmtInserir = $conex->prepare(
 +
        INSERIR_LIBRO_AUTOR
 +
    );
 +
 
 +
    foreach ($autores as $autorId) {
 +
 
 +
        $stmtInserir->execute([
 +
            ':libro_id' => $libroId,
 +
            ':autor_id' => $autorId
 +
        ]);
 +
    }
 +
 
 +
    /*
 +
    * Se chegamos aquí, todas as operacións
 +
    * se realizaron correctamente.
 +
    */
 +
    $conex->commit();
 +
 
 +
    header(
 +
        'Location: alta_libro_autores.php?msg=' .
 +
        urlencode(
 +
            'As asociacións foron actualizadas correctamente.'
 +
        )
 +
    );
 +
    exit;
 +
 
 +
} catch (PDOException $e) {
 +
 
 +
    if (
 +
        isset($conex) &&
 +
        $conex->inTransaction()
 +
    ) {
 +
        $conex->rollBack();
 +
    }
 +
 
 +
     error_log($e->getMessage());
 +
 
 +
    header(
 +
        'Location: alta_libro_autores.php?error=' .
 +
        urlencode('Erro relacionado coa base de datos.')
 +
    );
 +
    exit;
 +
 
 +
} catch (Throwable $e) {
 +
 
 +
    if (
 +
        isset($conex) &&
 +
        $conex->inTransaction()
 +
    ) {
 +
        $conex->rollBack();
 +
    }
 +
 
 +
    error_log($e->getMessage());
 +
 
 +
    header(
 +
        'Location: alta_libro_autores.php?error=' .
 +
        urlencode('Produciuse un erro na aplicación.')
 +
    );
 +
    exit;
 
}
 
}
−
catch (Expcetion $e) {
+
</syntaxhighlight>
−
     die("Erro xeral: " . $e->getMessage());
+
 
 +
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 />
 +
 
 +
== Exercicio proposto 2: alta dun libro e varios exemplares ==
 +
 
 +
Queremos crear unha páxina que permita:
 +
 
 +
:# 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 />
 +
 
 +
=== 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.
 +
 
 +
<br />
 +
 
 +
=== Consultas necesarias ===
 +
 
 +
No arquivo <code>bd.php</code>:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
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
 +
    )'
 +
);
 +
</syntaxhighlight>
 +
 
 +
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.
 +
 
 +
<br />
 +
 
 +
=== Requisitos previos do esquema ===
 +
 
 +
Os exemplos deste exercicio supoñen que existen as táboas <code>libros</code>, <code>exemplares</code> e <code>estados</code>, cos nomes de columnas indicados nas consultas. Deben comprobarse as claves primarias, alleas e as restricións <code>NOT NULL</code> da base real antes de executalos. Os nomes das táboas e os rangos de validación son os deste exercicio, non unha definición universal.
 +
 
 +
=== Validación dos prezos ===
 +
 
 +
Un valor decimal procedente dun formulario chega a PHP como texto.
 +
 
 +
Podemos normalizar unha coma decimal a punto:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$prezoTexto = str_replace(
 +
    ',',
 +
    '.',
 +
    trim($prezoTexto)
 +
);
 +
</syntaxhighlight>
 +
 
 +
e validalo:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$prezo = filter_var(
 +
    $prezoTexto,
 +
    FILTER_VALIDATE_FLOAT
 +
);
 +
 
 +
if (
 +
    $prezo === false ||
 +
    $prezo <= 0
 +
) {
 +
    // Prezo non válido.
 
}
 
}
−
finally{
+
</syntaxhighlight>
−
  if(!empty($conex)){
+
 
−
      $conex->close();
+
PDO non dispón de <code>PDO::PARAM_FLOAT</code>.
−
  }
+
 
 +
Por iso, unha vez validado o decimal, podemos envialo como cadea:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$stmt->bindValue(
 +
    ':prezo',
 +
    (string) $prezo,
 +
    PDO::PARAM_STR
 +
);
 +
</syntaxhighlight>
 +
 
 +
A columna MySQL correspondente seguirá sendo de tipo numérico, por exemplo <code>DECIMAL(6,2)</code>. Para importes monetarios reais é preferible validar a precisión decimal directamente sobre o texto (por exemplo, cun máximo de dúas cifras decimais), en vez de depender dun cálculo en coma flotante.
 +
 
 +
<br />
 +
 
 +
=== Non sanitizamos a imaxe para gardala ===
 +
 
 +
Se neste exercicio o campo <code>imaxe</code> contén un nome ou ruta textual, validaremos a súa lonxitude e formato.
 +
 
 +
Por exemplo:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$imaxe = trim(
 +
    $imaxeRecibida
 +
);
 +
 
 +
if (
 +
    $imaxe === '' ||
 +
    mb_strlen($imaxe, 'UTF-8') > 100
 +
) {
 +
    // Dato non válido.
 
}
 
}
−
?>  
+
</syntaxhighlight>
−
</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 />
  
 +
=== Arquivos do exercicio ===
  
 +
Empregaremos a mesma separación entre formulario e procesamento que nos exercicios CRUD anteriores:
  
−
'''Arquivo: procesar_alta_libro_autores.php'''
+
::<syntaxhighlight lang="text" enclose="div">
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
alta_libro_exemplares.php
 +
campos_exemplares.php
 +
procesar_alta_libro_exemplares.php
 +
</syntaxhighlight>
 +
 
 +
O primeiro arquivo recibe o título e o número de exemplares. Nunha segunda petición mostra os campos correspondentes. <code>campos_exemplares.php</code> é unha vista reutilizable, e <code>procesar_alta_libro_exemplares.php</code> executa a transacción.
 +
 
 +
==== Arquivo alta_libro_exemplares.php ====
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 
<?php
 
<?php
−
/* Lembrar quitar isto ao probar o scripts sen erros */
+
$numExemplares = null;
−
ini_set('display_errors', 1);
+
$titulo = '';
−
ini_set('display_startup_errors', 1);
+
$estados = [];
−
error_reporting(E_ALL);
+
$error = '';
 +
 
 +
try {
 +
    if (isset($_POST['btnContinuar'])) {
 +
        $titulo = trim($_POST['txtTitulo'] ?? '');
 +
        $numExemplares = filter_input(
 +
            INPUT_POST, 'numExemplares', FILTER_VALIDATE_INT,
 +
            ['options' => ['min_range' => 1, 'max_range' => 9]]
 +
        );
 +
 
 +
        if ($titulo === '' || mb_strlen($titulo, 'UTF-8') > 300 ||
 +
            $numExemplares === false || $numExemplares === null) {
 +
            throw new InvalidArgumentException('O título ou o número de exemplares non son válidos.');
 +
        }
 +
 
 +
        require '/var/www/confBD/bd.php';
 +
        $dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', SERVIDOR, BD);
 +
        $conex = new PDO($dsn, USUARIO, PASSWORD, [
 +
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
 +
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 +
            PDO::ATTR_EMULATE_PREPARES => false
 +
        ]);
 +
        $estados = $conex->query(CONSULTAR_ESTADOS)->fetchAll();
 +
 
 +
        if ($estados === []) {
 +
            throw new RuntimeException('Non hai estados dispoñibles.');
 +
        }
 +
    }
 +
} catch (InvalidArgumentException $e) {
 +
    $error = $e->getMessage();
 +
    $numExemplares = null;
 +
} catch (Throwable $e) {
 +
    error_log($e->getMessage());
 +
    $error = 'Non foi posible preparar o formulario.';
 +
    $numExemplares = null;
 +
}
 +
?>
 +
<!DOCTYPE html>
 +
<html lang="gl">
 +
<head>
 +
    <meta charset="UTF-8">
 +
    <title>Alta de libro e exemplares</title>
 +
</head>
 +
<body>
 +
<h1>Alta de libro e exemplares</h1>
 +
<?php if ($error !== ''): ?>
 +
    <p><?= htmlspecialchars($error, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
 +
<?php endif; ?>
 +
<?php if (isset($_GET['msg'])): ?>
 +
    <p><?= htmlspecialchars((string) $_GET['msg'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
 +
<?php endif; ?>
 +
<?php if (isset($_GET['error'])): ?>
 +
    <p><?= htmlspecialchars((string) $_GET['error'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?></p>
 +
<?php endif; ?>
 +
 
 +
<?php if ($numExemplares === null): ?>
 +
    <form method="post">
 +
        <label for="txtTitulo">Título:</label>
 +
        <input type="text" id="txtTitulo" name="txtTitulo" maxlength="300" required>
 +
        <label for="numExemplares">Número de exemplares (1-9):</label>
 +
        <input type="number" id="numExemplares" name="numExemplares" min="1" max="9" required>
 +
        <button type="submit" name="btnContinuar" value="CONTINUAR">Continuar</button>
 +
    </form>
 +
<?php else: ?>
 +
    <form action="procesar_alta_libro_exemplares.php" method="post">
 +
        <input type="hidden" name="txtTitulo"
 +
              value="<?= htmlspecialchars($titulo, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>">
 +
        <?php require 'campos_exemplares.php'; ?>
 +
        <button type="submit" name="btnAlta" value="ALTA">Dar de alta</button>
 +
    </form>
 +
<?php endif; ?>
 +
</body>
 +
</html>
 +
</syntaxhighlight>
 +
 
 +
==== Arquivo campos_exemplares.php ====
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
<table>
 +
    <thead><tr><th>Exemplar</th><th>Estado</th><th>Imaxe (nome)</th><th>Prezo</th></tr></thead>
 +
    <tbody>
 +
    <?php for ($i = 0; $i < $numExemplares; $i++): ?>
 +
        <tr>
 +
            <td><?= $i + 1 ?></td>
 +
            <td>
 +
                <select name="lstEstado[]" required>
 +
                    <?php foreach ($estados as $estado): ?>
 +
                        <option value="<?= (int) $estado['id'] ?>">
 +
                            <?= htmlspecialchars($estado['descripcion'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>
 +
                        </option>
 +
                    <?php endforeach; ?>
 +
                </select>
 +
            </td>
 +
            <td><input type="text" name="txtImaxe[]" maxlength="100" required></td>
 +
            <td><input type="number" name="txtPrezo[]" min="0.01" step="0.01" required></td>
 +
        </tr>
 +
    <?php endfor; ?>
 +
    </tbody>
 +
</table>
 +
</syntaxhighlight>
 +
 
 +
=== Procesamento transaccional do libro e os exemplares ===
 +
 
 +
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.
  
−
require ('/var/www/confBD/bd.inc.php');
+
<br />
 +
'''Arquivo: procesar_alta_libro_exemplares.php'''
 +
::<syntaxhighlight lang="php" enclose="div">
 +
<?php
  
−
$error="";  // Variable que a usaremos para amosar os posibles erros que poidan darse
 
−
$msg='';    // Variable empregada para enviar o novo id xerado
 
 
try {
 
try {
−
    $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,BD); 
 
−
    $conex->set_charset('utf8mb4');
 
−
    $conex->autocommit(false);
 
−
    // 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){
+
    require '/var/www/confBD/bd.php';
−
             $query = sprintf(INSERIR_LIBRO_AUTOR,$libro_id,$conex->real_escape_string($autor));
+
 
−
             $conex->query($query);
+
    $dsn = sprintf(
 +
        'mysql:host=%s;dbname=%s;charset=utf8mb4',
 +
         SERVIDOR,
 +
        BD
 +
    );
 +
 
 +
    $conex = new PDO(
 +
        $dsn,
 +
        USUARIO,
 +
        PASSWORD,
 +
        [
 +
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
 +
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
 +
            PDO::ATTR_EMULATE_PREPARES => false
 +
        ]
 +
    );
 +
 
 +
    if (
 +
        !isset($_POST['btnAlta']) ||
 +
        $_POST['btnAlta'] !== 'ALTA'
 +
    ) {
 +
        throw new RuntimeException(
 +
            'Operación non válida.'
 +
        );
 +
    }
 +
 
 +
    /*
 +
    * Validamos o título.
 +
    */
 +
    $titulo = trim(
 +
        $_POST['txtTitulo'] ?? ''
 +
    );
 +
 
 +
    if (
 +
        $titulo === '' ||
 +
        mb_strlen($titulo, 'UTF-8') > 300
 +
    ) {
 +
        throw new RuntimeException(
 +
            'O título non é válido.'
 +
        );
 +
    }
 +
 
 +
    /*
 +
    * Recuperamos os arrays.
 +
    */
 +
    $estadosRecibidos =
 +
        $_POST['lstEstado'] ?? [];
 +
 
 +
    $imaxesRecibidas =
 +
        $_POST['txtImaxe'] ?? [];
 +
 
 +
    $prezosRecibidos =
 +
        $_POST['txtPrezo'] ?? [];
 +
 
 +
    if (
 +
        !is_array($estadosRecibidos) ||
 +
        !is_array($imaxesRecibidas) ||
 +
        !is_array($prezosRecibidos)
 +
    ) {
 +
        throw new RuntimeException(
 +
            'Os datos dos exemplares non son válidos.'
 +
        );
 +
    }
 +
 
 +
    $numExemplares =
 +
        count($estadosRecibidos);
 +
 
 +
    if (
 +
        $numExemplares < 1 ||
 +
        $numExemplares > 9 ||
 +
        count($imaxesRecibidas) !==
 +
            $numExemplares ||
 +
        count($prezosRecibidos) !==
 +
            $numExemplares
 +
    ) {
 +
        throw new RuntimeException(
 +
             'O número de exemplares non é válido.'
 +
        );
 +
    }
 +
 
 +
    // Os tres arrays deben ter índices consecutivos desde cero.
 +
    if (array_keys($estadosRecibidos) !== range(0, $numExemplares - 1) ||
 +
        array_keys($imaxesRecibidas) !== range(0, $numExemplares - 1) ||
 +
        array_keys($prezosRecibidos) !== range(0, $numExemplares - 1)) {
 +
        throw new RuntimeException('A estrutura dos exemplares non é válida.');
 +
    }
 +
 
 +
    /*
 +
    * VALIDAMOS TODO ANTES DA TRANSACCIÓN.
 +
    */
 +
    $exemplares = [];
 +
 
 +
    for (
 +
        $indice = 0;
 +
        $indice < $numExemplares;
 +
        $indice++
 +
    ) {
 +
 
 +
        $estadoId = filter_var(
 +
            $estadosRecibidos[$indice],
 +
            FILTER_VALIDATE_INT,
 +
            [
 +
                'options' => [
 +
                    'min_range' => 1
 +
                ]
 +
            ]
 +
        );
 +
 
 +
        if ($estadoId === false) {
 +
            throw new RuntimeException(
 +
                'Existe un estado non válido.'
 +
            );
 +
        }
 +
 
 +
        $imaxe = trim(
 +
            $imaxesRecibidas[$indice]
 +
        );
 +
 
 +
        if (
 +
             $imaxe === '' ||
 +
            mb_strlen(
 +
                $imaxe,
 +
                'UTF-8'
 +
            ) > 100
 +
        ) {
 +
            throw new RuntimeException(
 +
                'Existe un nome de imaxe non válido.'
 +
            );
 +
        }
 +
 
 +
        $prezoTexto = str_replace(
 +
            ',',
 +
            '.',
 +
            trim(
 +
                $prezosRecibidos[$indice]
 +
            )
 +
        );
 +
 
 +
        $prezo = filter_var(
 +
            $prezoTexto,
 +
            FILTER_VALIDATE_FLOAT
 +
        );
 +
 
 +
        if (
 +
            $prezo === false ||
 +
            $prezo <= 0
 +
        ) {
 +
            throw new RuntimeException(
 +
                'Existe un prezo non válido.'
 +
            );
 
         }
 
         }
 +
 +
        $exemplares[] = [
 +
            'estado_id' => $estadoId,
 +
            'imaxe' => $imaxe,
 +
            'prezo' => $prezo
 +
        ];
 +
    }
 +
 +
    /*
 +
    * A PARTIR DE AQUÍ EMPEZA
 +
    * A TRANSACCIÓN.
 +
    */
 +
    $conex->beginTransaction();
 +
 +
    /*
 +
    * Inserimos o libro.
 +
    */
 +
    $stmtLibro = $conex->prepare(
 +
        INSERIR_LIBRO
 +
    );
 +
 +
    $stmtLibro->execute([
 +
        ':titulo' => $titulo
 +
    ]);
 +
 +
    $libroId =
 +
        (int) $conex->lastInsertId();
 +
 +
    /*
 +
    * Preparamos unha única vez
 +
    * o INSERT dos exemplares.
 +
    */
 +
    $stmtExemplar = $conex->prepare(
 +
        INSERIR_EXEMPLAR
 +
    );
 +
 +
    foreach (
 +
        $exemplares as $indice => $exemplar
 +
    ) {
 +
 +
        $stmtExemplar->bindValue(
 +
            ':libro_id',
 +
            $libroId,
 +
            PDO::PARAM_INT
 +
        );
 +
 +
        $stmtExemplar->bindValue(
 +
            ':id_exemplar',
 +
            $indice + 1,
 +
            PDO::PARAM_INT
 +
        );
 +
 +
        $stmtExemplar->bindValue(
 +
            ':imaxe',
 +
            $exemplar['imaxe'],
 +
            PDO::PARAM_STR
 +
        );
 +
 +
        $stmtExemplar->bindValue(
 +
            ':prezo',
 +
            (string) $exemplar['prezo'],
 +
            PDO::PARAM_STR
 +
        );
 +
 +
        $stmtExemplar->bindValue(
 +
            ':estado_id',
 +
            $exemplar['estado_id'],
 +
            PDO::PARAM_INT
 +
        );
 +
 +
        $stmtExemplar->execute();
 
     }
 
     }
 +
 +
    /*
 +
    * Todo foi correcto.
 +
    */
 
     $conex->commit();
 
     $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_exemplares.php?msg=' .
−
     $error=sprintf("error='Erro mysql: %s'",htmlentities($e->getMessage()));
+
        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;
 
}
 
}
−
catch (Expcetion $e) {
+
</syntaxhighlight>
−
    if(!empty($conex)) $conex->rollback();
+
 
−
    $error=sprintf("error='Erro xeral: %s'",htmlentities($e->getMessage()));
+
O ficheiro que contén este bloque debe chamarse <code>procesar_alta_libro_exemplares.php</code>, tal como indica o atributo <code>action</code> do formulario. Neste exemplo unha excepción producida ao inserir calquera exemplar provoca o salto ao <code>catch</code> e o posterior <code>rollBack()</code>.
−
}
+
 
−
finally{
+
Polo tanto, non debemos escribir lóxica do tipo:
−
    if(!empty($conex)){
+
 
−
        $conex->autocommit(true);
+
::<syntaxhighlight lang="php" enclose="div">
−
        $conex->close();
+
if (!$stmt->execute()) {
−
     }
+
    $error = '...';
 +
     break;
 
}
 
}
  
−
$url = sprintf("alta_libro_autores.php?%s",$error);
+
$conex->commit();
−
header("Location:" . $url);
+
</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 />
 +
 
 +
 
 +
=== Formulario para os exemplares ===
 +
 
 +
Na segunda fase do formulario de <code>alta_libro_exemplares.php</code> solicítanse os datos de cada exemplar.
 +
 
 +
Para xerar o formulario empréganse dous bucles:
 +
 
 +
:* Un bucle <code>for</code> que crea unha fila por cada exemplar indicado polo usuario.
 +
:* Un bucle <code>foreach</code> que percorre os estados dispoñibles para completar o elemento <code>&lt;select&gt;</code> de cada exemplar.
  
−
?>      
+
A lista de estados recupérase da base de datos unha única vez mediante <code>CONSULTAR_ESTADOS</code> e almacénase no array <code>$estados</code>.
  
−
</syntaxhighlight>
+
Deste xeito, reutilizamos os resultados da consulta para todos os exemplares, evitando realizar unha consulta SQL por cada fila do formulario.
  
 +
Os campos empregan a notación <code>[]</code>:
  
 +
:* <code>lstEstado[]</code>
 +
:* <code>txtImaxe[]</code>
 +
:* <code>txtPrezo[]</code>
  
 +
Isto permite recibir os datos como arrays en PHP. Os elementos que ocupan a mesma posición nos tres arrays corresponden ao mesmo exemplar.
  
  
 
<br />
 
<br />
−
==Exercicio proposto 2: Transaccións en MYSQL dentro de procedementos almacenados==
 
  
−
* Imos crear unha páxina de nome '''alta_libro_exemplares.php'''.
+
== Alternativa: procedemento almacenado para libro e exemplares ==
−
: 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.
+
Se queremos que a transacción sexa controlada por MySQL, o ideal é que a unidade de traballo completa estea encapsulada no procedemento.
−
: Os datos anteriores (título e número de exemplares) non se poderán cambiar.
+
 
 +
Para un número variable de exemplares isto require deseñar como se enviarán todos os datos ao servidor.
  
−
* Os campos do formulario serán dos seguintes tipo:
+
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.
−
:* Estado: Será un combobox (select en html) cos estados da táboa estados.
 
−
:* Imaxe: cambiaremos isto máis adiante. Por agora deixade un campo de texto.
 
−
:* Prezo: Prezo da imaxe.
 
  
−
* Como sempre deberedes validar os datos e que teñan o tipo/formato correcto.
+
Por iso temos dúas opcións:
−
* No segundo formulario aparecerá un botón de 'Alta' e outro de 'Cancelar'.
 
−
:* 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.
 
  
 +
:* 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.
  
 
<br />
 
<br />
−
* '''POSIBLE SOLUCIÓN''' (algúns dos procedementos xa foron creados en solucións anteriores)
 
  
−
'''MYSQL'''
+
=== Exemplo sinxelo de transacción dentro dun procedemento ===
  
−
'''Procedemento: CONSULTAR_ESTADOS'''
+
Para un caso cun número fixo de operacións:
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
 
 +
::<syntaxhighlight lang="sql" enclose="div">
 
DELIMITER $$
 
DELIMITER $$
−
CREATE DEFINER=`root`@`localhost` PROCEDURE `CONSULTAR_ESTADOS`()
+
 
 +
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
−
SELECT id,descripcion
 
−
FROM estados
 
−
ORDER BY descripcion;
 
  
−
END$$
+
    DECLARE v_libro_id INT;
−
DELIMITER ;
 
−
</syntaxhighlight>
 
  
 +
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
 +
    BEGIN
 +
        ROLLBACK;
 +
        RESIGNAL;
 +
    END;
  
 +
    START TRANSACTION;
  
 +
    INSERT INTO libros (titulo)
 +
    VALUES (p_titulo);
  
−
'''Procedemento: INSERIR_LIBRO'''
+
    SET v_libro_id =
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
+
        LAST_INSERT_ID();
−
DELIMITER $$
 
  
−
CREATE DEFINER=`root`@`localhost` PROCEDURE `INSERIR_LIBRO`(p_titulo varchar(300))
+
    INSERT INTO exemplares (
−
etiqueta:BEGIN  -- Asociamos unha etiqueta ao código do procedemento para que poidamos facer algo parecido a un exit
+
        libro_id,
−
    declare existe int;
+
        id_exemplar,
−
     
+
        imaxe,
−
SELECT count(*)
+
        prezo,
−
INTO existe
+
        estado_id
−
FROM libros
+
    )
−
WHERE upper(titulo)=upper(p_titulo);
+
    VALUES (
−
+
        v_libro_id,
−
IF existe=1 THEN
+
        1,
−
begin
+
        p_imaxe,
−
  SELECT -1 as valor_devolto;
+
        p_prezo,
−
  leave etiqueta; -- Para que saia do procedemento
+
        p_estado_id
−
end;
+
    );
−
        END IF; -- Fin do IF
 
  
−
INSERT INTO libros (titulo)
+
    COMMIT;
−
VALUES (p_titulo);
 
  
−
SELECT last_insert_id() as valor_devolto;
 
 
END$$
 
END$$
 +
 
DELIMITER ;
 
DELIMITER ;
−
</syntaxhighlight>  
+
</syntaxhighlight>
  
 +
Neste caso PHP simplemente chama ao procedemento e non inicia outra transacción.
  
 +
<br />
  
 +
== mysqli como ampliación ==
  
−
'''Procedemento: INSERIR_EXEMPLAR'''
+
PDO será o mecanismo principal empregado nos apuntes.
−
::<syntaxhighlight lang="java" enclose="div" highlight="" >
 
−
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)
 
−
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$$
+
Con <code>mysqli</code> o patrón equivalente é:
−
DELIMITER ;
 
−
</syntaxhighlight>  
 
  
 +
::<syntaxhighlight lang="php" enclose="div">
 +
<?php
  
 +
try {
  
 +
    require '/var/www/confBD/bd.php';
  
−
'''Arquivo bd.inc.php''':
+
    mysqli_report(
−
::<syntaxhighlight lang="java" line enclose="div" highlight="" >
+
        MYSQLI_REPORT_ERROR |
−
define('PROCEDEMENTO_INSERIR_LIBRO',"INSERIR_LIBRO('%s')");
+
        MYSQLI_REPORT_STRICT
−
define('PROCEDEMENTO_INSERIR_EXEMPLAR',"INSERIR_EXEMPLAR(%d,%d,'%s',%f,%d)");
+
    );
−
define('PROCEDEMENTO_CONSULTAR_ESTADOS','CONSULTAR_ESTADOS()');
 
−
</syntaxhighlight>
 
  
 +
    $conex = new mysqli(
 +
        SERVIDOR,
 +
        USUARIO,
 +
        PASSWORD,
 +
        BD
 +
    );
  
 +
    $conex->set_charset(
 +
        'utf8mb4'
 +
    );
  
 +
    $conex->begin_transaction();
  
−
<br />
+
    /*
−
'''Arquivo alta_libro_exemplares.php'''
+
    * Operacións preparadas.
−
::<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.inc.php');
+
    $conex->commit();
  
−
$error="";  // Variable que a usaremos para amosar os posibles erros que poidan darse
+
} catch (mysqli_sql_exception $e) {
−
$msg='';    // Variable empregada para enviar o novo id xerado
 
  
−
// *********************************ALTA ************************************************************************//
+
    if (isset($conex)) {
−
if (!empty($_POST['btnAlta'])){ // DAMOS DE ALTA A EXAMPLARES E O LIBRO
 
−
    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
+
            $conex->rollback();
−
         if (!filter_var($estado,FILTER_VALIDATE_INT) || $estado<0){
+
         } catch (Throwable) {
−
             $error .= "O estado é incorrecto...";
+
             // Non ocultamos a excepción orixinal.
−
            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;
+
    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.
 
         }
 
         }
−
        $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...";
+
    error_log($e->getMessage());
−
            break;
+
 
−
        }
+
    die('Produciuse un erro na aplicación.');
−
        // Gardamos os datos validados para dalos de alta todos xuntos
+
}
−
        $dato['prezo'] = $prezo;
+
</syntaxhighlight>
−
        $dato['imaxe'] = $imaxe;
+
 
−
        $dato['estado'] = $estado;
+
<br />
−
        $datos[] = $dato;
+
 
−
     }  // Fin do foreach
+
=== Exemplo mysqli con consultas preparadas ===
−
   
+
 
−
   
+
::<syntaxhighlight lang="php" enclose="div">
−
    if(empty($error)){ // Non houbo error, empezamos a operación de alta con transaccións
+
$conex->begin_transaction();
−
         try{
+
 
 +
$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'];
  
−
            $conex = new mysqli(SERVIDOR, USUARIO, PASSWORD,BD); 
+
    $stmtLina->bind_param(
−
            $conex->set_charset('utf8mb4');
+
        'iii',
−
           
+
        $pedidoId,
−
            $conex->autocommit(false);  // Iniciamos a transcción
+
        $produtoId,
−
           
+
        $cantidade
−
            // 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();
+
     $stmtLina->execute();
−
      
 
−
        }  // 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) {
 
−
            if(!empt($conex)) $conex->rollback();
 
−
            $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->commit();
−
?>
+
</syntaxhighlight>
 +
 
 +
Non é necesario empregar o patrón antigo:
 +
 
 +
::<syntaxhighlight lang="php" enclose="div">
 +
$conex->autocommit(false);
 +
 
 +
// ...
 +
 
 +
$conex->autocommit(true);
 +
</syntaxhighlight>
 +
 
 +
como forma principal de explicar as transaccións.
  
 +
Para código novo resulta máis claro iniciar explicitamente a unidade de traballo con:
  
−
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
+
::<syntaxhighlight lang="php" enclose="div">
−
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+
$conex->begin_transaction();
−
<html xmlns="http://www.w3.org/1999/xhtml" lang="es" xml:lang="es">
+
</syntaxhighlight>
−
    <head>
 
−
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
 
−
        <title>Transaccións</title>
 
−
    </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
 
−
            $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>
 
  
−
   
+
::<syntaxhighlight lang="php" enclose="div">
−
       
+
$conex->commit();
−
        <?php  
+
</syntaxhighlight>
−
        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 {
+
ou:
−
                $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']));
+
::<syntaxhighlight lang="php" enclose="div">
−
            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>");
+
</syntaxhighlight>
−
            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>");
 
−
            printf("<input type='submit' value='ALTA' name='btnAlta' />");   
 
−
            printf("<input type='reset' value='CANCELAR' name='btnCancelar' />");   
 
−
            printf("</form>");
 
−
           
 
−
        }
 
−
        ?>
 
−
        <?php
 
−
            if (!empty($error)) {  // En caso de atopar erros os amosa
 
−
                printf("<h4>$error</h4>");
 
−
            }
 
−
        ?>
 
  
−
    </body>   
+
<br />
−
</html>
 
  
 +
== Exercicios propostos ==
  
−
</syntaxhighlight>
+
=== 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 <code>ROLLBACK</code> 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.
  
  
 +
Indicar que na entrada da wiki de acceso a datos tedes como [https://wiki.cifprodolfoucha.es/index.php?title=PHP_Paso_de_datos_con_formularios#Gardando_imaxes_no_servidor subir unha imaxe ao servidor].
 
<br />
 
<br />
−
* '''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.
 
  
 +
=== Asociar autores durante a alta do libro ===
  
 +
Ampliar o exercicio de alta dun libro para permitir seleccionar tamén os autores.
  
−
* '''Exercicio proposto'''
+
A operación completa podería incluír:
−
: 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.
 
  
 +
:# 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>.
  
 +
Se falla calquera operación deberá realizarse <code>ROLLBACK</code>.
  
 +
<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]] -- (2023).
+
<br> -- [[Usuario:angelfg|Ángel D. Fernández González]] -- (2026).

Revisión actual del 11:39 8 oct 2026

Sumario

Introdución

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

Por exemplo:

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

Nestes casos normalmente queremos que se cumpra unha regra:

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

Para conseguir este comportamento empregamos unha transacción.


Que é unha transacción?

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

O esquema básico é:

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

Por exemplo:

INICIO

    INSERT libro
    INSERT exemplar 1
    INSERT exemplar 2
    INSERT exemplar 3

SE TODO FOI CORRECTO
    COMMIT
SE HOUBO UN ERRO
    ROLLBACK

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


Propiedades ACID

As transaccións adoitan explicarse mediante as propiedades ACID:

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


Autocommit

Normalmente MySQL traballa con autocommit activado.

Isto significa que unha orde individual como:

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

queda confirmada automaticamente cando remata correctamente.

Cando iniciamos explicitamente unha transacción con PDO:

$conex->beginTransaction();

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

Os cambios non se confirman ata executar:

$conex->commit();

Se executamos:

$conex->rollBack();

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

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


Requirimentos e consideracións

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

En MySQL empregaremos normalmente:

InnoDB

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


Nos exemplos desta unidade empregaremos:

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



  • CONSIDERACIÓNS:
  • Crear un usuario/password con permisos de acceso a dita base de datos (asignade unicamente os permisos imprescindibles: SELECT, INSERT, UPDATE e DELETE sobre as táboas necesarias; EXECUTE só se se empregan procedementos almacenados)
  • Crear nun arquivo separado do sitio web todos os datos necesarios para conectarse á base de datos e onde van estar definidas as constantes coas ordes SQL, tal como vimos nun punto anterior desta sección.
  • Lembrar poñer as ordes para amosar os erros que se poidan dar. Nunha aplicación final esas ordes hai que quitalas.
  • En Linux, os ficheiros PHP públicos poden ter permisos 644 e os directorios 755. O ficheiro de credenciais situado fóra da raíz web debe ter permisos máis restritivos (por exemplo, 640) e pertencer a un usuario/grupo axeitado, sen conceder escritura ao servidor web salvo que sexa imprescindible.
  • Ao principio ides faiar en moitas cousas. Lembrade ter posto o display_errors como vimos nas seccións anteriores. Para depurar empregade mensaxes e rexistros sen imprimir credenciais nin datos sensibles. Nas consultas preparadas non se debe construír a SQL concatenando valores do formulario.
  • Lembrar que tamén podedes activar o debugger como está indicado nesta sección da wiki.





Preparación de MySQL 8

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

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

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

A aplicación debe traballar con utf8mb4 e un usuario con privilexios mínimos. Non deben incluírse contrasinais reais nos arquivos públicos da wiki.

Arquivo de configuración

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

/var/www/confBD/bd.php

Por exemplo:

<?php

define('SERVIDOR', 'localhost');
define('USUARIO', 'user_php');
define('PASSWORD', 'user_php');
define('BD', 'php'); // Substituír polo nome real da base de datos de prácticas.


Conexión PDO empregada nos exemplos

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

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

Ao empregar:

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

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


Funcionamento dunha transacción con PDO

O patrón básico será:

try {

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

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

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

    $conex->beginTransaction();

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

    $conex->commit();

} catch (PDOException $e) {

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

    error_log($e->getMessage());

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

} catch (Throwable $e) {

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

    error_log($e->getMessage());

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


beginTransaction()

Para iniciar a transacción empregamos:

$conex->beginTransaction();

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


commit()

Se todas as operacións finalizaron correctamente:

$conex->commit();

confirma os cambios.

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


rollBack()

Se se produce unha excepción:

$conex->rollBack();

desfai os cambios realizados dentro da transacción.

Antes de chamar a rollBack() resulta conveniente comprobar:

$conex->inTransaction()

Por exemplo:

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

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


Exemplo completo

No arquivo bd.php poderiamos ter:

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

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

E a páxina PHP:

<?php

try {

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

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

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

    $clienteId = 10;

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

    $conex->beginTransaction();

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

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

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

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

    foreach ($produtos as $produto) {

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

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

} catch (PDOException $e) {

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

    error_log($e->getMessage());

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

} catch (Throwable $e) {

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

    error_log($e->getMessage());

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

Fixarse en varios detalles:

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


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

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

Por exemplo, non deberiamos facer:

INICIAR TRANSACCIÓN

INSERT ...

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

UPDATE ...

COMMIT

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

O recomendable é:

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


Sentenzas DDL e COMMIT implícito

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

Por exemplo:

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

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

Isto significa que non debemos esperar que:

$conex->rollBack();

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

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

SELECT
INSERT
UPDATE
DELETE


Niveis de illamento

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

Isto está relacionado co nivel de illamento.

InnoDB soporta os niveis:

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

En MySQL/InnoDB o nivel por defecto é normalmente:

REPEATABLE READ


Problemas de concorrencia

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

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

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

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

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


Modificar o nivel de illamento

Normalmente non necesitaremos cambialo nos primeiros exemplos.

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

Por exemplo:

SET TRANSACTION ISOLATION LEVEL
READ COMMITTED;

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


Interbloqueos ou deadlocks

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

Un exemplo simplificado:

TRANSACCIÓN A
    bloquea fila 1
    espera pola fila 2

TRANSACCIÓN B
    bloquea fila 2
    espera pola fila 1

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

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

Para reducir os deadlocks resulta útil:

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


Transaccións en procedementos almacenados

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

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

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

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

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


Alternativa A: transacción controlada por PDO

O procedemento almacenado non executa:

START TRANSACTION
COMMIT
ROLLBACK

PHP controla todo:

$conex->beginTransaction();

try {

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

    $conex->commit();

} catch (Throwable $e) {

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

    throw $e;
}

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


Alternativa B: transacción controlada polo procedemento

O procedemento pode encapsular completamente a operación.

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

DELIMITER $$

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

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

    START TRANSACTION;

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

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

    COMMIT;

END$$

DELIMITER ;

Neste caso:

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

Desde PHP non iniciaremos outra transacción:

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

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

$stmt->closeCursor();

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


Exercicio proposto 1: asociar libros e autores

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

A idea é:

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

A operación final implica:

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

Estas operacións deben formar unha transacción. Para evitar actualizacións concorrentes perdidas cando dous usuarios modifican o mesmo libro, convén bloquear a fila do libro mediante SELECT ... FOR UPDATE ao comezo da transacción, antes de borrar as asociacións.




Arquivos

Empregaremos:

alta_libro_autores.php
procesar_alta_libro_autores.php



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

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

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

define(
    'BLOQUEAR_LIBRO',
    'SELECT id FROM libros WHERE id = :libro_id FOR UPDATE'
);

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

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




  • Arrays enviados por formularios

Cada autor pode representarse cun checkbox:

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

Ao repetir:

name="autores[]"

PHP recibirá os autores seleccionados como un array:

$_POST['autores']

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

Por iso empregaremos:

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

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




  • Arquivo alta_libro_autores.php
<?php

try {

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

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

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

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

    $libros = $stmt->fetchAll();

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

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

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

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

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

        $autores = $stmt->fetchAll();

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

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

        foreach($datos as $dato){
            $autoresLibro[] = (int) $dato['id'];
        }
        /*  => OUTRA FORMA DE FACER O MESMO SEN EMPREGAR FOREACH
        $autoresLibro = array_map(
            'intval',
            array_column(
                $stmt->fetchAll(),
                'id'
            )
        );
        */
    }

} catch (PDOException $e) {

    error_log($e->getMessage());

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

} catch (Throwable $e) {

    error_log($e->getMessage());

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

    <h1>Asociar libros e autores</h1>

    <form method="post">

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

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

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

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

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

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

            <?php endforeach; ?>

        </select>

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

    </form>

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

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

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

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

                <tbody>

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

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

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

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

                <?php endforeach; ?>

                </tbody>
            </table>

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

        </form>

    <?php endif; ?>

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

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

    <?php endif; ?>

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

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

    <?php endif; ?>

</body>
</html>

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

Recuperamos os resultados mediante:

$stmt->fetchAll()

e traballamos directamente co array obtido.




  • Arquivo procesar_alta_libro_autores.php

Esta é a operación realmente transaccional.

<?php

try {

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

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

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

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

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

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

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

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

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

    foreach ($autoresRecibidos as $autor) {

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

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

        $autores[] = $autorId;
    }

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

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

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

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

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

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

    foreach ($autores as $autorId) {

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

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

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

} catch (PDOException $e) {

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

    error_log($e->getMessage());

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

} catch (Throwable $e) {

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

    error_log($e->getMessage());

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

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

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


Exercicio proposto 2: alta dun libro e varios exemplares

Queremos crear unha páxina que permita:

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

Os datos de cada exemplar serán:

  • estado;
  • imaxe;
  • prezo.

A operación debe ser atómica:

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


Deseño recomendado

Neste exercicio hai dúas alternativas válidas:

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

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

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

Despois amosaremos unha alternativa cun procedemento almacenado.


Consultas necesarias

No arquivo bd.php:

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

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

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

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


Requisitos previos do esquema

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

Validación dos prezos

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

Podemos normalizar unha coma decimal a punto:

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

e validalo:

$prezo = filter_var(
    $prezoTexto,
    FILTER_VALIDATE_FLOAT
);

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

PDO non dispón de PDO::PARAM_FLOAT.

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

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

A columna MySQL correspondente seguirá sendo de tipo numérico, por exemplo DECIMAL(6,2). Para importes monetarios reais é preferible validar a precisión decimal directamente sobre o texto (por exemplo, cun máximo de dúas cifras decimais), en vez de depender dun cálculo en coma flotante.


Non sanitizamos a imaxe para gardala

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

Por exemplo:

$imaxe = trim(
    $imaxeRecibida
);

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

Non empregaremos:

FILTER_SANITIZE_FULL_SPECIAL_CHARS

para transformar o dato antes de gardalo.

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


Arquivos do exercicio

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

alta_libro_exemplares.php
campos_exemplares.php
procesar_alta_libro_exemplares.php

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

Arquivo alta_libro_exemplares.php

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

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

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

        require '/var/www/confBD/bd.php';
        $dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', SERVIDOR, BD);
        $conex = new PDO($dsn, USUARIO, PASSWORD, [
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_EMULATE_PREPARES => false
        ]);
        $estados = $conex->query(CONSULTAR_ESTADOS)->fetchAll();

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

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

Arquivo campos_exemplares.php

<table>
    <thead><tr><th>Exemplar</th><th>Estado</th><th>Imaxe (nome)</th><th>Prezo</th></tr></thead>
    <tbody>
    <?php for ($i = 0; $i < $numExemplares; $i++): ?>
        <tr>
            <td><?= $i + 1 ?></td>
            <td>
                <select name="lstEstado[]" required>
                    <?php foreach ($estados as $estado): ?>
                        <option value="<?= (int) $estado['id'] ?>">
                            <?= htmlspecialchars($estado['descripcion'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>
                        </option>
                    <?php endforeach; ?>
                </select>
            </td>
            <td><input type="text" name="txtImaxe[]" maxlength="100" required></td>
            <td><input type="number" name="txtPrezo[]" min="0.01" step="0.01" required></td>
        </tr>
    <?php endfor; ?>
    </tbody>
</table>

Procesamento transaccional do libro e os exemplares

Supoñemos que o segundo formulario envía:

txtTitulo
lstEstado[]
txtImaxe[]
txtPrezo[]

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


Arquivo: procesar_alta_libro_exemplares.php

<?php

try {

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

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

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

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

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

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

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

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

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

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

    $numExemplares =
        count($estadosRecibidos);

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

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

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

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

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

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

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

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

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

        $prezo = filter_var(
            $prezoTexto,
            FILTER_VALIDATE_FLOAT
        );

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

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

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

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

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

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

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

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

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

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

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

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

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

        $stmtExemplar->execute();
    }

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

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

} catch (PDOException $e) {

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

    error_log($e->getMessage());

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

    if ($codigoMySQL === 1062) {

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

    } elseif ($codigoMySQL === 1452) {

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

    } else {

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

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

} catch (Throwable $e) {

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

    error_log($e->getMessage());

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

O ficheiro que contén este bloque debe chamarse procesar_alta_libro_exemplares.php, tal como indica o atributo action do formulario. Neste exemplo unha excepción producida ao inserir calquera exemplar provoca o salto ao catch e o posterior rollBack().

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

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

$conex->commit();

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



Formulario para os exemplares

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

Para xerar o formulario empréganse dous bucles:

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

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

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

Os campos empregan a notación []:

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

Isto permite recibir os datos como arrays en PHP. Os elementos que ocupan a mesma posición nos tres arrays corresponden ao mesmo exemplar.



Alternativa: procedemento almacenado para libro e exemplares

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

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

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

Por iso temos dúas opcións:

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

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


Exemplo sinxelo de transacción dentro dun procedemento

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

DELIMITER $$

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

    DECLARE v_libro_id INT;

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

    START TRANSACTION;

    INSERT INTO libros (titulo)
    VALUES (p_titulo);

    SET v_libro_id =
        LAST_INSERT_ID();

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

    COMMIT;

END$$

DELIMITER ;

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


mysqli como ampliación

PDO será o mecanismo principal empregado nos apuntes.

Con mysqli o patrón equivalente é:

<?php

try {

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

    mysqli_report(
        MYSQLI_REPORT_ERROR |
        MYSQLI_REPORT_STRICT
    );

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

    $conex->set_charset(
        'utf8mb4'
    );

    $conex->begin_transaction();

    /*
     * Operacións preparadas.
     */

    $conex->commit();

} catch (mysqli_sql_exception $e) {

    if (isset($conex)) {

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

    error_log($e->getMessage());

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

} catch (Throwable $e) {

    if (isset($conex)) {

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

    error_log($e->getMessage());

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


Exemplo mysqli con consultas preparadas

$conex->begin_transaction();

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

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

$stmt->execute();

$pedidoId =
    $conex->insert_id;

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

foreach ($produtos as $produto) {

    $produtoId =
        $produto['produto_id'];

    $cantidade =
        $produto['cantidade'];

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

    $stmtLina->execute();
}

$conex->commit();

Non é necesario empregar o patrón antigo:

$conex->autocommit(false);

// ...

$conex->autocommit(true);

como forma principal de explicar as transaccións.

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

$conex->begin_transaction();

e rematala con:

$conex->commit();

ou:

$conex->rollback();


Exercicios propostos

Subida de imaxes

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

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

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

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


Indicar que na entrada da wiki de acceso a datos tedes como subir unha imaxe ao servidor.

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