Introducción

Uno de los principales objetivos de Django es crear contenido de manera rápida. Y una forma de conseguir esto es liberando al programador del acceso directo a la base de datos. En lugar de escribir consultas SQL a mano, Django nos proporciona un mapeo objeto-relacional (ORM por sus siglas en inglés) bastante práctico y sencillo de usar. En este post explicaré algunos conceptos básicos para el trabajo con este ORM. Esta no es una guía definitiva, pero sí un documento que espero te anime a usar Django cuando conozcas lo sencillo que es trabajar con datos.

El modelo de datos

Django crea la asociación (o el mapeo) de las tablas de la base de datos a estructuras y tipos de datos de python usando el modelo de datos. Un modelo es una clase que corresponde al nombre de una tabla en la base de datos, y cada variable de clase es un campo de dicha tabla. En esta clase también se especifican los índices de la tabla, llaves foráneas, ordenamiento de los datos, reglas de validación, entre otras cosas. Cada app de nuestro sistema Django tiene un modelo asociado, y las tablas creadas son del tipo app_nombremodelo; así que si tengo una app contabilidad y mi modelo tiene la clase Cliente, la tabla se llamará contabilidad_cliente.

Migraciones

Cuando el modelo está definido, Django usa las migraciones para crear las tablas y campos necesarios en la base de datos, y si posteriormente hacemos modificaciones al modelo, las migraciones se encargan de replicar estos cambios. Explicar a detalle que son y como funcionan las migraciones da para un post por sí solo (el cual haré más adelante), pero por el momento basta con saber que es el mecanismo usado por Django para mantener al día nuestro modelo con la base de datos.

Implementación del modelo

Todas las clases tienen que heredar de la clase django.db.models.Model, y las variables de clase corresponde a django.db.models.Mi_TipoField(), donde Mi_Tipo puede ser Char, Date, Int, Bool, Email, etc (puedes ver la lista completa de tipos en la referencia de campos del modelo). A continuación un ejemplo de un modelo sencillo, el cual usaremos de base en este post:

from django.db import models


class Cliente(models.Model):
    nombre = models.CharField(max_length=64)
    apellidos = models.CharField(max_length=64)
    rfc = models.CharField(max_length=15, unique=True)
    fecha_nacimiento = models.DateField()
    activo = models.BoolField(default=True)

Para crear la migración ejecutamos el comando:

python manage.py makemigrations

este comando buscará cambios en todos los modelos de nuestras apps y creará las migraciones correspondientes. Y para ejecutarlas usamos el comando:

python manage.py migrate

En nuestro caso tendremos la tabla contabilidad_cliente con los campos id, nombre, apellidos, rfc, fecha_nacimiento y activo.

Nota 1: aunque no definimos el campo id en nuestro modelo, Django lo crea automáticamente por nosotros, y es de tipo entero auto-incremental. Este campo es la llave primaria de nuestra tabla.

Nota 2: Aunque podemos especificar la longitud de campo o valores default, depende de la base de datos que estemos usando el que estas opciones tengan efecto o no.

Llaves foráneas

Relación Uno a Muchos

Sin duda una de las relaciones más usadas. Voy a crear el modelo Factura para ligarlo al modelo Cliente, así podemos decir que un cliente tiene muchas facturas, pero una factura solo le pertenece a un cliente:

from django.db import models


class Cliente(models.Model):
    nombre = models.CharField(max_length=64)
    apellidos = models.CharField(max_length=64)
    rfc = models.CharField(max_length=15, unique=True)
    fecha_nacimiento = models.DateField()
    activo = models.BoolField(default=True)


class Factura(models.Model):
    cliente = models.ForeignKey(Cliente, on_delete=models.CASCADE)
    importe = models.DecimalField(max_digits=8, decimal_digits=2)
    pagada = models.BoolField(default=False)

Nota: en el caso de los campos que son llaves foráneas Django les agrega el sufijo _id, por lo que el campo cliente en la base de datos es contabilidad_factura.cliente_id.

Creación de registros

Ahora que ya sabemos como crear un modelo, el siguiente paso es crear instancias de estos modelos para almacenarlos en la base de datos. Para esto tenemos dos opciones: crear el registro y guardarlo en automático (usando el método create()), o crear el registro (una instancia), y guardarlo a posteriori (usando el método save()).

Usando el método save()

from contabilidad.models import Cliente, Factura
import datetime

fecha_nacimiento = datetime.date(1980, 12, 5)
pedro = Cliente(
    nombre="Pedro",
    apellidos="Aguilar Ramírez",
    rfc="AGRM-801205-111",
    fecha_nacimiento=fecha_nacimiento,
    activo=True,
)
pedro.save()

factura = Factura(cliente=pedro, importe=5690.12, pagada=False)
factura.save()

Cuando creamos la factura, tenemos que pasarle un registro de tipo Cliente por la relación uno a muchos, en este caso nuestro registro es pedro.

Usando el método create()

El método MiModelo.objects.create() en realidad es un wrapper a save(), permitiéndonos crear y guardar en un solo paso el modelo.

from contabilidad.models import Cliente, Factura
import datetime

fecha_nacimiento = datetime.date(1980, 12, 5)
pedro = Cliente.objects.create(
    nombre="Pedro",
    apellidos="Aguilar Ramírez",
    rfc="AGRM-801205-111",
    fecha_nacimiento=fecha_nacimiento,
    activo=True,
)

factura = Factura.objects.create(cliente=pedro, importe=5690.12, pagada=False)

¿Cuál de los dos métodos es el correcto?

Depende de nuestro caso de uso. En la siguiente tabla resumiré cuando usar cada método:

Caso de uso save() create()
Crear un registro (INSERT) X X
Actualizar un registro (UPDATE) X
Requiere una instancia del modelo X
Modificar un campo antes de grabarlo X
No necesita una instancia creada X

Como podemos ver, create() solo se usa cuando vamos a crear un registro nuevo, para todo lo demás, usamos save().

Consultando el modelo

Ahora que ya tenemos creado nuestro modelo y hemos creado algunos registros, tenemos que consultar los datos. Podemos obtener todos los datos de un modelo (tabla), o solo unos cuantos registros. También podemos ver todos los campos del modelo, o solo unos cuantos. Todos los métodos de esta sección regresan un QuerySet con los registros consultados, y podemos iterar sobre este QuerySet para procesar cada registro, y si no encontró registro alguno, entonces tendremos un QuerySet vacío. Veamos unos ejemplos.

Recuperando todos los registros del modelo

Este es el caso más simple, solo tenemos que usar el método all() de nuestro modelo:

from contabilidad.models import Cliente, Factura

clientes = Cliente.objects.all()
for cliente in clientes:
    print(f"Nombre: {cliente.nombre}, Apellidos: {cliente.apellidos}")

y en el caso de los modelos con relaciones, también podemos accesar a los datos del modelo relacionado, usando el nombre del campo que tiene la relación, seguido de un doble guión bajo (__) y por último el campo del otro modelo:

from contabilidad.models import Cliente, Factura

facturas = Factura.objects.all()
for factura in facturas:
    print(f"Pagada: {factura.pagada}, Importe: {factura.importe}, RFC Cliente: {factura.cliente__rfc}")

Como se puede observar al final de la última línea, para accesar al campo rfc desde el modelo Factura, usamos factura.cliente__rfc.

Filtrando los registros

Podemos usar el método filter() como un símil del WHERE de SQL, y así obtener un conjunto de datos limitado por condiciones. Los parámetros de filter() corresponden a los nombres de las variables del modelo, y se pueden agregar modificadores con el sufijo __modificador.

from contabilidad.models import Cliente, Factura
import datetime

# obtenemos exclusivamente a los clientes activos
clientes = Cliente.objects.filter(activo=True)

# buscamos a los clientes que su fecha de nacimiento > 1980-03-01
# y que no estén activos. El modificaror '__gt' significa greater than, o >
fecha = datetime.date(1980, 3, 1)
clientes = Cliente.objects.filter(activo=False, fecha_nacimiento__gt=fecha)
# si queremos un filtro inclusivo usamos '__gte' (greater than equal)
clientes = Cliente.objects.filter(activo=False, fecha_nacimiento__gte=fecha)

# para buscar un rango de fechas y todos los que se llamen Carlos:
inicio = datetime.date(1980, 3, 1)
final = datetime.date(2000, 1, 1)
clientes = Clientes.objects.filter(
    fecha_nacimiento__range=(inicio, final),
    nombre="Carlos")
    
# podemos buscar por los valores de una lista
clientes = Clientes.objects.filter(nombre__in=["Juan", "María", "Gabriela"])

Cuando tenemos modelos con relaciones el procedimiento es muy similar, solo tenemos que usar __ para accesar al modelo referenciado, y si queremos usar un modificador usamos el sufijo __modificador. Busquemos las facturas pagadas de los clientes nacidos en la década de los 80’s:

from contabilidad.models import Cliente, Factura
import datetime


inicio = datetime.date(1980, 1, 1)
final = datetime.date(1989, 12, 31)
facturas = Factura.objects.filter(
    pagada=True,
    cliente__fecha_nacimiento__range=(inicio, final))

Nota: podemos restringir el número de registros regresados usando la notación de slices nativa de Python, y esto se traduce al equivalente LIMIT de SQL. Para regresar los primeros diez registros: Factura.objects.all()[:10] o si queremos regresar los registros del 5 al 20 usamos Factura.objects.all()[5:20].

Obteniendo un solo registro

En ocasiones solo necesitamos un registro en particular, y para esto usamos el método get(). Los parámetros de este método son los nombres de los campos, los cuales nos servirán como condiciones SQL para obtener el registro. El caso más común es filtrar por el id del registro, por lo que si queremos obtener al Cliente con id número 5 hacemos lo siguiente:

from contabilidad.models import Cliente

pedro = Cliente.objects.get(id=5)
print(pedro.nombre)

En este caso no obtenemos un QuerySet, pero sí obtenemos un objeto con la información de nuestro registro.

Nota: lo más común es usar get() con el id, pero si creamos manualmente la llave primaria de nuestro modelo, le podemos poner el nombre que nosotros consideremos más adecuado. Para facilitarnos la vida, Django pone a nuestra disposición la variable pk, que apunta a la llave primaria. Es por esto que se recomienda usar pk con get(): Cliente.objects.get(pk=5).

A diferencia de filter() y all(), si get() no encuentra ningún registro lanzará la excepción modelo.DoesNotExist, por lo que hay que manejarla siempre que usemos get():

from contabilidad.models import Cliente

cliente_id=5
try:
    pedro = Cliente.objects.get(pk=cliente_id)
    print(pedro.nombre)
except Cliente.DoesNotExist:
    print(f"No existe el registro con id={cliente_id}")

Muchas veces necesitamos crear el usuario que estamos buscando si es que no existe, de esta forma:

from contabilidad.models import Cliente
import datetime


cliente_id=5
fecha_nacimiento = datetime.date(1980, 12, 5)
try:
    pedro = Cliente.objects.get(
        nombre="Pedro",
        apellidos="Aguilar Ramírez",
        rfc="AGRM-801205-111",
        fecha_nacimiento=fecha_nacimiento)
except Cliente.DoesNotExist:
    pedro = Cliente.objects.create(
        nombre="Pedro",
        apellidos="Aguilar Ramírez",
        rfc="AGRM-801205-111",
        fecha_nacimiento=fecha_nacimiento)

Esto además de engorroso rompe con el principio DRY. Afortunadamente para nosotros, Django tiene un atajo (este es solo uno de muchos que nos hacen la vida más fácil):

from contabilidad.models import Cliente
import datetime


cliente_id=5
fecha_nacimiento = datetime.date(1980, 12, 5)
pedro = Cliente.objects.get_or_create(
    nombre="Pedro",
    apellidos="Aguilar Ramírez",
    rfc="AGRM-801205-111",
    fecha_nacimiento=fecha_nacimiento)

Solo tenemos que hacer la búsqueda usando todos los campos que no tengan valores default, para que el registro pueda ser creado si no existe.

Ordenando el resultado

Así como en SQL usamos ORDER BY para ordenar el resultado, el ORM de Django tiene el método order_by(). Usarlo es muy sencillo, solo tenemos que especificar los nombres de los campos a ordenar, y si queremos un ordenamiento descendente anteponemos un guión al nombre del campo:

from contabilidad.models import Cliente

# Buscamos todos los clientes con apellido paterno 'García' y
# ordenamos ascendentemente por 'apellidos', y después
# descendentemente por 'fecha_nacimiento'
clientes = Cliente.objects.filter(apellidos__startswith="García").order_by(
    "apellidos", "-fecha_nacimiento")

Funciones de agregación (Agregates)

También es posible usar funciones como Sum, Avg, Count (entre otras), solo que que en lugar de usar un group_by() usamos annotate(). Veamos un ejemplo más complejo con varios conceptos que hemos visto:

import datetime
from django.db import models
from contabilidad import Factura

inicio = datetime.date(2020, 1, 1)
final = datetime.date(2020, 12, 31)
Factura.objects.filter(
    cliente_id=3, fecha__range=(inicio, final), importe__lte=50000
    ).annotate(Sum("importe")
    ).order_by("fecha", "importe")

Esta consulta nos dará el total por día de todas las facturas con un importe menor o igual a 50,000, ordenando primero por fecha y después por el importe, para el cliente con un id de 3.

Pro-Tip: mirar el SQL generado

Aunque no estemos trabajando con SQL directamente, al final Django convierte las consultas del ORM a código SQL. En ocasiones es muy útil inspeccionar este código para validar que nuestra consulta sea la correcta. Para esto solo tenemos que revisar el contenido de la variable query:

from contabilidad.models import Cliente

clientes = Cliente.objects.filter(apellidos__startswith="García").order_by(
    "apellidos", "-fecha_nacimiento")
print(clientes.query)

Conclusión

El ORM de Django es muy sencillo de usar y potente al mismo tiempo. Sin embargo, habrá situaciones donde una consulta SQL no la podamos expresar con el ORM, pero Django nos permite ejecutar raw queries en estos casos (spoiler: habrá un post sobre esto). También es posible extender el funcionamiento del ORM creando nuestros propios filtros o funciones, manteniendo toda la lógica del modelo en un mismo lugar (más spoiler: próximamente un post de este tema).