Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lab-05-graphql-cache/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.venv/
venv/
*.egg-info/
21 changes: 21 additions & 0 deletions lab-05-graphql-cache/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM python:3.12-slim

COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app

RUN uv pip install --system \
"django>=5.0" \
"strawberry-graphql[django]>=0.240" \
"django-redis>=5.4" \
"psycopg2-binary>=2.9" \
"python-dotenv>=1.0" \
"pytest>=8.0" \
"pytest-django>=4.8" \
"pytest-asyncio>=0.23"

COPY . .

EXPOSE 8000

CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
176 changes: 176 additions & 0 deletions lab-05-graphql-cache/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# Lab 05 — GraphQL + Cache: Strawberry + Redis

API GraphQL sobre Django para consultar rutas, paradas y horarios de transporte público. Implementa DataLoaders para resolver el problema N+1 y caching de queries en Redis.

---

## Stack

| Componente | Tecnología |
|---|---|
| **Lenguaje** | Python 3.12 |
| **Framework** | Django 5 |
| **GraphQL** | Strawberry GraphQL |
| **Cache** | Redis · django-redis |
| **Base de datos** | PostgreSQL 16 |
| **Tests** | pytest · pytest-django · pytest-asyncio |
| **Infraestructura** | Docker · Docker Compose · uv |

---

## Conceptos Demostrados

### GraphQL vs REST
GraphQL permite al cliente especificar exactamente qué campos necesita en cada consulta, eliminando el *over-fetching* (recibir más datos de los necesarios) y el *under-fetching* (necesitar múltiples requests). En lugar de múltiples endpoints REST, un único endpoint `/graphql/` atiende todas las operaciones.

### Problema N+1 y DataLoaders
Sin optimización, resolver `routes { stops { schedules } }` dispara una query por cada ruta y otra por cada parada. Los **DataLoaders** agrupan todas las solicitudes de un mismo nivel en una única query SQL (*batching*), reduciendo N+1 queries a 1.

### Caching en Redis
Las queries de lectura (`routes`, `route`) almacenan su resultado en Redis con TTL de 5 minutos. Las mutaciones (`createRoute`) invalidan las claves afectadas. Esto descarga la base de datos ante tráfico repetitivo.

---

## Estructura

```
lab-05-graphql-cache/
├── docker-compose.yml
├── Dockerfile
├── manage.py
├── pyproject.toml
├── config/
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── apps/
└── transit/
├── models.py # Route · Stop · Schedule
├── schema.py # Strawberry types · Query · Mutation
├── dataloaders.py # StopsByRoute · SchedulesByStop
└── tests/
├── conftest.py
└── test_queries.py
```

---

## Servicios Docker

| Servicio | Puerto (host) | Descripción |
|---|---|---|
| `web` | 8005 | Django + Strawberry GraphQL |
| `db` | 5434 | PostgreSQL 16 |
| `redis` | 6380 | Redis 7 (cache) |

---

## Inicio Rápido

```bash
# Levantar servicios
docker compose up -d

# Crear tablas en la base de datos (primera vez)
docker compose exec web python manage.py makemigrations transit
docker compose exec web python manage.py migrate

# Correr tests
docker compose exec web pytest -v

# Acceder al playground interactivo
# http://localhost:8005/graphql/
```

> **Nota:** `--no-migrations` en `pyproject.toml` aplica únicamente a pytest (crea las tablas directamente desde los modelos). Para el servidor es necesario ejecutar las migraciones con los comandos anteriores.

### Cargar datos de prueba

```bash
docker compose exec web python manage.py shell
```

```python
from apps.transit.models import Route, Stop, Schedule

r = Route.objects.create(name="Ruta Escazú", origin="San José", destination="Escazú")
s1 = Stop.objects.create(name="Parada UCR", lat=9.9381, lon=-84.0505, route=r)
s2 = Stop.objects.create(name="Parada Sabana", lat=9.9320, lon=-84.0950, route=r)
Schedule.objects.create(route=r, stop=s1, departure_time="07:00", stop_order=1)
Schedule.objects.create(route=r, stop=s2, departure_time="07:20", stop_order=2)
```

---

## Queries de Ejemplo

### Listar rutas activas

```graphql
{
routes(activeOnly: true) {
id
name
origin
destination
}
}
```

### Ruta con paradas y horarios (DataLoader en acción)

```graphql
{
route(id: "1") {
name
stops {
name
lat
lon
schedules {
departureTime
stopOrder
}
}
}
}
```

### Paradas cercanas (Haversine)

```graphql
{
stopsNearby(lat: 9.9337, lon: -84.0800, radiusKm: 0.5) {
name
lat
lon
}
}
```

### Crear ruta

```graphql
mutation {
createRoute(
name: "Ruta Exprés Cartago"
origin: "San José"
destination: "Cartago"
) {
id
name
active
}
}
```

---

## Qué Demuestra Este Laboratorio

- **API GraphQL funcional** con queries y mutations sobre modelos Django reales
- **DataLoaders** que eliminan el problema N+1 mediante batching automático de queries SQL
- **Caching en Redis** con invalidación selectiva en mutaciones
- **Resolvers async** que aprovechan el ORM asíncrono de Django 5
- **Distancia geoespacial** con Haversine sin dependencia de PostGIS
- **Tests async** con `pytest-asyncio` + `pytest-django` cubriendo queries, mutaciones y DataLoaders
Empty file.
Empty file.
39 changes: 39 additions & 0 deletions lab-05-graphql-cache/apps/transit/dataloaders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""DataLoaders para resolver el problema N+1 en queries GraphQL.

Sin DataLoaders, resolver `routes { stops { schedules } }` dispara:
- 1 query para todas las rutas
- N queries (una por ruta) para sus paradas
- M queries (una por parada) para sus horarios

Con DataLoaders, cada nivel se agrupa en una sola query SQL (batching).
"""
from collections import defaultdict
from typing import List

from strawberry.dataloader import DataLoader

from apps.transit.models import Schedule, Stop


async def _batch_stops_by_route(route_ids: List[int]) -> List[List[Stop]]:
mapping: dict[int, list[Stop]] = defaultdict(list)
async for stop in Stop.objects.filter(route_id__in=route_ids).order_by("name"):
mapping[stop.route_id].append(stop)
return [mapping[rid] for rid in route_ids]


async def _batch_schedules_by_stop(stop_ids: List[int]) -> List[List[Schedule]]:
mapping: dict[int, list[Schedule]] = defaultdict(list)
async for sched in (
Schedule.objects.filter(stop_id__in=stop_ids).order_by("stop_order", "departure_time")
):
mapping[sched.stop_id].append(sched)
return [mapping[sid] for sid in stop_ids]


def make_loaders() -> dict:
"""Crea instancias frescas por request (el estado del DataLoader es por-request)."""
return {
"stops_by_route": DataLoader(load_fn=_batch_stops_by_route),
"schedules_by_stop": DataLoader(load_fn=_batch_schedules_by_stop),
}
56 changes: 56 additions & 0 deletions lab-05-graphql-cache/apps/transit/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generated by Django 6.0.3 on 2026-03-21 00:36

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
]

operations = [
migrations.CreateModel(
name='Route',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('origin', models.CharField(max_length=100)),
('destination', models.CharField(max_length=100)),
('active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Stop',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('lat', models.FloatField()),
('lon', models.FloatField()),
('route', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stops', to='transit.route')),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Schedule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('departure_time', models.TimeField()),
('stop_order', models.PositiveIntegerField()),
('route', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='transit.route')),
('stop', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='schedules', to='transit.stop')),
],
options={
'ordering': ['stop_order', 'departure_time'],
'unique_together': {('route', 'stop', 'departure_time')},
},
),
]
Empty file.
42 changes: 42 additions & 0 deletions lab-05-graphql-cache/apps/transit/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from django.db import models


class Route(models.Model):
name = models.CharField(max_length=100)
origin = models.CharField(max_length=100)
destination = models.CharField(max_length=100)
active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
ordering = ["name"]

def __str__(self) -> str:
return self.name


class Stop(models.Model):
name = models.CharField(max_length=100)
lat = models.FloatField()
lon = models.FloatField()
route = models.ForeignKey(Route, on_delete=models.CASCADE, related_name="stops")

class Meta:
ordering = ["name"]

def __str__(self) -> str:
return self.name


class Schedule(models.Model):
route = models.ForeignKey(Route, on_delete=models.CASCADE, related_name="schedules")
stop = models.ForeignKey(Stop, on_delete=models.CASCADE, related_name="schedules")
departure_time = models.TimeField()
stop_order = models.PositiveIntegerField()

class Meta:
ordering = ["stop_order", "departure_time"]
unique_together = [("route", "stop", "departure_time")]

def __str__(self) -> str:
return f"{self.route} — {self.stop} @ {self.departure_time}"
Loading
Loading