Préstamos de una biblioteca con PDO y transacciones
Gestiona los préstamos de una biblioteca sobre una base de datos SQLite con PDO: búsquedas con LIKE, préstamos dentro de una transacción con sus reglas, devoluciones con multa, morosos y estadísticas. Consultas preparadas contra la inyección de SQL, JOIN, fetch y fechas con DateTimeImmutable.
- PDO y consultas preparadas
- Transacciones (beginTransaction, commit, rollBack)
- LEFT JOIN y GROUP BY
- fetch, fetchAll y fetchColumn
- DateTimeImmutable
- match
Enunciado
La biblioteca de un instituto quiere dejar la hoja de cálculo y gestionar los préstamos con una aplicación web en PHP. Antes de hacer las pantallas, se escribe y se prueba la capa de datos: funciones que reciben órdenes y trabajan contra la base de datos con PDO.
Para que el ejercicio funcione sin instalar nada, la base de datos es SQLite en memoria: el código inicial la crea con sus tres tablas y unos datos de partida, que tienes abajo. Con MySQL el código sería el mismo cambiando solo la cadena de conexión (el DSN): esa es la ventaja de PDO.
Las reglas del préstamo son las habituales: 15 días de plazo, como mucho 3 libros a la vez, nada nuevo si se tiene algo vencido y 0,20 € de multa por día de retraso. Comprobar las reglas y registrar el préstamo debe ser una operación atómica: si dos bibliotecarios prestan a la vez el último ejemplar, solo uno puede conseguirlo. Eso es una transacción.
Qué tiene que hacer el programa
- Cada línea es una orden; antes de responder, escribe
>y la línea tal como se ha leído (sin espacios en los extremos). Una orden desconocida o con argumentos que no tocan escribeOrden no válida. BUSCAR texto: libros cuyo título o autor contienen el texto (sin distinguir mayúsculas), por orden de título, comocódigo · título (autor) · disponibles D de E, donde D son los ejemplares menos los prestados sin devolver. Si no hay ninguno,Sin resultados para «texto».PRESTAR código socio fecha: comprueba que existen el libro (No existe el libro X) y el socio (No existe el socio N) y, dentro de una transacción y en este orden, que queda algún ejemplar (No quedan ejemplares de «título»), que el socio no tiene préstamos activos vencidos a esa fecha (Nombre tiene préstamos vencidos) y que no tiene ya 3 activos (Nombre ya tiene 3 préstamos). Si todo va bien, registra el préstamo y escribePréstamo N: «título» para Nombre (devolver antes del AAAA-MM-DD), con la fecha de salida más 15 días. Un préstamo vence cuando su fecha límite es anterior a la fecha de la operación.DEVOLVER id fecha:No existe el préstamo N,El préstamo N ya se devolvió el AAAA-MM-DD, o registra la devolución y escribeDevuelto «título» a tiempooDevuelto «título» con R días de retraso: multa X €(R = días transcurridos − 15; «día» si es 1).MOROSOS fecha:Préstamos vencidos el fecha:y una línea por préstamo activo con retraso a esa fecha, por fecha de salida:Nombre: «título» (R días de retraso); oNo hay préstamos vencidos el fecha.RESUMEN:Préstamos activos: NySocio más lector: Nombre (N préstamos)(contando también los devueltos; si empatan, el primero alfabéticamente).- Todas las consultas que llevan datos de la entrada deben ser consultas preparadas.
Entrada
Una orden por línea: BUSCAR, PRESTAR, DEVOLVER, MOROSOS o RESUMEN, con las fechas como AAAA-MM-DD.
Datos de referencia
| codigo | titulo | autor | ejemplares |
|---|---|---|---|
| L01 | Cien años de soledad | Gabriel García Márquez | 2 |
| L02 | 1984 | George Orwell | 1 |
| L03 | Rebelión en la granja | George Orwell | 2 |
| L04 | La sombra del viento | Carlos Ruiz Zafón | 1 |
| L05 | El nombre del viento | Patrick Rothfuss | 3 |
| L06 | El tercer policía | Flann O'Brien | 1 |
| id | nombre |
|---|---|
| 1 | Ana Gil |
| 2 | Luis Mora |
| 3 | Eva Ruiz |
| id | codigo | socio | salida | devolucion |
|---|---|---|---|---|
| 1 | L02 | 2 | 2026-09-01 | NULL |
| 2 | L01 | 1 | 2026-09-20 | 2026-09-28 |
| 3 | L05 | 1 | 2026-09-25 | NULL |
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Una mañana en la biblioteca
Entrada
BUSCAR viento BUSCAR orwell BUSCAR O'Brien PRESTAR L02 3 2026-10-01 PRESTAR L04 2 2026-10-01 PRESTAR L04 3 2026-10-01 DEVOLVER 1 2026-10-01 PRESTAR L02 3 2026-10-02 MOROSOS 2026-10-20 RESUMEN
Salida por consola
> BUSCAR viento L05 · El nombre del viento (Patrick Rothfuss) · disponibles 2 de 3 L04 · La sombra del viento (Carlos Ruiz Zafón) · disponibles 1 de 1 > BUSCAR orwell L02 · 1984 (George Orwell) · disponibles 0 de 1 L03 · Rebelión en la granja (George Orwell) · disponibles 2 de 2 > BUSCAR O'Brien L06 · El tercer policía (Flann O'Brien) · disponibles 1 de 1 > PRESTAR L02 3 2026-10-01 No quedan ejemplares de «1984» > PRESTAR L04 2 2026-10-01 Luis Mora tiene préstamos vencidos > PRESTAR L04 3 2026-10-01 Préstamo 4: «La sombra del viento» para Eva Ruiz (devolver antes del 2026-10-16) > DEVOLVER 1 2026-10-01 Devuelto «1984» con 15 días de retraso: multa 3,00 € > PRESTAR L02 3 2026-10-02 Préstamo 5: «1984» para Eva Ruiz (devolver antes del 2026-10-17) > MOROSOS 2026-10-20 Préstamos vencidos el 2026-10-20: Ana Gil: «El nombre del viento» (10 días de retraso) Eva Ruiz: «La sombra del viento» (4 días de retraso) Eva Ruiz: «1984» (3 días de retraso) > RESUMEN Préstamos activos: 3 Socio más lector: Ana Gil (2 préstamos)
Órdenes y datos que no existen
Entrada
BUSCAR Quijote PRESTAR L99 1 2026-10-01 PRESTAR L01 7 2026-10-01 DEVOLVER 2 2026-10-01 DEVOLVER 50 2026-10-01 PRESTAR L01 CATALOGO
Salida por consola
> BUSCAR Quijote Sin resultados para «Quijote» > PRESTAR L99 1 2026-10-01 No existe el libro L99 > PRESTAR L01 7 2026-10-01 No existe el socio 7 > DEVOLVER 2 2026-10-01 El préstamo 2 ya se devolvió el 2026-09-28 > DEVOLVER 50 2026-10-01 No existe el préstamo 50 > PRESTAR L01 Orden no válida > CATALOGO Orden no válida
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Consultas preparadas siempre
Con prepare y execute, los datos viajan aparte de la consulta: el apóstrofo de O'Brien no la rompe y nadie puede inyectar SQL. Un mismo parámetro con nombre (:t) se puede usar dos veces.
$st = $pdo->prepare("SELECT * FROM libros WHERE titulo LIKE :t OR autor LIKE :t ORDER BY titulo");
$st->execute([":t" => "%$texto%"]);
$libros = $st->fetchAll();2. Ejemplares disponibles con LEFT JOIN
Cuenta los préstamos sin devolver de cada libro con un LEFT JOIN (para que los libros sin préstamos salgan con 0) y réstalos de los ejemplares. fetchColumn() devuelve directamente el valor de la primera columna.
3. La transacción del préstamo
Abre la transacción antes de comprobar las reglas y confírmala después del INSERT. Si una regla falla, lanza una excepción y en el catch deshaz con rollBack(): la base de datos queda como estaba.
$pdo->beginTransaction();
try {
// comprobaciones + INSERT
$pdo->commit();
} catch (RuntimeException $e) {
$pdo->rollBack();
echo $e->getMessage() . "\n";
}4. Fechas sin hacer cuentas a mano
(new DateTimeImmutable($salida))->modify("+15 days") da la fecha límite, y diff los días entre dos fechas (format("%r%a") con signo). Las fechas AAAA-MM-DD también se pueden comparar como texto.
5. Un despachador con match
match (true) con condiciones permite elegir la función según la orden y comprobar a la vez el número de argumentos, con un default para lo que no vale.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
BUSCAR viento BUSCAR orwell BUSCAR O'Brien PRESTAR L02 3 2026-10-01 PRESTAR L04 2 2026-10-01 PRESTAR L04 3 2026-10-01 DEVOLVER 1 2026-10-01 PRESTAR L02 3 2026-10-02 MOROSOS 2026-10-20 RESUMEN
> BUSCAR viento L05 · El nombre del viento (Patrick Rothfuss) · disponibles 2 de 3 L04 · La sombra del viento (Carlos Ruiz Zafón) · disponibles 1 de 1 > BUSCAR orwell L02 · 1984 (George Orwell) · disponibles 0 de 1 L03 · Rebelión en la granja (George Orwell) · disponibles 2 de 2 > BUSCAR O'Brien L06 · El tercer policía (Flann O'Brien) · disponibles 1 de 1 > PRESTAR L02 3 2026-10-01 No quedan ejemplares de «1984» > PRESTAR L04 2 2026-10-01 Luis Mora tiene préstamos vencidos > PRESTAR L04 3 2026-10-01 Préstamo 4: «La sombra del viento» para Eva Ruiz (devolver antes del 2026-10-16) > DEVOLVER 1 2026-10-01 Devuelto «1984» con 15 días de retraso: multa 3,00 € > PRESTAR L02 3 2026-10-02 Préstamo 5: «1984» para Eva Ruiz (devolver antes del 2026-10-17) > MOROSOS 2026-10-20 Préstamos vencidos el 2026-10-20: Ana Gil: «El nombre del viento» (10 días de retraso) Eva Ruiz: «La sombra del viento» (4 días de retraso) Eva Ruiz: «1984» (3 días de retraso) > RESUMEN Préstamos activos: 3 Socio más lector: Ana Gil (2 préstamos)
Solución explicada
Ver la solución completa
1<?php
2// Base de datos de la biblioteca (SQLite en memoria): tablas libros, socios y prestamos
3$pdo = new PDO("sqlite::memory:");
4$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
5$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
6$pdo->exec(<<<SQL
7 CREATE TABLE libros (codigo TEXT PRIMARY KEY, titulo TEXT NOT NULL, autor TEXT NOT NULL, ejemplares INTEGER NOT NULL);
8 CREATE TABLE socios (id INTEGER PRIMARY KEY, nombre TEXT NOT NULL);
9 CREATE TABLE prestamos (
10 id INTEGER PRIMARY KEY AUTOINCREMENT,
11 codigo TEXT NOT NULL REFERENCES libros(codigo),
12 socio INTEGER NOT NULL REFERENCES socios(id),
13 salida TEXT NOT NULL,
14 devolucion TEXT
15 );
16 INSERT INTO libros VALUES
17 ('L01', 'Cien años de soledad', 'Gabriel García Márquez', 2),
18 ('L02', '1984', 'George Orwell', 1),
19 ('L03', 'Rebelión en la granja', 'George Orwell', 2),
20 ('L04', 'La sombra del viento', 'Carlos Ruiz Zafón', 1),
21 ('L05', 'El nombre del viento', 'Patrick Rothfuss', 3),
22 ('L06', 'El tercer policía', 'Flann O''Brien', 1);
23 INSERT INTO socios VALUES (1, 'Ana Gil'), (2, 'Luis Mora'), (3, 'Eva Ruiz');
24 INSERT INTO prestamos (codigo, socio, salida, devolucion) VALUES
25 ('L02', 2, '2026-09-01', NULL),
26 ('L01', 1, '2026-09-20', '2026-09-28'),
27 ('L05', 1, '2026-09-25', NULL);
28 SQL);
29
30const PLAZO = 15; // días de préstamo
31const MULTA = 20; // céntimos por día de retraso
32const MAXIMO = 3; // préstamos activos por socio
33
34function euros(int $c): string { return number_format($c / 100, 2, ",", ".") . " €"; }
35
36function dias(string $desde, string $hasta): int {
37 return (int) (new DateTimeImmutable($desde))->diff(new DateTimeImmutable($hasta))->format("%r%a");
38}
39
40function vence(string $salida): string {
41 return (new DateTimeImmutable($salida))->modify("+" . PLAZO . " days")->format("Y-m-d");
42}
43
44/** Ejemplares que quedan en la estantería: los totales menos los prestados sin devolver. */
45function disponibles(PDO $pdo, string $codigo): int {
46 $st = $pdo->prepare("SELECT l.ejemplares - COUNT(p.id) FROM libros l
47 LEFT JOIN prestamos p ON p.codigo = l.codigo AND p.devolucion IS NULL
48 WHERE l.codigo = ? GROUP BY l.codigo");
49 $st->execute([$codigo]);
50 return (int) $st->fetchColumn();
51}
52
53function buscar(PDO $pdo, string $texto): void {
54 // El texto va como parámetro: un apóstrofo (O'Brien) no rompe la consulta ni permite inyectar SQL
55 $st = $pdo->prepare("SELECT codigo, titulo, autor, ejemplares FROM libros
56 WHERE titulo LIKE :t OR autor LIKE :t ORDER BY titulo");
57 $st->execute([":t" => "%$texto%"]);
58 $libros = $st->fetchAll();
59 if (!$libros) {
60 echo "Sin resultados para «{$texto}»\n";
61 return;
62 }
63 foreach ($libros as $l) {
64 echo "{$l['codigo']} · {$l['titulo']} ({$l['autor']}) · disponibles " . disponibles($pdo, $l['codigo']) . " de {$l['ejemplares']}\n";
65 }
66}
67
68function prestar(PDO $pdo, string $codigo, int $socio, string $fecha): void {
69 $libro = $pdo->prepare("SELECT titulo FROM libros WHERE codigo = ?");
70 $libro->execute([$codigo]);
71 $titulo = $libro->fetchColumn();
72 $persona = $pdo->prepare("SELECT nombre FROM socios WHERE id = ?");
73 $persona->execute([$socio]);
74 $nombre = $persona->fetchColumn();
75 if ($titulo === false) { echo "No existe el libro $codigo\n"; return; }
76 if ($nombre === false) { echo "No existe el socio $socio\n"; return; }
77
78 // Las comprobaciones y el INSERT van en una transacción: nadie puede llevarse el último ejemplar entre medias
79 $pdo->beginTransaction();
80 try {
81 if (disponibles($pdo, $codigo) <= 0) throw new RuntimeException("No quedan ejemplares de «{$titulo}»");
82 $activos = $pdo->prepare("SELECT salida FROM prestamos WHERE socio = ? AND devolucion IS NULL");
83 $activos->execute([$socio]);
84 $salidas = $activos->fetchAll(PDO::FETCH_COLUMN);
85 foreach ($salidas as $s) {
86 if (vence($s) < $fecha) throw new RuntimeException("$nombre tiene préstamos vencidos");
87 }
88 if (count($salidas) >= MAXIMO) throw new RuntimeException("$nombre ya tiene " . MAXIMO . " préstamos");
89 $pdo->prepare("INSERT INTO prestamos (codigo, socio, salida) VALUES (?, ?, ?)")->execute([$codigo, $socio, $fecha]);
90 $id = $pdo->lastInsertId();
91 $pdo->commit();
92 echo "Préstamo $id: «{$titulo}» para $nombre (devolver antes del " . vence($fecha) . ")\n";
93 } catch (RuntimeException $e) {
94 $pdo->rollBack();
95 echo $e->getMessage() . "\n";
96 }
97}
98
99function devolver(PDO $pdo, int $id, string $fecha): void {
100 $st = $pdo->prepare("SELECT p.salida, p.devolucion, l.titulo FROM prestamos p JOIN libros l USING (codigo) WHERE p.id = ?");
101 $st->execute([$id]);
102 $p = $st->fetch();
103 if (!$p) { echo "No existe el préstamo $id\n"; return; }
104 if ($p['devolucion'] !== null) { echo "El préstamo $id ya se devolvió el {$p['devolucion']}\n"; return; }
105 $pdo->prepare("UPDATE prestamos SET devolucion = ? WHERE id = ?")->execute([$fecha, $id]);
106 $retraso = max(0, dias($p['salida'], $fecha) - PLAZO);
107 if ($retraso === 0) {
108 echo "Devuelto «{$p['titulo']}» a tiempo\n";
109 } else {
110 echo "Devuelto «{$p['titulo']}» con $retraso " . ($retraso === 1 ? "día" : "días") . " de retraso: multa " . euros($retraso * MULTA) . "\n";
111 }
112}
113
114function morosos(PDO $pdo, string $fecha): void {
115 $st = $pdo->query("SELECT s.nombre, l.titulo, p.salida FROM prestamos p
116 JOIN socios s ON s.id = p.socio JOIN libros l ON l.codigo = p.codigo
117 WHERE p.devolucion IS NULL ORDER BY p.salida, p.id");
118 $lista = [];
119 foreach ($st as $fila) {
120 $retraso = dias($fila['salida'], $fecha) - PLAZO;
121 if ($retraso > 0) $lista[] = " {$fila['nombre']}: «{$fila['titulo']}» ($retraso " . ($retraso === 1 ? "día" : "días") . " de retraso)";
122 }
123 echo $lista ? "Préstamos vencidos el $fecha:\n" . implode("\n", $lista) . "\n" : "No hay préstamos vencidos el $fecha\n";
124}
125
126function resumen(PDO $pdo): void {
127 $activos = $pdo->query("SELECT COUNT(*) FROM prestamos WHERE devolucion IS NULL")->fetchColumn();
128 echo "Préstamos activos: $activos\n";
129 $top = $pdo->query("SELECT s.nombre, COUNT(*) AS n FROM prestamos p JOIN socios s ON s.id = p.socio
130 GROUP BY s.id ORDER BY n DESC, s.nombre LIMIT 1")->fetch();
131 echo $top ? "Socio más lector: {$top['nombre']} ({$top['n']} préstamos)\n" : "Socio más lector: ninguno\n";
132}
133
134foreach (explode("\n", stream_get_contents(STDIN)) as $linea) {
135 $linea = trim($linea);
136 if ($linea === "") continue;
137 $p = preg_split('/\s+/', $linea, 2);
138 $orden = strtoupper($p[0]);
139 $args = isset($p[1]) ? preg_split('/\s+/', $p[1]) : [];
140 echo "> $linea\n";
141 match (true) {
142 $orden === "BUSCAR" && isset($p[1]) => buscar($pdo, $p[1]),
143 $orden === "PRESTAR" && count($args) === 3 => prestar($pdo, $args[0], (int) $args[1], $args[2]),
144 $orden === "DEVOLVER" && count($args) === 2 => devolver($pdo, (int) $args[0], $args[1]),
145 $orden === "MOROSOS" && count($args) === 1 => morosos($pdo, $args[0]),
146 $orden === "RESUMEN" => resumen($pdo),
147 default => print("Orden no válida\n"),
148 };
149}Toda consulta con datos del usuario es preparada. Además de proteger frente a la inyección de SQL, evita los errores de comillas (O'Brien) y permite reutilizar la consulta con distintos valores.
El préstamo es el caso de libro de una transacción: varias lecturas (disponibles, préstamos activos) y una escritura que solo tiene sentido si las lecturas siguen siendo ciertas. Con beginTransaction/commit/rollBack todo ocurre o nada ocurre; en MySQL se añadiría SELECT … FOR UPDATE para bloquear las filas leídas.
Los ejemplares disponibles no se guardan en ninguna columna: se calculan con un LEFT JOIN a partir de los préstamos. Así nunca pueden quedar desincronizados, que es lo que pasaría si cada préstamo tuviera que acordarse de restar uno.
Las reglas se expresan como excepciones con el mensaje para el usuario, de modo que la transacción tiene un único camino de éxito y un único catch que deshace. DateTimeImmutable hace las cuentas de fechas sin errores de meses o años bisiestos.
Para ir más allá
- Cambia el DSN a MySQL (con Docker) y comprueba que todo funciona igual; añade
FOR UPDATEa la consulta de disponibles. - Haz las pantallas: un buscador con formulario GET y el préstamo con un formulario POST protegido con token CSRF.
- Añade reservas: si no quedan ejemplares, el socio se apunta a una lista de espera y se le asigna el primero que se devuelva.