Las consultas de la lección anterior funcionan. Esta lección va de lo otro: de si dentro de un año, cuando ya no te acuerdes de por qué escribiste ese LEFT JOIN, alguien —incluido tú— podrá tocarlas sin miedo. Porque el SQL tiene una peculiaridad que lo hace especialmente traicionero: una consulta mal escrita no falla, devuelve números. Un programa mal escrito revienta y te enteras; una consulta con un JOIN de más devuelve una cifra plausible que acaba en una diapositiva de dirección.

Aquí no hay sintaxis nueva. Hay criterio: cómo se nombran las cosas, cómo se formatean, qué se decide al diseñar el esquema, qué garantías se ponen en la base y cuáles en la aplicación, y qué proceso rodea a todo eso. Casi todo son convenciones, y en una convención lo importante no es cuál eliges, sino que el equipo entero elija la misma. La lección termina con el catálogo de antipatrones y con una lista de comprobación para revisar una consulta antes de darla por buena.

Contenido

  1. Nomenclatura
  2. Formato y legibilidad
  3. Diseño del esquema
  4. Fiabilidad: dónde viven las garantías
  5. Proceso: versiones, revisión, pruebas y copias
  6. Antipatrones
  7. Checklist de revisión de una consulta
  8. Errores Comunes y Consejos
  9. Ejercicios
  10. Conclusión

  1. Nomenclatura

Singular o plural, y por qué da igual

La discusión más vieja del oficio: ¿cliente o clientes? Hay argumentos honestos para las dos:

Plural (clientes) Singular (cliente)
Razonamiento Una tabla es una colección de filas Una fila es un cliente; la tabla es el tipo
Se lee bien en SELECT * FROM clientes JOIN cliente ON cliente.id = ...
Lo usan Rails, Django (por omisión), este curso Hibernate/JPA por costumbre, muchos DBA

Ninguna de las dos es mejor. Lo que sí es objetivamente malo es mezclarlas: un esquema con clientes, pedido y linea_pedidos obliga a mirar el diccionario antes de cada consulta. TiendaVerde usa plural en todas las tablas, sin excepción, y eso es todo el mérito que tiene la decisión.

Las reglas que sí son objetivas

  • snake_case en minúsculas, siempre. PostgreSQL pasa a minúsculas todo identificador que no vaya entre comillas dobles: si creas "FechaPedido", tendrás que escribirlo entrecomillado para siempre, y al primer olvido saldrá column "fechapedido" does not exist. Poner comillas dobles en un CREATE TABLE es una condena.
  • Solo ASCII en los identificadores. Por eso la tabla es resenas y no reseñas (01-06). Los datos llevan tildes y ñ; los nombres de objeto, no.
  • Nada de palabras reservadas. user, order, group, table, select, check, end... Una columna llamada order obliga a entrecomillarla en cada consulta. Si el negocio dice "pedido", la tabla se llama pedidos; si dice "usuario", usuarios.
  • Nombres que dicen algo. datos, tabla1, temp2, info, campo3, x no significan nada dentro de seis meses. Y sin abreviaturas propias: fecha_pedido, no fec_ped.
  • Sin prefijo de tipo. str_nombre, tbl_clientes, int_stock son notación húngara: ruido que además miente en cuanto alguien cambia el tipo.

Claves, restricciones e índices

La convención de TiendaVerde, que es la más extendida y la que este curso ha usado en once módulos:

Objeto Convención Ejemplo del curso
Tabla Plural, snake_case lineas_pedido
Clave primaria id, subrogada productos.id
Clave foránea <tabla_singular>_id pedidos.cliente_id, lineas_pedido.producto_id
FK reflexiva Nombre del papel, no de la tabla empleados.jefe_id, clientes.referido_por_id
Booleano Adjetivo afirmativo, sin negar activo (nunca no_activo)
Fecha fecha_<qué> fecha_pedido, fecha_registro, fecha_alta
Restricción CHECK chk_<tabla>_<columna> chk_resenas_puntuacion
Clave foránea (restricción) fk_<tabla>_<tabla_referida> fk_pedidos_clientes
UNIQUE uq_<tabla>_<columnas> uq_clientes_email
Índice idx_<tabla>_<columnas> idx_lineas_pedido_pedido_id
Vista / materializada v_ / mv_ v_detalle_ventas, mv_ventas_mensuales
Función / procedimiento / trigger fn_ / sp_ / trg_ fn_total_pedido, sp_confirmar_pedido

Dos notas. La primera: la FK reflexiva se nombra por el papel. empleados.empleado_id no dice nada; jefe_id lo dice todo. La segunda: un booleano en negativo es una trampaWHERE NOT no_activo es ilegible y no_activo = FALSE es peor—; nombra siempre la condición verdadera.

Nombra las restricciones a mano. Si no lo haces, PostgreSQL genera productos_precio_check o pedidos_cliente_id_fkey, que son legibles pero no controlados por ti: cambian si cambia el nombre de la columna, y aparecen tal cual en el mensaje de error que verá el usuario. Con CONSTRAINT chk_productos_precio_positivo CHECK (precio >= 0), el error dice qué regla se ha violado y la aplicación puede mapearlo a un mensaje decente.

  1. Formato y legibilidad

Una consulta se escribe una vez y se lee veinte. Compara:

-- ⚠️ INCORRECTA (no por el resultado, sino por lo que cuesta leerla y modificarla)
select c.nombre,sum(l.cantidad*l.precio_unitario*(1-l.descuento)) from categorias c,productos p,
lineas_pedido l where c.id=p.categoria_id and p.id=l.producto_id group by c.nombre order by 2 desc;

-- ✅ CORRECTA
SELECT cat.nombre                                                            AS categoria,
       ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2)  AS facturacion
FROM   lineas_pedido AS lp
JOIN   productos     AS p   ON p.id   = lp.producto_id
JOIN   categorias    AS cat ON cat.id = p.categoria_id
GROUP  BY cat.nombre
ORDER  BY facturacion DESC;
categoria facturacion
Alimentación 256.27
Bebidas 195.28
Cosmética natural 156.32
Hogar sostenible 88.58
Higiene personal 31.50

Cinco categorías: Complementos no aparece porque su único producto está descatalogado y nunca se vendió, y un INNER JOIN no inventa filas. Las dos consultas devuelven lo mismo; solo una se puede modificar sin releerla entera. Las reglas que aplica la segunda:

  • Una cláusula por línea, con las palabras clave alineadas a la izquierda. Los ojos encuentran el WHERE sin buscarlo.
  • JOIN explícito, nunca la coma. FROM a, b WHERE a.id = b.a_id es sintaxis de 1989: mezcla la unión con el filtro y, si olvidas la condición, produce un producto cartesiano silencioso (03-06). Con JOIN ... ON, la unión y el filtro están separados.
  • Alias significativos. lp, p, cat se entienden; a, b, c obligan a subir a mirar. Los del curso están fijados desde el módulo 3 y no cambian nunca.
  • AS explícito en los alias de columna. Es opcional en PostgreSQL, y omitirlo hace que una coma olvidada convierta precio, coste en precio AS coste, un error que ninguna herramienta detecta.
  • Palabras clave en mayúsculas, identificadores en minúsculas. No es cosmética: separa de un vistazo el lenguaje de tus datos.
  • Condición de JOIN en orden constante: tabla_nueva.columna = tabla_ya_conocida.columna. Leído en cadena, cuenta el recorrido.

Comentarios: el porqué, no el qué

-- ⚠️ Inútil: repite lo que el código ya dice
-- Suma el importe de las líneas
SELECT SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) FROM lineas_pedido AS lp;

-- ✅ Útil: explica una decisión que no se deduce del código
-- Usamos precio_unitario y no productos.precio: es el precio HISTÓRICO de la venta.
-- Los pedidos 1 y 2 son anteriores a la subida de tarifas de abril de 2025.
SELECT SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)) FROM lineas_pedido AS lp;

Y para lo que sí es del esquema, PostgreSQL tiene el sitio correcto: COMMENT ON, que queda dentro de la base de datos y lo ve cualquiera con \d+.

COMMENT ON COLUMN lineas_pedido.descuento IS 'Fracción entre 0 y 1 (0.10 = 10 %), no porcentaje';
COMMENT ON TABLE  resenas IS 'Sin ñ por portabilidad de identificadores; el dato sí lleva tildes';

Formateadores automáticos

Discutir la indentación en una revisión de código es tiempo tirado. Delégalo:

Herramienta Qué es Nota
pgFormatter (pg_format) Formateador para PostgreSQL, en Perl Muy configurable; hay complemento para los editores habituales
sqlfluff Linter y formateador, en Python Detecta además malas prácticas; entiende plantillas de dbt/Jinja
Formateador del IDE DBeaver, DataGrip, pgAdmin Cómodo, pero cada uno formatea distinto

La recomendación: fija un estilo en un fichero de configuración dentro del repositorio (.sqlfluff, .pg_format) y ejecútalo en el gancho de pre-commit o en la integración continua. Así el estilo deja de ser una opinión y pasa a ser una comprobación automática.

  1. Diseño del esquema

Normalizar por defecto, desnormalizar con motivo

La regla, retomando 01-05 y 05-06: empieza normalizado. La normalización elimina la redundancia, y la redundancia es lo que permite que dos copias del mismo dato acaben diciendo cosas distintas. Desnormaliza solo cuando tengas una medida que lo justifique y un mecanismo que mantenga la copia sincronizada.

TiendaVerde tiene los dos casos, y conviene distinguirlos:

Caso Qué es Veredicto
lineas_pedido.precio_unitario Parece copia de productos.precio No es desnormalización: es un dato histórico distinto. El precio de venta de marzo no es el de hoy
pedidos.total (módulo 10) Suma precalculada de las líneas Sí lo es: hay que mantenerla con un trigger, y si el trigger falla la fila miente

La pregunta que resuelve la duda: ¿el valor puede cambiar por su cuenta después de guardarse? Si no —el precio al que se vendió—, no es redundancia, es historia, y debe guardarse. Si sí —el total del pedido, la valoración media—, es un agregado y tiene coste de mantenimiento.

Tipos: el más restrictivo que sirva

Un tipo es la restricción más barata que existe, porque no cuesta nada comprobarla.

En lugar de Usa Porque
TEXT para todo VARCHAR(n), INTEGER, DATE, NUMERIC Un TEXT acepta "tres" en una cantidad
FLOAT/REAL para dinero NUMERIC(10,2) Coma flotante = errores de redondeo (01-04)
VARCHAR para fechas DATE / TIMESTAMPTZ '31/02/2025' cabe en un VARCHAR
INTEGER 0/1 para banderas BOOLEAN activo = 2 no debería existir
VARCHAR(255) por costumbre La longitud real del dominio 255 es una herencia de MySQL, no una medida

NOT NULL por defecto, NULL como decisión

Retomando 04-03: un NULL es una fuente de complejidad que se propaga por toda la aplicación —agregados que lo ignoran, comparaciones que no son ciertas ni falsas, NOT IN que devuelve cero filas—. Por eso la postura por omisión debe ser NOT NULL, y cada columna nullable ser una decisión que sepas defender.

En TiendaVerde hay tres columnas nulas y las tres tienen un significado explícito: pedidos.empleado_id = pedido web sin comercial; clientes.referido_por_id = llegó por su cuenta; empleados.jefe_id = dirección general. Ninguna significa "no lo sabemos todavía" ni "el formulario venía vacío", y ahí está la diferencia: si un NULL puede significar dos cosas distintas, el diseño está mal.

Claves subrogadas frente a naturales, y el EAV

Clave subrogada (id) Clave natural (email, NIF)
Estabilidad Total: nada la cambia El cliente cambia de correo
Tamaño de las FK Pequeño (4-8 bytes) El del texto, repetido en cada tabla hija
Legibilidad de una FK Ninguna: hay que unir para saber quién es Se lee directamente
Unicidad de negocio No la garantiza: hace falta un UNIQUE aparte La garantiza por definición

El criterio: id subrogado como PK, y la clave natural protegida con UNIQUE. Es exactamente lo que hace clientes (PK id, UNIQUE(email)), y así tienes las dos cosas: estabilidad y unicidad de negocio.

Y el antipatrón que hay que reconocer para huir de él: el EAV (Entity-Attribute-Value), una tabla atributos(entidad_id, nombre_atributo, valor) que promete flexibilidad infinita. El precio: todo es texto, no hay tipos ni restricciones, consultar tres atributos son tres autouniones, y nadie sabe qué atributos existen. Si los campos varían de verdad, la respuesta moderna es JSONB (10-06); si no varían, son columnas.

CHECK o tabla de catálogo en lugar de texto libre

pedidos.estado podría ser un VARCHAR sin más, y a los seis meses tendrías entregado, Entregado, ENTREGADO y entergado. Hay dos formas de impedirlo:

CHECK (estado IN (...)) Tabla de catálogo + FK
Añadir un valor ALTER TABLE (migración, 05-06) Un INSERT
Guardar atributos del valor No puede Sí: orden, color, si es final
Traducir a otros idiomas No
Cuándo usarlo Lista cerrada y corta que casi nunca cambia Lista que crece o tiene atributos propios

TiendaVerde usa CHECK para estado y metodo_pago porque son cinco y cuatro valores que forman parte del modelo de negocio. Un catálogo de países o de categorías, en cambio, es una tabla.

  1. Fiabilidad: dónde viven las garantías

La regla que separa una base sana de una enferma: la integridad vive en la base de datos, no solo en la aplicación.

Es tentador pensar "ya valido yo en el formulario". No basta, por cuatro motivos que se cumplen siempre: llegará una segunda aplicación (el panel de administración, un guion de migración, una integración); alguien ejecutará un UPDATE a mano en psql una noche; habrá un bug en la validación de la aplicación; y habrá concurrencia, y una comprobación "leo y luego escribo" hecha en la aplicación tiene una condición de carrera que solo una restricción UNIQUE cierra de verdad (09-04).

Garantía Sitio correcto En TiendaVerde
"Este cliente existe" FK pedidos.cliente_id REFERENCES clientes(id)
"No hay dos correos iguales" UNIQUE clientes.email
"La puntuación va de 1 a 5" CHECK chk_resenas_puntuacion
"El campo es obligatorio" NOT NULL pedidos.fecha_pedido
"No se puede pedir más stock del que hay" Transacción + bloqueo, o trigger sp_confirmar_pedido (10-04)
"El mensaje de error debe ser bonito" Aplicación Traducir el error de la restricción

La aplicación también valida: para dar mensajes útiles y no hacer viajes inútiles al servidor. Pero valida además, no en lugar de.

Dos detalles más. DEFAULT sensatos: stock DEFAULT 0, activo DEFAULT TRUE, descuento DEFAULT 0 evitan que un INSERT incompleto meta nulos donde no debe. Y TIMESTAMPTZ con UTC para todo instante: TIMESTAMP sin zona guarda un número sin significado, y en una tienda que vende a España, Portugal y Francia eso se paga el día del cambio de hora. Guarda en UTC, convierte al mostrar. Las fechas de calendario puras —fecha_pedido, fecha_registro— sí son DATE, porque el 4 de marzo es el 4 de marzo en todas partes.

  1. Proceso: versiones, revisión, pruebas y copias

El esquema es código. Todo lo de la lección 05-06 se resume en una frase: si el esquema de producción no se puede reconstruir desde el repositorio, no tienes control de versiones.

  • Migraciones numeradas, versionadas e inmutables. Cada cambio es un fichero (V007__add_indice_pedidos_fecha.sql), va a Git con el código que lo necesita, y no se edita una vez aplicado: se corrige con una migración nueva. Herramientas: Flyway, Liquibase, Alembic, o las migraciones del ORM (11-05).
  • Cada migración con su vuelta atrás, o al menos con un plan escrito de qué hacer si falla. Y los cambios rompedores, en el patrón expand/contract de 05-06: añadir, desplegar, migrar datos, y solo entonces quitar.
  • Revisión de código también para el SQL. Una consulta de informe merece la misma revisión que una función: es igual de fácil equivocarse y mucho más difícil darse cuenta. Un JOIN que duplica filas produce números, no excepciones.
  • Un entorno de pruebas con datos realistas. Realistas en volumen y forma —un plan sobre 20 filas no dice nada de lo que pasará con 20 millones (08-05)— pero ficticios o anonimizados.

⚠️ No copies datos personales de producción a desarrollo. Es la práctica más extendida y una de las más peligrosas: multiplica las copias de datos reales en portátiles, entornos sin cifrar y volcados que nadie borra. Genera datos sintéticos, o seudonimiza antes de copiar (sustituir nombres y correos, desplazar fechas, redondear importes) — con la advertencia de que la seudonimización mal hecha es reversible. Antes de mover datos personales entre entornos, consúltalo con el responsable de protección de datos o con asesoría jurídica de tu organización. Es el mismo aviso de 05-04, y aquí también aplica.

  • Copias de seguridad probadas. Una copia que no se ha restaurado nunca no es una copia: es un fichero del que supones cosas. Programa una restauración de prueba periódica en un entorno aparte y mide cuánto tarda, porque ese número es tu tiempo real de recuperación. Comprueba también que la copia incluye lo que crees (roles, extensiones, secuencias) y que la retención cubre el tiempo que tardas en detectar un problema: si el borrado se descubre a los diez días y guardas siete, no hay copia.
  • Monitorización de consultas lentas. pg_stat_statements ordenado por tiempo total —no por tiempo medio, que esconde el N+1 (08-04)—, log_min_duration_statement para registrar lo que pase de un umbral, y una revisión periódica del ranking. Sin esto no te enteras de que algo va mal: te lo cuenta un usuario enfadado.

  1. Antipatrones

Antipatrón Por qué duele Qué hacer
SELECT * en producción Trae columnas que nadie usa, impide el Index Only Scan, y se rompe cuando alguien añade una columna Enumerar columnas. SELECT * solo para explorar en psql (08-04)
La misma regla de negocio en cinco sitios El día que cambia el IVA hay que encontrar los cinco Una vista o una función que sea la única definición (10-01, 10-04)
DELETE/UPDATE sin WHERE en producción Borra la tabla entera y ya está BEGIN primero, SELECT la misma condición, comprobar el recuento y luego COMMIT (05-04). Y \set AUTOCOMMIT off en psql
Construir SQL concatenando cadenas Inyección SQL, sin más Consultas parametrizadas → 11-03
Consultas dentro de un bucle El N+1: 21 o 501 consultas para pintar una pantalla Un JOIN, o carga anticipada del ORM → 11-05
Índices "por si acaso" Cada índice frena todas las escrituras y ocupa disco; los que no se usan solo cuestan Crear con una consulta concreta delante; revisar pg_stat_user_indexes (08-02)
"Lo arreglo directamente en producción" El cambio no está en el repositorio: al siguiente despliegue desaparece, o al revés, la migración choca Corregir en una migración y desplegarla. Sin excepciones
Lógica de negocio en un trigger sorpresa Un INSERT hace cosas que no están en el código y nadie las encuentra Triggers para integridad y auditoría; la lógica visible, en el código (10-05)
Un NULL que significa varias cosas "No lo sé", "no aplica" y "cero" no son lo mismo Separar en columnas o valores explícitos (04-03)
Redondear al final de una cadena de medias Media de medias, errores acumulados Redondear solo al presentar (11-04)

  1. Checklist de revisión de una consulta

Antes de dar una consulta por buena, en este orden:

Corrección

  1. ¿Los JOIN multiplican filas? Comprueba el recuento antes y después de cada uno: si pedidos pasa de 20 a 47, estás sumando líneas, no pedidos.
  2. ¿Hay LEFT JOIN donde el negocio admite ausencia? Los 10 pedidos web sin comercial desaparecen con un INNER JOIN.
  3. ¿Qué pasa con los NULL? En el WHERE, en el NOT IN, en las agregaciones, en las concatenaciones.
  4. ¿El resultado cuadra con una cifra conocida? Si el total de un desglose no da 727,95 €, el desglose está mal.
  5. ¿El ORDER BY es determinista? Sin desempate, dos ejecuciones pueden devolver órdenes distintos.

Rendimiento

  1. ¿Las condiciones son sargables (columna desnuda)? ¿Existen los índices que necesita (08-01)?
  2. ¿Devuelve solo las columnas y las filas que se van a usar? ¿Tiene LIMIT si va a una pantalla?
  3. ¿La has ejecutado con EXPLAIN ANALYZE sobre un volumen realista (08-05)?

Mantenibilidad

  1. ¿Se lee? ¿Alias significativos, una cláusula por línea, AS explícito?
  2. ¿Los comentarios explican por qué, no qué?
  3. ¿Está en el repositorio, con la pregunta de negocio que responde escrita al lado?

Seguridad

  1. ¿Todos los valores del usuario van como parámetros (11-03)?
  2. ¿Devuelve datos personales que quien la ejecuta no debería ver?

Errores Comunes y Consejos

  • Entrecomillar identificadores en el CREATE TABLE. "FechaPedido" te obliga a escribirlo con comillas para siempre. snake_case en minúsculas y se acabó.
  • Mezclar singular y plural, o dos convenciones de FK. El coste no es estético: es tener que consultar el diccionario en cada consulta.
  • Dejar que PostgreSQL nombre las restricciones. El nombre generado acaba en el mensaje de error que ve el usuario y cambia si cambia la columna. Nómbralas: chk_, fk_, uq_, idx_.
  • Guardar dinero en FLOAT o fechas en VARCHAR. Es la decisión que más caro se paga y la más difícil de revertir con datos dentro.
  • Poner toda la validación en la aplicación. Llegará una segunda aplicación, un guion nocturno y una condición de carrera. La integridad va en la base.
  • Usar TIMESTAMP sin zona para instantes. Guarda en UTC con TIMESTAMPTZ y convierte al mostrar.
  • Editar una migración ya aplicada. Los entornos quedan desincronizados en silencio. Se corrige con una migración nueva.
  • Consejo: la primera consulta de cualquier informe es SELECT COUNT(*). Si el recuento cambia al añadir un JOIN, para y averigua por qué antes de seguir.
  • Consejo: automatiza el estilo con sqlfluff o pg_format en la integración continua. Las revisiones de código deberían discutir la lógica, no la indentación.
  • Consejo: escribe la convención en un fichero del repositorio. Media página basta, y convierte "así lo hacemos" en algo que un compañero nuevo puede leer.

Ejercicios

Ejercicio 1

Este esquema es real en el sentido de que se parece mucho a lo que te vas a encontrar. Enumera todos los problemas de nomenclatura, tipo y diseño, y reescríbelo.

CREATE TABLE "Pedidos_Cliente" (
    "ID"        VARCHAR(50) PRIMARY KEY,
    "user"      VARCHAR(255),
    order_date  VARCHAR(20),
    total       FLOAT,
    "Estado"    VARCHAR(255),
    no_activo   INTEGER,
    datos       TEXT
);

Ejercicio 2

Tu equipo discute dónde poner la regla "un pedido cancelado no puede recibir líneas nuevas". Hay tres propuestas: (a) validarlo en el formulario web; (b) un CHECK en lineas_pedido; (c) un trigger BEFORE INSERT. (1) ¿Cuál funciona y cuál no, y por qué? (2) ¿Cuál elegirías y qué harías además? (3) ¿Cambia la respuesta si el sistema tiene también un panel de administración y un proceso nocturno de importación?

Ejercicio 3

Recibes esta consulta en una revisión de código, con la nota "da el número de pedidos y la facturación por comercial". Aplícale el checklist del apartado 7 y di qué está mal.

select e.nombre, count(*), sum(l.cantidad*l.precio_unitario)
from empleados e, pedidos p, lineas_pedido l
where e.id=p.empleado_id and p.id=l.pedido_id
group by e.nombre;

Soluciones

Solución 1

Los problemas, uno a uno: identificadores entrecomillados con mayúsculas ("Pedidos_Cliente", "ID", "Estado"), que obligan a escribir comillas para siempre; "user" es palabra reservada; mezcla de idiomas (order_date junto a Estado); ID VARCHAR(50) como PK cuando debería ser un entero subrogado; order_date VARCHAR en lugar de DATE; total FLOAT para dinero; VARCHAR(255) por costumbre; no_activo en negativo y como INTEGER en vez de BOOLEAN; datos TEXT que no dice qué contiene; ninguna FK hacia clientes; ningún NOT NULL, ningún CHECK y ninguna restricción nombrada.

CREATE TABLE pedidos_cliente (
    id           INTEGER       GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    cliente_id   INTEGER       NOT NULL
                 CONSTRAINT fk_pedidos_cliente_clientes REFERENCES clientes(id) ON DELETE RESTRICT,
    fecha_pedido DATE          NOT NULL,
    total        NUMERIC(10,2) NOT NULL DEFAULT 0
                 CONSTRAINT chk_pedidos_cliente_total CHECK (total >= 0),
    estado       VARCHAR(20)   NOT NULL
                 CONSTRAINT chk_pedidos_cliente_estado
                 CHECK (estado IN ('pendiente','pagado','enviado','entregado','cancelado')),
    activo       BOOLEAN       NOT NULL DEFAULT TRUE,
    observaciones TEXT
);

Y una pregunta que hay que hacerse antes de escribir nada: total es un agregado precalculado. Si se puede sumar de las líneas, quizá no debería existir; y si existe por rendimiento, hace falta el mecanismo que lo mantenga (10-05) y la decisión escrita de por qué.

Solución 2

1. La (b) no funciona: un CHECK solo puede mirar la fila que se está insertando, y el estado del pedido está en otra tabla. PostgreSQL no permite subconsultas en un CHECK precisamente porque no podría garantizar que siga siendo cierto cuando cambie la otra tabla. La (a) funciona pero no protege: cubre un camino de entrada de los varios que hay. La (c) sí funciona: un trigger BEFORE INSERT sobre lineas_pedido puede consultar pedidos.estado y lanzar RAISE EXCEPTION (10-05).

2. El trigger, y además la validación en el formulario, para dar un mensaje decente sin ir al servidor. La base garantiza; la interfaz explica. Y hay que pensar el caso simétrico —¿qué pasa si el pedido se cancela después de tener líneas?—, que ese trigger no cubre.

3. No cambia la respuesta, la refuerza. Con tres vías de entrada, la validación en el formulario protege una de tres, y precisamente el proceso nocturno es el que insertará miles de filas sin que nadie las mire. Ese es el argumento entero del apartado 4.

Solución 3

Cuatro problemas de corrección y varios de forma. (1) El COUNT(*) está mal: al unir con lineas_pedido, cada pedido aparece tantas veces como líneas tiene, así que no cuenta pedidos sino líneas. Tiene que ser COUNT(DISTINCT p.id). (2) Falta el descuento: el importe es cantidad * precio_unitario * (1 - descuento), y sin él la facturación sale inflada. (3) INNER JOIN con empleados deja fuera los 10 pedidos web: si el informe quiere "por comercial", hay que decidir explícitamente si esos pedidos se excluyen o aparecen como "Web" con un LEFT JOIN y COALESCE. (4) GROUP BY e.nombre agrupa por nombre de pila: dos comerciales llamados igual se fundirían en una fila. Hay que agrupar por e.id. En la forma: sintaxis de comas en lugar de JOIN, sin AS ni alias de columna, sin mayúsculas y sin ORDER BY.

SELECT e.id, e.nombre || ' ' || e.apellidos                                  AS comercial,
       COUNT(DISTINCT pe.id)                                                 AS pedidos,
       ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2)  AS facturacion
FROM   empleados     AS e
JOIN   pedidos       AS pe ON pe.empleado_id = e.id
JOIN   lineas_pedido AS lp ON lp.pedido_id   = pe.id
GROUP  BY e.id, e.nombre, e.apellidos
ORDER  BY facturacion DESC;
id comercial pedidos facturacion
5 Laia Puig Sanchis 4 191.63
4 Óscar Peris Blasco 4 132.90
6 Marc Estévez Roig 2 54.30

Tres comerciales, 10 pedidos y 378,83 € — el canal telefónico. Los otros 349,12 € son los 10 pedidos web sin comercial, y que no aparezcan aquí es ahora una decisión, no un descuido.

Conclusión

Esta lección no añadía sintaxis: añadía criterio.

  • Nomenclatura: snake_case en minúsculas y solo ASCII; singular o plural da igual, mezclarlos no; id para la PK y <tabla>_id para la FK, con el nombre del papel en las reflexivas; booleanos en afirmativo; nada de palabras reservadas ni de datos/tabla1; y restricciones e índices nombrados a mano con chk_, fk_, uq_, idx_.
  • Formato: una cláusula por línea, JOIN ... ON explícito en lugar de la coma, alias significativos, AS explícito, palabras clave en mayúsculas, comentarios que explican el porqué, COMMENT ON para lo que es del esquema, y un formateador automático configurado en el repositorio.
  • Diseño: normalizar por defecto y desnormalizar con una medida y un mecanismo detrás; el tipo más restrictivo que sirva; NOT NULL por defecto y cada NULL con un significado único; id subrogado más UNIQUE sobre la clave natural; nada de EAV; y CHECK para listas cerradas, tabla de catálogo para las que crecen.
  • Fiabilidad: la integridad vive en la base de datos, porque llegará una segunda aplicación, un UPDATE a mano, un bug y una condición de carrera. DEFAULT sensatos y TIMESTAMPTZ en UTC.
  • Proceso: migraciones versionadas e inmutables, revisión de código también para el SQL, entorno de pruebas con datos ficticios o anonimizados —nunca copias de producción con datos personales—, copias de seguridad probadas (una copia sin restaurar no es una copia) y monitorización de consultas lentas por tiempo total.
  • Y el checklist de trece puntos, que empieza por la pregunta que más errores evita: ¿este JOIN multiplica filas?

Dos de los antipatrones de la tabla se han quedado con una promesa: la construcción de SQL por concatenación y el "quién puede ver qué" que ha ido apareciendo desde el módulo 5. En la lección siguiente, Seguridad: inyección SQL, permisos y roles, se cierran los dos: qué es exactamente una inyección SQL y por qué ocurre; la defensa que funciona de verdad —las consultas parametrizadas— escrita en cuatro lenguajes; lo que no es una defensa; el caso especial de los identificadores dinámicos; y después el modelo de permisos de PostgreSQL al completo, con GRANT, REVOKE, roles, ALTER DEFAULT PRIVILEGES, seguridad a nivel de fila y un diseño de roles concreto para TiendaVerde.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados