Qué es un listado
Un listado muestra lo que hay guardado. Es la R del CRUD y la única de las cuatro operaciones que no cambia nada: lee, muestra y listo. La tabla queda exactamente igual después de pasar por ella.
Es importante no dejarse engañar por esa modestia, porque el listado es la pantalla principal del sistema. Es la que se abre primero, la que da sentido a las otras tres y la que se usa más veces por día. Y casi siempre es también la puerta de entrada a las demás: en la fila de cada dato están los botones para editarlo y para darlo de baja. Como decía la sigla ABM, ahí está el motivo por el que el listado no aparece en su nombre: se da por supuesto.
Hay dos formas de listado, y las dos hacen falta. El listado completo, que muestra todo lo que hay, y el listado con filtros, que muestra solo lo que cumple una condición —buscar un socio por documento, ver los préstamos sin devolver—. El primero sirve cuando la tabla es chica; el segundo es obligatorio en cuanto hay unos cientos de filas, porque nadie encuentra nada en una lista de tres mil elementos.
La sentencia SELECT
La sentencia que devuelve datos es SELECT, y el tema completo está en la Unidad 3: las cláusulas y las columnas en Consultas básicas, y los filtros, los operadores y el ordenamiento en Filtros y ordenamiento.
SELECT id, nombre, documento, correo
FROM socios
WHERE activo = 1
ORDER BY nombre;
Lo que es propio de un listado hecho desde un programa son los dos detalles de abajo.
SELECT no va a estar disponible en el programa: si no se pide el correo, después no se puede mostrar, por más que esté guardado en la tabla. Por eso conviene nombrar los campos en lugar de usar SELECT *: en el gestor el asterisco es una comodidad, y en el código de una pantalla es una molestia que trae datos que no se van a usar. Y el ORDER BY no es un lujo: sin él el orden no está garantizado, y una lista que cambia de orden cada vez que se recarga es inservible.
Mostrar el resultado
Acá está la idea que cuesta al principio: la base de datos no devuelve una tabla HTML. Devuelve una lista de filas, y cada fila es un conjunto de valores con el nombre de su columna. Armar la tabla es trabajo de PHP, que tiene que recorrer esa lista y escribir el HTML.
<?php
$bd = new PDO('mysql:host=localhost;dbname=biblioteca;charset=utf8mb4', 'usuario', 'clave');
$bd->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Lo que el usuario escribió en el buscador (puede no venir)
$buscar = trim($_GET['buscar'] ?? '');
$sentencia = $bd->prepare(
'SELECT id, nombre, documento, correo
FROM socios
WHERE nombre LIKE :buscar
ORDER BY nombre'
);
// El comodín se agrega acá, no en la consulta
$sentencia->execute([':buscar' => '%' . $buscar . '%']);
$socios = $sentencia->fetchAll();
?>
Y después, el HTML que recorre el resultado:
<table class="table">
<thead>
<tr>
<th>Nombre</th>
<th>Documento</th>
<th>Correo</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<?php foreach ($socios as $socio): ?>
<tr>
<td><?= htmlspecialchars($socio['nombre']) ?></td>
<td><?= htmlspecialchars($socio['documento']) ?></td>
<td><?= htmlspecialchars($socio['correo']) ?></td>
<td class="acciones">
<a href="editar.php?id=<?= (int) $socio['id'] ?>">Editar</a>
<form action="baja.php" method="post" class="en-linea">
<input type="hidden" name="id" value="<?= (int) $socio['id'] ?>">
<button type="submit">Eliminar</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
Vale la pena mirar con atención la celda de acciones, porque ahí está resumida toda la unidad. El botón Editar es un enlace y el de Eliminar es un formulario. No es una inconsistencia: es exactamente la regla de los métodos de envío. Editar muestra un formulario con los datos, así que no cambia nada y va con GET; eliminar destruye una fila, así que va con POST y nunca podría ser un enlace.
Ese código arma un listado de socios. Así queda, en cambio, el listado del CRUD de la tienda, con los encabezados, los datos y los dos botones en la columna de acciones:
Por qué htmlspecialchars()
Todos los valores que salen de la base de datos están envueltos en esa función, y no es un adorno: es lo que impide que un dato guardado se convierta en parte de la página.
El problema es este. Un dato guardado es texto: no tiene por qué cumplir ninguna regla de HTML. Pero cuando se escribe dentro de la página, el navegador lo lee como HTML. Y si ese texto contiene símbolos con significado —los signos de menor y mayor, las comillas— el navegador los interpreta en lugar de mostrarlos.
Alcanza con imaginar que alguien carga un socio con este nombre:
<script>alert('hola')</script>
Si ese nombre se escribe tal cual en el listado, el navegador no lo muestra: lo ejecuta. Cada persona que abra el listado va a ver una ventana emergente. Y eso, que parece una travesura, es en realidad una de las fallas de seguridad más conocidas de la web: con un poco más de ingenio, el mismo mecanismo permite robar la sesión de otro usuario o redirigirlo a otro sitio.
htmlspecialchars(). Sin excepciones, aunque el dato parezca inofensivo y aunque lo haya cargado alguien de confianza. La función convierte los símbolos peligrosos en su representación segura —el signo de menor pasa a ser <—, con lo cual el navegador muestra el texto en lugar de interpretarlo. Es la defensa contra lo que se llama XSS, y es una línea de código.
Un plus: el modo inseguro y el modo seguro
El listado del CRUD de la tienda funciona tal como está escrito, y así como está se entiende todo lo anterior. Pero hay una mejora opcional que conviene conocer: cómo se escriben los valores dentro de la tabla. Hay dos maneras de hacerlo —una insegura y una segura— y la diferencia entre las dos se ve a simple vista.
Para comprobarlo, se cargó en la tienda un producto con este nombre:
<b>Aceite</b>
No es un nombre razonable, y justamente por eso sirve de prueba: es un texto guardado en la base que además contiene símbolos con significado en HTML. La base lo acepta sin opinar —para un VARCHAR es texto como cualquier otro— y el listado lo muestra de una manera o de otra según cómo esté escrito el programa.
Modo inseguro: el valor tal cual
Es lo que hace el CRUD: escribir el valor directamente.
<td><?= $producto['articulo'] ?></td>
<b>Aceite</b>, pero en pantalla se ve Aceite en negrita: el navegador interpretó la etiqueta en lugar de mostrar el texto.El navegador no muestra el nombre: interpreta el <b> y lo dibuja. El dato que estaba guardado dejó de ser un dato y pasó a formar parte de la página.
Modo seguro: el valor envuelto en htmlspecialchars()
Es una palabra más por dato, y alcanza:
<td><?= htmlspecialchars($producto['articulo']) ?></td>
Solo cambió cómo se escribe el valor en el HTML. Se aplica igual a los otros campos de texto —la descripcion— y no hace falta para los números, como el id o las unidades, que no pueden contener símbolos.
htmlspecialchars(), lo peor que puede pasar es que se vea en pantalla un texto raro. Sin él, lo peor que puede pasar es que el navegador ejecute algo que alguien guardó en la base. Las dos capturas muestran el mismo camino con un <b>, que solo pone negrita; ese mismo camino, con un <script>, es el que permite robar la sesión de otro usuario.
Una segunda mejora, del mismo estilo, es declarar el juego de caracteres en la conexión: mysql:host=localhost;dbname=tienda;charset=utf8mb4. El CRUD de la tienda no lo declara y en este servidor funciona igual, porque MariaDB ya viene con utf8mb4 por defecto. Declararlo sirve para que el programa no dependa de la configuración de cada servidor: en una instalación con otro juego por defecto, las tildes y las eñes llegan deformadas.
Cuando el listado viene vacío
Hay un caso que se pasa por alto siempre y que se ve en la primera prueba: la consulta no devuelve nada. Y eso no es un error. Puede pasar por dos motivos muy distintos, y el usuario tiene derecho a distinguirlos:
- La tabla está vacía. Todavía no se cargó ningún socio.
- El filtro no encontró nada. Hay socios cargados, pero ninguno coincide con la búsqueda.
Una tabla HTML vacía no distingue esos dos casos, y tampoco distingue el vacío de una falla: se ve igual que una página que no cargó. El usuario, que no sabe si el sistema está roto, prueba de nuevo, recarga, aprieta el botón otra vez. La solución es un mensaje, y es distinto según el caso:
<?php if (! $socios): ?>
<?php if ($buscar !== ''): ?>
<p>No se encontró ningún socio que coincida con «<?= htmlspecialchars($buscar) ?>».</p>
<?php else: ?>
<p>Todavía no hay socios cargados.</p>
<?php endif; ?>
<?php else: ?>
... la tabla con los resultados ...
<?php endif; ?>
Los dos mensajes son distintos porque las dos situaciones son distintas, y la acción que el usuario tiene que tomar también: en el primer caso conviene borrar el filtro, en el segundo hay que cargar el primer socio. Un mensaje genérico del tipo «no hay resultados» deja al usuario sin saber qué hacer. Detalle de color: fetchAll() devuelve un arreglo vacío cuando no hay filas, y una lista vacía evaluada como condición es falsa, así que if (! $socios) alcanza para los dos casos.
Contar y paginar
Dos herramientas que se necesitan en cuanto la tabla crece:
-- Cuántas filas hay en total
SELECT COUNT(*) FROM socios;
-- Traer solo una parte: 20 filas, salteando las primeras 40
SELECT id, nombre FROM socios ORDER BY nombre LIMIT 20 OFFSET 40;
El COUNT(*) devuelve un único número: cuántas filas cumplen la condición. Sirve para mostrar «47 socios» arriba del listado y, sobre todo, para armar la paginación. El LIMIT y el OFFSET son los que hacen posible mostrar de a veinte: LIMIT es cuántas filas traer y OFFSET, cuántas saltear. Cambiando el OFFSET se pasa de página en página. Sin eso, un listado de diez mil filas obligaría a traerlas todas de la base, armar un HTML enorme y hacer esperar al usuario —y a todos los demás— mientras tanto. El ORDER BY no es opcional cuando se pagina: sin un orden definido, la base puede devolver las filas en cualquier orden en cada consulta, y entonces la página 2 podría mostrar filas que ya salieron en la 1 y saltearse otras.
Resumen
- Un listado muestra lo que está guardado y no cambia nada.
- Es la pantalla principal del sistema y la puerta de entrada a las otras tres operaciones.
- Se usa
SELECT, nombrando las columnas en lugar de usar*. - Siempre conviene un
ORDER BY: sin él, el orden no está garantizado. - Los filtros van con
WHERE; elLIKEcon%es el de los buscadores. - El
%se agrega en PHP, no dentro de la consulta. - La base devuelve una lista de filas, no una tabla HTML: la tabla la arma PHP recorriéndola.
- En el listado, editar es un enlace y borrar es un formulario:
GETpara pedir,POSTpara cambiar. - Todo valor que sale de la base va dentro de
htmlspecialchars(): es la defensa contra XSS. - Un listado vacío no es un error, y el mensaje cambia según la causa: tabla vacía o filtro sin resultados.
COUNT(*),LIMITyOFFSETpermiten contar y paginar.