Módulo 4 · Lección 13 de 45
Relaciones con ForeignKey
Cuando un dato repetido deja de ser un texto y pasa a ser otra tabla, y la pregunta que Django te obliga a contestar.
El campo grupo de la agenda ha sido hasta ahora un texto con choices. Funciona mientras los grupos los decidas tú al escribir el código. En cuanto quien usa la aplicación tenga que poder crear grupos nuevos, renombrarlos o borrarlos, ese texto se queda corto: los grupos dejan de ser una lista fija en el código y pasan a ser datos, y los datos van en una tabla.
Ese paso, de un texto repetido a una tabla aparte con una referencia, es la operación más característica de una base de datos relacional. En Django se escribe con ForeignKey, y trae con ella una pregunta obligatoria que conviene entender antes de contestarla a la ligera.
Cuándo toca#
La señal es siempre la misma: un valor que se repite en muchas filas y que alguien querrá cambiar en un solo sitio.
Con grupo como texto, la agenda tiene doscientas filas donde pone trabajo. Renombrarlo a Profesional es un UPDATE masivo. Escribir Travajo por error en una es un grupo nuevo que nadie ha pedido y que sale en los desplegables. Y no hay dónde guardar nada más del grupo: ni un color, ni una descripción, ni el orden en que salen.
Con una tabla aparte, el grupo es una fila con su clave. Las doscientas filas apuntan a esa clave. Renombrar es cambiar una celda, la errata es imposible porque el grupo tiene que existir, y añadirle un color es añadir una columna.
Los dos modelos#
from django.db import models
class Grupo(models.Model):
nombre = models.CharField('nombre', max_length=50, unique=True)
def __str__(self):
return self.nombre
class Contacto(models.Model):
nombre = models.CharField('nombre', max_length=100)
correo = models.EmailField('correo', unique=True)
telefono = models.CharField('teléfono', max_length=20, blank=True)
grupo = models.ForeignKey(
Grupo,
on_delete=models.PROTECT,
related_name='contactos',
null=True,
blank=True,
verbose_name='grupo',
)
notas = models.TextField('notas', blank=True)
creado = models.DateTimeField('creado', auto_now_add=True)
def __str__(self):
return self.nombreDos cosas del orden de las clases. Grupo va arriba porque Contacto la nombra, y en Python una clase tiene que existir antes de usarla. Cuando dos modelos se apuntan mutuamente, o cuando uno se apunta a sí mismo, se pone el nombre entre comillas: models.ForeignKey('Grupo', ...), y Django lo resuelve después.
Y fíjate en null=True, blank=True juntos. Es la regla de la lección 9: en un campo que no es de texto, "no hay valor" se dice con NULL, y para que además sea opcional en el formulario hace falta el blank.
Lo que aparece en la base de datos#
CREATE TABLE "contactos_contacto" (
...
"grupo_id" bigint NULL REFERENCES "contactos_grupo" ("id") DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX "contactos_contacto_grupo_id_dc60af8d" ON "contactos_contacto" ("grupo_id");Tres cosas que no has escrito:
La columna se llama grupo_id, no grupo. Django le añade el sufijo porque lo que guarda no es un grupo, es su clave. En Python tienes las dos:
>>> c = Contacto.objects.get(correo='ana@ejemplo.es')
>>> c.grupo
<Grupo: Trabajo>
>>> c.grupo_id
1c.grupo hace una consulta si el grupo no está ya cargado; c.grupo_id está en la fila y no consulta nada. Cuando solo necesitas el identificador —para comparar, para construir una URL— usar grupo_id ahorra una consulta.
REFERENCES es una restricción de verdad. La base de datos no dejará guardar un grupo_id que no exista. A diferencia de choices, esto sí lo hace cumplir el gestor.
Se crea un índice solo. ForeignKey pone db_index=True por defecto, porque una clave ajena se usa constantemente para unir tablas.
Recorrer la relación#
Hacia el grupo, que es lo evidente:
>>> c.grupo.nombre
'Trabajo'Y hacia atrás, del grupo a sus contactos, que es lo que da related_name:
>>> trabajo = Grupo.objects.get(nombre='Trabajo')
>>> trabajo.contactos.all()
<QuerySet [<Contacto: Ana Ruiz>, <Contacto: Zoe Alvarez>]>
>>> trabajo.contactos.count()
2trabajo.contactos no es una lista: es un gestor, con los mismos métodos que Contacto.objects. Se puede filtrar, contar y ordenar, y todo ocurre en la base de datos.
Filtrar cruzando tablas#
Los dos guiones bajos de la lección anterior atraviesan también las relaciones:
>>> Contacto.objects.filter(grupo__nombre='Trabajo')
<QuerySet [<Contacto: Ana Ruiz>, <Contacto: Zoe Alvarez>]>
>>> Contacto.objects.filter(grupo__isnull=True)
<QuerySet [<Contacto: Sin grupo>]>Eso es un JOIN, escrito por Django. Y funciona en las dos direcciones y encadenando tantos niveles como haga falta: filter(grupo__nombre__icontains='trab').
Contar en la dirección contraria es annotate sobre el nombre de la relación:
>>> from django.db.models import Count
>>> Grupo.objects.annotate(n=Count('contactos')).values_list('nombre', 'n')
[('Trabajo', 2), ('Familia', 1)]on_delete: la pregunta obligatoria#
on_delete no tiene valor por defecto. Django obliga a escribirlo, y la razón es que no hay respuesta buena en general: qué pasa con los contactos cuando se borra su grupo depende de lo que signifique ese grupo.
| Valor | Qué hace al borrar el grupo |
|---|---|
CASCADE | Borra también sus contactos |
PROTECT | Impide borrar el grupo, con un error |
RESTRICT | Como PROTECT, pero permite el borrado si otra cascada ya se lo lleva |
SET_NULL | Deja los contactos con el grupo a NULL. Exige null=True |
SET_DEFAULT | Les pone el valor por defecto. Exige default |
DO_NOTHING | Nada, y la base de datos se queja |
La forma de elegir es preguntarse si la fila que apunta tiene sentido sin aquella a la que apunta:
- Un comentario no tiene sentido sin su artículo:
CASCADE. Si se borra el artículo, sus comentarios sobran. - Un contacto sí tiene sentido sin su grupo: no puede ser
CASCADE. Borrar el grupo "Trabajo" y llevarse por delante a cuarenta personas sería un desastre silencioso.
Para la agenda, PROTECT o SET_NULL. PROTECT obliga a vaciar el grupo antes de borrarlo, lo que da la oportunidad de decidir qué hacer con esos contactos:
>>> Grupo.objects.get(nombre='Trabajo').delete()
ProtectedError: ("Cannot delete some instances of model 'Grupo' because they are
referenced through protected foreign keys: 'Contacto.grupo'.", ...)SET_NULL es más cómodo y más peligroso: borra el grupo y los contactos se quedan sin ninguno, sin preguntar.
El problema N+1#
Este es el fallo de rendimiento más habitual de Django, y lo que lo hace peligroso es que el código que lo provoca parece perfectamente razonable:
for contacto in Contacto.objects.all():
print(contacto.grupo.nombre)Medido con cuatro contactos:
N+1 sin select_related: 4 consultasUna consulta para traer los contactos, y una más por cada contacto para traer su grupo. Con cuatro filas son cuatro consultas; con quinientas son quinientas una. De ahí el nombre: N más uno.
No hay nada raro en el código. El problema es que contacto.grupo es perezoso igual que todo lo demás: va a buscar el grupo cuando alguien lo pide, y dentro de un bucle alguien lo pide N veces.
La solución es una palabra:
for contacto in Contacto.objects.select_related('grupo'):
print(contacto.grupo.nombre)con select_related: 1 consultasselect_related le dice al ORM que traiga el grupo en la misma consulta, con un JOIN. Una consulta en total, da igual cuántas filas haya.
Asignar la relación#
Tres formas, y una que no funciona:
contacto.grupo = trabajo # con el objeto
contacto.grupo_id = 1 # con la clave, sin consultar
Contacto.objects.create(nombre='X', correo='x@e.es', grupo_id=2)La segunda es útil cuando ya tienes el identificador, porque evita traer el grupo solo para volver a guardarlo.
Y la que falla:
>>> g = Grupo(nombre='Nuevo')
>>> Contacto.objects.create(nombre='X', correo='x@e.es', grupo=g)
ValueError: save() prohibited to prevent data loss due to unsaved related object 'grupo'.El grupo no está guardado, así que no tiene clave, así que no hay nada que poner en grupo_id. Django podría guardar la fila con un NULL y perder la relación en silencio; en vez de eso, lo prohíbe. Es un buen mensaje de error: dice el problema y dice por qué no lo arregla por su cuenta.