flowchart LR F["FROM<br/><i>¿de qué tabla(s)?</i>"] --> J["JOIN<br/><i>¿qué cruzo?</i>"] J --> W["WHERE<br/><i>¿qué filas?</i>"] W --> G["GROUP BY<br/><i>¿agrupo?</i>"] G --> S["SELECT<br/><i>¿qué columnas?</i>"] S --> O["ORDER BY / LIMIT<br/><i>¿cómo salen?</i>"]
18 9. Necesitamos hablar SQL de verdad
El ORM viene después; primero hay que saber qué genera
Antes de que cualquier ORM traduzca por ti, escribes las queries a mano en psql. SELECT para leer, INSERT/UPDATE/DELETE para escribir, JOIN para cruzar tablas, GROUP BY para reportes — y la lección de seguridad más importante del libro: nunca concatenes datos del usuario en una query.
18.1 El problema
En el cap. 8 creamos las tablas — están vacías. Ahora hay que usarlas: meter filas, leerlas, cruzarlas. Podríamos saltar directo al ORM (“el framework escribe el SQL por ti”), pero entonces cada query sería magia: no sabrías qué se ejecuta, por qué va lento, ni qué revisar cuando algo falla. Primero se aprende el idioma; después el traductor.
Un framework es un esqueleto de aplicación ya decidido: te da la estructura (rutas, validación, errores) y tú llenas la lógica. Diferencia con librería: la librería la llamas tú; el framework te llama a ti.
Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.
Un ORM (Object-Relational Mapper) traduce entre objetos del código y filas de la tabla: task.save() en vez de INSERT. Cómodo para el CRUD, peligroso si no sabes qué SQL genera (el N+1 nace ahí).
18.2 Cómo lo resuelve un equipo
psql es la terminal de PostgreSQL — el equivalente a tu shell, pero el idioma es SQL. Todo backend dev tiene una abierta siempre: inspeccionar datos reales, probar una query antes de meterla en código, arreglar una fila a mano. Los comandos de orientación primero:
La terminal (consola, línea de comandos) es la interfaz de texto con el sistema operativo: escribes comandos, lees resultados. Es como hablarle a la computadora por cartas en vez de señalar con el mouse.
psql "postgresql://app:dev@localhost:5432/taskflow"\dt -- list tables
\d tasks -- describe the tasks table (columns, constraints)
\q -- quit18.3 Conceptos nuevos
- D
SELECT/WHERE/ORDER BY/LIMIT— leer con filtros - D
INSERT/UPDATE/DELETE— y por quéUPDATEsinWHEREes un incidente con nombre - D JOINs:
INNERvsLEFT— cruzar tablas por sus FKs - D
COUNT,GROUP BY,HAVING— reportes sin traer toda la tabla - D Inyección SQL: el error clásico y por qué los parámetros lo matan
18.4 La explicación visual
La anatomía de una query — siempre en este orden mental:
18.5 Implementación
Las 6 queries que Taskflow necesita, escritas a mano en psql. Lee cada una preguntándote: ¿de dónde, qué filas, qué columnas?
1. Insertar — INSERT ... RETURNING:
INSERT INTO users (email, password_hash)
VALUES ('ana@taskflow.dev', '$2b$10$...')
RETURNING id, public_id; -- give me back the generated idsRETURNING es el truco de Postgres: te devuelve lo que acabas de crear sin una segunda query.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
2. Leer con filtro — SELECT ... WHERE:
SELECT public_id, title, due_date
FROM tasks
WHERE user_id = 1 AND done = FALSE
ORDER BY priority ASC, due_date ASC NULLS LAST
LIMIT 20;3. Actualizar — el WHERE es tu cinturón:
UPDATE tasks
SET done = TRUE
WHERE public_id = '7f3a9c2e-...' AND user_id = 1;
-- the user_id check: you can only touch YOUR tasks4. Cruzar tablas — JOIN:
SELECT t.title, tg.name AS tag
FROM tasks t
JOIN task_tags tt ON tt.task_id = t.id
JOIN tags tg ON tg.id = tt.tag_id
WHERE t.user_id = 1;INNER JOIN trae solo filas con pareja en ambos lados; LEFT JOIN trae todas las de la izquierda aunque no tengan pareja (NULL en las columnas de la derecha) — útil para “tareas con o sin etiquetas”.
Un JOIN combina tablas por su relación: “cada tarea con el nombre de su proyecto”. Es la superpotencia relacional — en una query traes lo que en NoSQL serían varios viajes.
5. Reportes — GROUP BY:
SELECT u.email, COUNT(t.id) AS open_tasks
FROM users u
LEFT JOIN tasks t ON t.user_id = u.id AND t.done = FALSE
GROUP BY u.id, u.email
HAVING COUNT(t.id) > 0
ORDER BY open_tasks DESC;“Tareas abiertas por usuario” sin traer las tareas — la BD cuenta, tú recibes el resumen.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
6. La lección de seguridad — inyección SQL:
# NEVER — the user types: ' OR '1'='1
query = f"SELECT * FROM users WHERE email = '{email}'"
# becomes: WHERE email = '' OR '1'='1' → returns EVERY user
# ALWAYS — the driver sends data SEPARATE from code
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))Con parámetros, el dato del usuario viaja apartado de la query — la BD lo trata como texto, jamás como código. Toda la sección de seguridad del libro cuelga de esta regla.
El parámetro es el hueco declarado en la función (def f(x) — x es parámetro); el argumento es el valor concreto que le pasas (f(42) — 42 es argumento). Mismo dato, dos momentos: declaración vs uso.
Enséñame SQL practicando sobre este esquema: users(id, email),
tasks(id, user_id→users, title, priority 1-5, due_date, done),
tags(id, name), task_tags(task_id, tag_id). Para cada una de estas
necesidades dame la query y explícala línea por línea: (1) tareas
abiertas de un usuario ordenadas por prioridad, (2) tareas con sus
tags, (3) usuarios con conteo de tareas abiertas incluyendo los que
tienen cero, (4) marcar una tarea como done solo si pertenece al
usuario. Luego muéstrame cómo se vería cada query parametrizada y por
qué concatenar strings es inyección SQL.
18.6 ¿Por qué SQL primero y ORM después?
Lo único que necesitas llevarte: el ORM genera SQL — si no sabes leerlo, cada ORM es una caja negra que no puedes depurar. Con SQL en la cabeza, el ORM es un atajo que entiendes; sin él, es una dependencia que temes.
Una dependencia es código de terceros que tu proyecto usa: npm install, pip install, go get las traen. Cada una es deuda — ahora funciona, pero hay que mantenerla, actualizarla y confiar en ella.
El diagrama de Venn clásico miente un poco — un JOIN no es “intersección de conjuntos”, es multiplicación con filtro: cada fila de tasks se empareja con cada fila de tags que cumpla el ON. Si una tarea tiene 3 tags, aparece 3 veces en el resultado (una por tag). Por eso “la lista de tareas” con JOIN a tags devuelve filas repetidas — y por eso el cap. 12 necesita hablar de cómo traer relaciones sin duplicar.
18.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
UPDATE/DELETE sin WHERE |
Enter, pánico | Escribe el WHERE primero, luego el UPDATE; haz SELECT con ese WHERE antes |
| Concatenar strings en queries | “Es un string más” | Parámetros siempre — el dato nunca toca el SQL |
SELECT * por costumbre |
Traes columnas que no usas (y password_hash de regalo) |
Nombra las columnas — es tu contrato de salida del cap. 5 aplicado a la BD |
| Probar la query solo en código | El error sale en el handler, lejos de la query | Pruébala en psql primero — ahí el error es inmediato |
LEFT JOIN cuando era INNER |
“Salen filas de más con NULLs” | Pregúntate: ¿quiero filas sin pareja? |
18.8 Buenas prácticas
- El
WHEREse escribe antes que elUPDATE— literal: redactaWHERE public_id = '...'y úsalo en unSELECTque devuelva exactamente la fila esperada; recién entonces conviértelo en UPDATE. RETURNINGpara saber qué creaste — una sola ida a la BD.- Alias cortos pero legibles (
tasks t,users u) — las queries de JOIN se leen como nombres completos. - Formato consistente: keywords en mayúscula, una cláusula por línea. La query que lees en un incidente a las 3am agradece el aire.
18.9 Ejercicio
En psql (o en papel):
- Lista las tareas del usuario 2 que vencen esta semana (
due_date < now() + interval '7 days'), abiertas, por fecha. - Cuenta cuántas tareas tiene cada tag (
GROUP BY). - Escribe el
INSERTde una tarea conRETURNINGy explica qué devuelve.
SELECT title, due_date FROM tasks WHERE user_id = 2 AND done = FALSE AND due_date < now() + interval '7 days' ORDER BY due_date ASC;SELECT tg.name, COUNT(tt.task_id) AS task_count FROM tags tg JOIN task_tags tt ON tt.tag_id = tg.id GROUP BY tg.id, tg.name ORDER BY task_count DESC;- Devuelve una fila con los valores generados por la BD — sin un segundo SELECT para conocerlos.
INSERT INTO tasks (user_id, title, priority) VALUES (2, 'Write chapter 9', 1) RETURNING id, public_id, created_at;
18.10 Mini reto
Escribir la query “tareas pendientes por usuario, ordenadas por prioridad” — y luego la versión peligrosa que un junior escribiría concatenando el user_id.
SELECT title, priority
FROM tasks
WHERE user_id = $1 AND done = FALSE -- $1: parameter, never concatenated
ORDER BY priority ASC;La versión peligrosa: "WHERE user_id = " + userInput. Si userInput es 0 OR 1=1, la query devuelve las tareas de todos los usuarios. El parámetro $1/%s envía el dato por un canal separado — la BD no puede confundirlo con SQL.
18.11 Vocabulario técnico del capítulo
Un array es una colección ordenada de elementos accedidos por posición: ["a","b","c"][0] es "a" (se cuenta desde 0). Python las llama listas, Go slices — misma idea, distinto acento.
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
Paginar es servir la lista en páginas (20 por 20) en vez de las 10 millones. LIMIT 21 + cursor = la página siguiente se pide con la última fila vista, no con “salta 40000” (offset, que se degrada).
Una constraint es una regla que la BD hace cumplir: NOT NULL, UNIQUE, CHECK (price > 0), FOREIGN KEY. Es la última línea de defensa — el código puede tener bugs; la constraint no perdona.
La inyección SQL es el clásico de romper la query: si concatenas input del usuario en el SQL, el usuario escribe SQL. ' OR 1=1-- borra tablas. La vacuna: queries parametrizadas ($1), siempre.
Una traza sigue un request a través de todo el sistema: entró por el gateway → llamó auth → consultó la BD → tardó 340ms en la query. Cuando algo anda lento, la traza dice exactamente dónde.
Los tipos (int, string, boolean, Task) son el contrato de cada dato. Tipado estático (TypeScript, Go, Python con hints) = los tipos se revisan al escribir, no cuando explota en producción.
Un puerto es una puerta numerada dentro de una computadora: la IP llega al edificio, el puerto al departamento. :3000 = “la app que escucha en el departamento 3000”. Postgres suele usar 5432, HTTP el 80, HTTPS el 443.
localhost significa “esta misma máquina” — es la dirección que tu computadora usa para hablarse a sí misma. Cuando desarrollas, el “servidor” y el “cliente” viven en tu laptop: por eso todo es localhost:3000.
El esquema es el plano de la base de datos: qué tablas hay, qué columnas tiene cada una y de qué tipo. Es contrato: una fila que no cumple el esquema no entra. Se cambia con migraciones, no a mano.