Apuntes DAM
Volver al inicio

Préstamos de una biblioteca con PDO y transacciones

Ejercicio de PHPMuy difícilUnos 100 minutos

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

  1. 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 escribe Orden no válida.
  2. BUSCAR texto: libros cuyo título o autor contienen el texto (sin distinguir mayúsculas), por orden de título, como có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».
  3. 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 escribe Pré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.
  4. 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 escribe Devuelto «título» a tiempo o Devuelto «título» con R días de retraso: multa X € (R = días transcurridos − 15; «día» si es 1).
  5. 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); o No hay préstamos vencidos el fecha.
  6. RESUMEN: Préstamos activos: N y Socio más lector: Nombre (N préstamos) (contando también los devueltos; si empatan, el primero alfabéticamente).
  7. 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

libros
codigotituloautorejemplares
L01Cien años de soledadGabriel García Márquez2
L021984George Orwell1
L03Rebelión en la granjaGeorge Orwell2
L04La sombra del vientoCarlos Ruiz Zafón1
L05El nombre del vientoPatrick Rothfuss3
L06El tercer policíaFlann O'Brien1
socios
idnombre
1Ana Gil
2Luis Mora
3Eva Ruiz
prestamos
idcodigosociosalidadevolucion
1L0222026-09-01NULL
2L0112026-09-202026-09-28
3L0512026-09-25NULL

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.

php
$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.

php
$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.

🐘PHPPréstamos de una biblioteca con PDO y transaccionesMuy difícil

Ejemplo

Entrada (lo que se escribe por teclado)
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 esperada
> 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)
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
php
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 UPDATE a 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.

Dónde se explica