18  9. Necesitamos hablar SQL de verdad

El ORM viene después; primero hay que saber qué genera

NotaEn una frase

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               -- quit

18.3 Conceptos nuevos

  • D SELECT / WHERE / ORDER BY / LIMIT — leer con filtros
  • D INSERT / UPDATE / DELETE — y por qué UPDATE sin WHERE es un incidente con nombre
  • D JOINs: INNER vs LEFT — 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:

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.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 ids

RETURNING 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 tasks

4. 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 WHERE se escribe antes que el UPDATE — literal: redacta WHERE public_id = '...' y úsalo en un SELECT que devuelva exactamente la fila esperada; recién entonces conviértelo en UPDATE.
  • RETURNING para 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):

  1. Lista las tareas del usuario 2 que vencen esta semana (due_date < now() + interval '7 days'), abiertas, por fecha.
  2. Cuenta cuántas tareas tiene cada tag (GROUP BY).
  3. Escribe el INSERT de una tarea con RETURNING y explica qué devuelve.
  1. 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;
  2. 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;
  3. INSERT INTO tasks (user_id, title, priority)
    VALUES (2, 'Write chapter 9', 1)
    RETURNING id, public_id, created_at;
    Devuelve una fila con los valores generados por la BD — sin un segundo SELECT para conocerlos.

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.

18.12 Lo que deberías saber hacer ahora