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
- Nomenclatura
- Formato y legibilidad
- Diseño del esquema
- Fiabilidad: dónde viven las garantías
- Proceso: versiones, revisión, pruebas y copias
- Antipatrones
- Checklist de revisión de una consulta
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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_caseen 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 unCREATE TABLEes una condena.- Solo ASCII en los identificadores. Por eso la tabla es
resenasy noreseñ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 llamadaorderobliga a entrecomillarla en cada consulta. Si el negocio dice "pedido", la tabla se llamapedidos; si dice "usuario",usuarios. - Nombres que dicen algo.
datos,tabla1,temp2,info,campo3,xno significan nada dentro de seis meses. Y sin abreviaturas propias:fecha_pedido, nofec_ped. - Sin prefijo de tipo.
str_nombre,tbl_clientes,int_stockson 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 trampa —WHERE 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_checkopedidos_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. ConCONSTRAINT 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.
- 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
WHEREsin buscarlo. JOINexplícito, nunca la coma.FROM a, b WHERE a.id = b.a_ides sintaxis de 1989: mezcla la unión con el filtro y, si olvidas la condición, produce un producto cartesiano silencioso (03-06). ConJOIN ... ON, la unión y el filtro están separados.- Alias significativos.
lp,p,catse entienden;a,b,cobligan a subir a mirar. Los del curso están fijados desde el módulo 3 y no cambian nunca. ASexplícito en los alias de columna. Es opcional en PostgreSQL, y omitirlo hace que una coma olvidada conviertaprecio, costeenprecio 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
JOINen 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.
- 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 | Sí |
| 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.
- 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.
- 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
JOINque 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_statementsordenado por tiempo total —no por tiempo medio, que esconde el N+1 (08-04)—,log_min_duration_statementpara 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.
- 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) |
- Checklist de revisión de una consulta
Antes de dar una consulta por buena, en este orden:
Corrección
- ¿Los
JOINmultiplican filas? Comprueba el recuento antes y después de cada uno: sipedidospasa de 20 a 47, estás sumando líneas, no pedidos. - ¿Hay
LEFT JOINdonde el negocio admite ausencia? Los 10 pedidos web sin comercial desaparecen con unINNER JOIN. - ¿Qué pasa con los
NULL? En elWHERE, en elNOT IN, en las agregaciones, en las concatenaciones. - ¿El resultado cuadra con una cifra conocida? Si el total de un desglose no da 727,95 €, el desglose está mal.
- ¿El
ORDER BYes determinista? Sin desempate, dos ejecuciones pueden devolver órdenes distintos.
Rendimiento
- ¿Las condiciones son sargables (columna desnuda)? ¿Existen los índices que necesita (08-01)?
- ¿Devuelve solo las columnas y las filas que se van a usar? ¿Tiene
LIMITsi va a una pantalla? - ¿La has ejecutado con
EXPLAIN ANALYZEsobre un volumen realista (08-05)?
Mantenibilidad
- ¿Se lee? ¿Alias significativos, una cláusula por línea,
ASexplícito? - ¿Los comentarios explican por qué, no qué?
- ¿Está en el repositorio, con la pregunta de negocio que responde escrita al lado?
Seguridad
- ¿Todos los valores del usuario van como parámetros (11-03)?
- ¿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_caseen 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
FLOATo fechas enVARCHAR. 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
TIMESTAMPsin zona para instantes. Guarda en UTC conTIMESTAMPTZy 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 unJOIN, para y averigua por qué antes de seguir. - Consejo: automatiza el estilo con
sqlfluffopg_formaten 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_caseen minúsculas y solo ASCII; singular o plural da igual, mezclarlos no;idpara la PK y<tabla>_idpara la FK, con el nombre del papel en las reflexivas; booleanos en afirmativo; nada de palabras reservadas ni dedatos/tabla1; y restricciones e índices nombrados a mano conchk_,fk_,uq_,idx_. - Formato: una cláusula por línea,
JOIN ... ONexplícito en lugar de la coma, alias significativos,ASexplícito, palabras clave en mayúsculas, comentarios que explican el porqué,COMMENT ONpara 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 NULLpor defecto y cadaNULLcon un significado único;idsubrogado másUNIQUEsobre la clave natural; nada de EAV; yCHECKpara 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
UPDATEa mano, un bug y una condición de carrera.DEFAULTsensatos yTIMESTAMPTZen 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
JOINmultiplica 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
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
