Dica 19.1 - Modelagem - OneToMany - Um pra Muitos - ForeignKey - Chave Estrangeira
Versões usadas no vídeo: Django 4.1.3, Python 3.10, PostgreSQL 14 (no Docker) e django-seed 0.3.1.
Código: https://github.com/rg3915/dicas-de-django (branch aula19)
Este vídeo é um corte da live Segredos do ORM do Django e abre a série sobre modelagem do Projeto Dicas de Django. Nesta primeira parte vamos:
- entender o que é o ORM do Django, comparando um
SELECTfeito direto no PostgreSQL com a mesma consulta feita em Python; - criar um app novo,
bookstore, onde ficarão os models de todas as dicas de modelagem; - modelar o relacionamento OneToMany (um para muitos) com
ForeignKey: um cliente pode ter várias ordens de compra; - popular o banco com o django-seed e consultar os dados pelo ORM, pelo
psqle por três clientes gráficos (CloudBeaver, pgAdmin 4 e DBeaver); - usar o
shell_plusdentro do Jupyter Notebook.
Leia
Esse artigo mostra as formas de estender o model User do Django, e usa justamente os relacionamentos que vamos estudar nesta série (por exemplo, o OneToOneField da Dica 19.2).
Pré-requisitos
O projeto é o mesmo das dicas anteriores, com o usuário customizado que faz login com e-mail (Dica 14) e os contêineres do docker-compose da Dica 07:
dicas_de_django_db: PostgreSQL 14, na porta 5431 do lado de fora (5432 dentro da rede do Docker);dicas_de_django_pgadmin: pgAdmin 4, na porta 5051;dicas_de_django_mailhog: MailHog;dicas_de_django_appedicas_de_django_nginx: o Django com Gunicorn e o Nginx.
No vídeo o Django roda fora do contêiner, com runserver em localhost:8000, mas conectado ao PostgreSQL do contêiner. O Portainer é usado para acompanhar os contêineres.
Suba tudo, aplique as migrações e crie dois usuários:
source .venv/bin/activate
docker-compose up -d
python manage.py migrate
python manage.py createsuperuser # admin@email.com
python manage.py createsuperuser # regis@email.com
python manage.py runserver
Como o usuário é customizado, o createsuperuser pede só o e-mail e a senha. No admin (http://localhost:8000/admin/), preencha o nome do regis@email.com como "Regis Santos" e o do admin@email.com como "Admin".
ORM - Object Relational Mapping
Rodando comandos direto no PostgreSQL
Primeiro, vamos olhar os dados direto no banco, com o psql que existe dentro do contêiner do PostgreSQL:
docker container ls
docker container exec -it dicas_de_django_db psql
Dentro do psql:
\l # lista os bancos
\c dicas_de_django_db # conecta em dicas_de_django_db
\dt # lista as tabelas
SELECT * FROM accounts_user;
SELECT first_name, last_name, email FROM accounts_user;
\q # sair
A tabela accounts_user é a do nosso usuário customizado. O segundo SELECT retorna:
first_name | last_name | email
------------+-----------+-----------------
Regis | Santos | regis@email.com
Admin | | admin@email.com
(2 rows)
Rodando comandos pelo Django
O ORM (Object-Relational Mapping, ou mapeamento objeto-relacional) faz a ponte entre as tabelas do banco e as classes Python: em vez de escrever SQL, trabalhamos com objetos. Para testar, usamos o shell_plus do django-extensions, que já importa todos os models.
Fora do contêiner
python manage.py shell_plus
>>> User.objects.all()
<QuerySet [<User: regis@email.com>, <User: admin@email.com>]>
É o mesmo resultado do SELECT, mas agora cada linha é um objeto User.
Dentro do contêiner
Também dá para rodar o Django que está dentro do contêiner dicas_de_django_app. Ele se conecta no mesmo banco:
docker container exec -it dicas_de_django_app \
python manage.py shell_plus
>>> User.objects.all()
<QuerySet [<User: regis@email.com>, <User: admin@email.com>]>
Caso dê erro no contêiner, reconstrua a imagem e tente de novo:
docker-compose up --build -d
docker container exec -it dicas_de_django_app python manage.py shell_plus
Daqui para frente vamos usar sempre o Django da máquina local.
Fazendo algumas queries
O values() retorna dicionários só com os campos pedidos, e o atributo query mostra o SQL que o ORM gerou:
>>> users = User.objects.values('first_name', 'last_name', 'email')
>>> users
<QuerySet [{'first_name': 'Regis', 'last_name': 'Santos', 'email': 'regis@email.com'}, {'first_name': 'Admin', 'last_name': '', 'email': 'admin@email.com'}]>
>>> print(users.query)
SELECT "accounts_user"."first_name", "accounts_user"."last_name", "accounts_user"."email" FROM "accounts_user"
Um queryset pode ser percorrido como uma lista de objetos:
>>> users = User.objects.all()
>>> users
<QuerySet [<User: regis@email.com>, <User: admin@email.com>]>
>>> for user in users:
... print(user)
...
regis@email.com
admin@email.com
>>> for user in users:
... print(user.first_name, user.email)
...
Regis regis@email.com
Admin admin@email.com
E agora vamos aplicar um filtro. O email__icontains='regis' significa "o e-mail contém regis, sem diferenciar maiúsculas de minúsculas". O __ (duplo underline, ou dunder) separa o nome do campo do tipo de busca (lookup).
>>> user = User.objects.filter(email__icontains='regis').values('first_name', 'email')
>>> user
<QuerySet [{'first_name': 'Regis', 'email': 'regis@email.com'}]>
>>> print(user.query)
SELECT "accounts_user"."first_name", "accounts_user"."email" FROM "accounts_user" WHERE UPPER("accounts_user"."email"::text) LIKE UPPER(%regis%)
Repare que o icontains virou um WHERE UPPER(...) LIKE UPPER(%regis%) no PostgreSQL.
Leia QuerySet API reference #icontains.
Criando uma nova app
Vamos criar uma nova app chamada bookstore. Como todas as apps do projeto ficam dentro da pasta backend, entramos nela e chamamos o manage.py da pasta de cima:
cd backend
python ../manage.py startapp bookstore
cd ..
Adicione em INSTALLED_APPS:
# backend/settings.py
INSTALLED_APPS = [
...
# minhas apps
'backend.core',
'backend.bookstore',
'backend.crm',
]
Edite bookstore/apps.py, acrescentando o backend. no name:
# backend/bookstore/apps.py
from django.apps import AppConfig
class BookstoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'backend.bookstore'
Crie bookstore/urls.py, por enquanto sem rotas, só para o include funcionar:
# backend/bookstore/urls.py
from django.urls import path
app_name = 'bookstore'
urlpatterns = [
]
E inclua as rotas do app no urls.py principal:
# backend/urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('', include('backend.core.urls', namespace='core')), # noqa E501
path('accounts/', include('backend.accounts.urls')), # noqa E501
path('bookstore/', include('backend.bookstore.urls', namespace='bookstore')), # noqa E501
path('crm/', include('backend.crm.urls', namespace='crm')), # noqa E501
path('admin/', admin.site.urls), # noqa E501
]
OneToMany - Um pra Muitos - ForeignKey - Chave Estrangeira
É o relacionamento onde usamos chave estrangeira, conhecido como ForeignKey.

Ilustração feita com excalidraw.com.
A chave estrangeira é um campo que colocamos na segunda tabela para apontar para um registro da primeira. Aqui temos cliente (Customer) e ordem de compra (Ordered), e a pergunta é: de que lado fica a chave?
Pense nas duas possibilidades como uma planilha. Se a chave ficasse no cliente, cada cliente teria um pedido, e um mesmo pedido poderia aparecer em dois clientes diferentes, o que não faz sentido:
customer | ordered
---------+--------
1 | 1
2 | 1
3 |
Com a chave no pedido, cada pedido tem um cliente, e um cliente aparece em vários pedidos:
ordered | customer_id
--------+------------
1 | 1
2 | 1
3 | 2
Essa é a forma natural: um cliente pode fazer vários pedidos. Então a ForeignKey vai em Ordered, apontando para Customer. Para reproduzir o esquema acima, usamos o seguinte código:
# backend/bookstore/models.py
from django.db import models
class Customer(models.Model):
first_name = models.CharField('nome', max_length=100)
last_name = models.CharField('sobrenome', max_length=255, null=True, blank=True) # noqa E501
email = models.EmailField('e-mail', max_length=50, unique=True)
active = models.BooleanField('ativo', default=True)
class Meta:
ordering = ('first_name',)
verbose_name = 'cliente'
verbose_name_plural = 'clientes'
@property
def full_name(self):
return f'{self.first_name} {self.last_name or ""}'.strip()
def __str__(self):
return self.full_name
STATUS = (
('p', 'Pendente'),
('a', 'Aprovado'),
('c', 'Cancelado'),
)
class Ordered(models.Model):
status = models.CharField(max_length=1, choices=STATUS, default='p')
customer = models.ForeignKey(
Customer,
on_delete=models.SET_NULL,
verbose_name='cliente',
related_name='ordereds',
null=True,
blank=True
)
created = models.DateTimeField(
'criado em',
auto_now_add=True,
auto_now=False
)
class Meta:
ordering = ('-pk',)
verbose_name = 'ordem de compra'
verbose_name_plural = 'ordens de compra'
def __str__(self):
if self.customer:
return f'{str(self.pk).zfill(3)}-{self.customer}'
return f'{str(self.pk).zfill(3)}'
Em Customer:
last_nametemnull=True, blank=True: o sobrenome não é obrigatório (nullvale para o banco,blankpara os formulários). O nome é obrigatório.emailtemunique=True: não pode haver dois clientes com o mesmo e-mail.activeé um booleano que já começa comoTrue.full_nameé uma@propertyque junta nome e sobrenome; oor ""evita aparecerNonequando não há sobrenome. O__str__usa essa propriedade.
Em Ordered:
statususachoices=STATUS: no banco grava só uma letra (p,aouc), e o padrão ép, pendente.customeré a chave estrangeira. O primeiro argumento é o model para o qual ela aponta,Customer.on_delete=models.SET_NULL: se o cliente for apagado, os pedidos dele continuam existindo, com o cliente vazio. Por isso o campo precisa denull=True(eblank=Truepara não ser obrigatório no formulário). Existem outras opções, comoCASCADE(apaga os pedidos junto) ePROTECT(impede apagar o cliente que tem pedidos).related_name='ordereds': é o nome do caminho de volta, do cliente para os pedidos (cliente.ordereds.all()). Vamos usá-lo no fim da dica.createdcomauto_now_add=Trueé preenchido automaticamente com a data e hora da criação.- No
__str__, ozfill(3)completa o id com zeros à esquerda (001,002...). Se o pedido tem cliente, retorna001-Nome do Cliente; senão, só001.
O admin:
# backend/bookstore/admin.py
from django.contrib import admin
from .models import Customer, Ordered
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
list_display = ('__str__', 'email', 'active')
search_fields = ('first_name', 'last_name')
list_filter = ('active',)
@admin.register(Ordered)
class OrderedAdmin(admin.ModelAdmin):
list_display = ('__str__', 'customer', 'status')
search_fields = (
'customer__first_name',
'customer__last_name',
'customer__email',
)
list_filter = ('status',)
date_hierarchy = 'created'
Repare no search_fields do OrderedAdmin: com customer__first_name a busca atravessa a chave estrangeira e procura no nome do cliente.
Crie e aplique as migrações:
python manage.py makemigrations
python manage.py migrate
Migrations for 'bookstore':
backend/bookstore/migrations/0001_initial.py
- Create model Customer
- Create model Ordered
Diagrama ER

De um lado, bookstore_customer com id, first_name, last_name, email e active; do outro, bookstore_ordered com id, created, status e customer_id, que é a chave estrangeira. No banco, o campo customer vira a coluna customer_id.
Inserindo dados com django-seed
Para não cadastrar dados na mão, vamos usar o django-seed, que gera registros aleatórios com o Faker.
pip install django-seed
pip freeze | grep django-seed >> requirements.txt
Edite settings.py
# backend/settings.py
INSTALLED_APPS = [
...
# apps de terceiros
'django_extensions',
'widget_tweaks',
'django_seed',
...
]
Gerando os dados:
python manage.py seed bookstore --number=3
Seeding 3 Customers
Seeding 3 Ordereds
Model Customer generated record with primary key 1
Model Customer generated record with primary key 2
Model Customer generated record with primary key 3
Model Ordered generated record with primary key 1
Model Ordered generated record with primary key 2
Model Ordered generated record with primary key 3
O comando cria 3 registros de cada model do app bookstore, já ligando os pedidos a clientes existentes.
ORM
python manage.py shell_plus
O shell_plus já mostra na abertura que importou from backend.bookstore.models import Customer, Ordered.
>>> customers = Customer.objects.all()
>>> for customer in customers:
... print(customer)
...
Blake Estrada
Brian Roberson
Jeffrey Stanley
>>> ordereds = Ordered.objects.all()
>>> for ordered in ordereds:
... print(ordered)
...
003-Blake Estrada
002-Jeffrey Stanley
001-Jeffrey Stanley
Os nomes são aleatórios, então os seus serão outros. Aqui Jeffrey Stanley fez duas compras e Blake Estrada, uma. Os clientes saem em ordem alfabética e os pedidos do mais novo para o mais antigo, por causa do ordering de cada Meta.
Vendo os dados no PostgreSQL
Os mesmos dados, direto no banco:
docker container exec -it dicas_de_django_db psql
As tabelas
\l
\c dicas_de_django_db # conecta no banco dicas_de_django_db
\dt # mostra todas as tabelas
Agora aparecem as tabelas bookstore_customer e bookstore_ordered.
Os registros
SELECT * FROM bookstore_ordered;
id | status | created | customer_id
----+--------+------------------------+-------------
1 | p | 1979-12-01 00:43:40+00 | 1
2 | p | 1985-03-01 21:37:38+00 | 1
3 | p | 1988-10-24 20:56:37+00 | 3
(3 rows)
A coluna customer_id é a chave estrangeira: guarda o id do cliente. As datas são aleatórias porque foram geradas pelo django-seed.
Schema
Para ver as colunas e os tipos de dados de uma tabela:
SELECT column_name, data_type FROM information_schema.columns WHERE TABLE_NAME = 'bookstore_ordered';
column_name | data_type
-------------+--------------------------
id | bigint
created | timestamp with time zone
customer_id | bigint
status | character varying
(4 rows)
O id é bigint por causa do BigAutoField definido no apps.py, e a customer_id também é bigint, porque aponta para o id de bookstore_customer.
DBeaver, CloudBeaver e pgAdmin
Além do ORM e do psql, podemos ver o banco por clientes gráficos. Vamos ver três opções.
CloudBeaver
O CloudBeaver é a versão web do DBeaver, e roda num contêiner. Acrescente o serviço no docker-compose.yml, junto dos outros:
# docker-compose.yml
cloudbeaver:
container_name: dicas_de_django_cloudbeaver
image: dbeaver/cloudbeaver:latest
volumes:
- /var/cloudbeaver/workspace:/opt/cloudbeaver/workspace
ports:
- 5052:8978
networks:
- dicas-de-django-network
E rode
docker-compose up -d
Só o contêiner novo é criado (Creating dicas_de_django_cloudbeaver ... done). Pelo Portainer, entre no CloudBeaver, que fica em http://localhost:5052.
Login e senha:
Login: cbadmin
Pass: admin
Depois, em Connection, crie uma conexão manual do tipo PostgreSQL com dicas_de_django_db:
Host: dicas_de_django_db
Port: 5432
Database: postgres
Username: postgres
Password: postgres
O host é o nome do contêiner do banco, e a porta é a interna, 5432, porque o CloudBeaver está na mesma rede do Docker (dicas-de-django-network). Marque Show all databases para aparecer o banco dicas_de_django_db. Depois navegue em dicas_de_django_db > Schemas > public > Tables, clique com o botão direito em accounts_user > Generate SQL > SELECT, copie o comando, cole no editor SQL e execute.
pgAdmin 4
O pgAdmin já estava no docker-compose.yml desde a Dica 07, na porta 5051 (http://localhost:5051). O login é o que está no docker-compose.yml:
Email: admin@admin.com
Password: admin
Em Add New Server, na aba Connection:
Host name/address: dicas_de_django_db
Port: 5432
Maintenance database: postgres
Username: postgres
Password: postgres
Pelo mesmo motivo do CloudBeaver, o host é o nome do contêiner e a porta é a interna. Depois é só navegar em dicas_de_django_db > Schemas > public > Tables e, com o botão direito numa tabela (accounts_user, bookstore_customer, bookstore_ordered), escolher View/Edit Data.
DBeaver
O DBeaver instalado na sua máquina fica fora do Docker, então ele usa o endereço e a porta externa do contêiner:
Host: 0.0.0.0
Port: 5431
Database: dicas_de_django_db
Username: postgres
Password: postgres
No vídeo, com a porta 5432 o teste de conexão dá "conexão recusada"; com a 5431, que é a porta publicada pelo contêiner (5431:5432), funciona.
Jupyter Notebook
Para instalar o Jupyter digite
pip install jupyter
E para rodar digite
python manage.py shell_plus --notebook
No navegador, crie um notebook novo com o kernel Django Shell-Plus, que já vem com os models importados. Quando você tentar rodar
Customer.objects.all()
Você vai ter este erro
SynchronousOnlyOperation: You cannot call this from an async context - use a thread or sync_to_async.
O Jupyter roda o código dentro de um event loop assíncrono, e o Django bloqueia operações de banco nesse contexto. Para liberar, edite o settings.py:
# backend/settings.py
import os
from pathlib import Path
...
AUTH_USER_MODEL = 'accounts.User'
os.environ['DJANGO_ALLOW_ASYNC_UNSAFE'] = 'true'
Use isso só em desenvolvimento. Volte ao Jupyter, clique em Kernel > Restart e rode de novo:
Customer.objects.all()
# <QuerySet [<Customer: Blake Estrada>, <Customer: Brian Roberson>, <Customer: Jeffrey Stanley>]>
Criando registros pelo ORM
Agora, no Jupyter, vamos criar clientes e pedidos pelo código, porque um dia você pode precisar fazer isso pela linha de comando:
adam = Customer.objects.create(first_name='Adam', email='adam@email.com')
james = Customer.objects.create(first_name='James', email='james@email.com')
Customer.objects.all()
# <QuerySet [<Customer: Adam>, <Customer: Blake Estrada>, <Customer: Brian Roberson>, <Customer: James>, <Customer: Jeffrey Stanley>]>
Dois pedidos para o Adam e três para o James. Para ligar o pedido ao cliente, basta passar o objeto no campo da chave estrangeira:
Ordered.objects.create(customer=adam)
Ordered.objects.create(customer=adam)
# <Ordered: 005-Adam>
Ordered.objects.create(customer=james)
Ordered.objects.create(customer=james)
Ordered.objects.create(customer=james)
# <Ordered: 008-James>
Como o seed já tinha criado os pedidos 1 a 3, os do Adam ficam com os ids 4 e 5, e os do James com 6, 7 e 8. No Jupyter aparece só o resultado da última linha de cada célula.
Do pedido para o cliente
ordereds = Ordered.objects.all()
for ordered in ordereds:
print(ordered)
008-James
007-James
006-James
005-Adam
004-Adam
003-Blake Estrada
002-Jeffrey Stanley
001-Jeffrey Stanley
O status guarda só a letra. Para ver o texto da opção, o Django cria automaticamente o método get_status_display() para todo campo com choices:
for ordered in ordereds:
print(ordered.status) # p, p, p...
for ordered in ordereds:
print(ordered.get_status_display()) # Pendente, Pendente, Pendente...
E pela chave estrangeira chegamos no cliente e em qualquer campo dele:
for ordered in ordereds:
print(ordered.customer)
for ordered in ordereds:
print(ordered.customer.email)
james@email.com
james@email.com
james@email.com
adam@email.com
adam@email.com
joshua45@example.com
manningdavid@example.org
manningdavid@example.org
O código completo, em um bloco só:
from backend.bookstore.models import Customer, Ordered
Customer.objects.all()
adam = Customer.objects.create(first_name='Adam', email='adam@email.com')
james = Customer.objects.create(first_name='James', email='james@email.com')
Customer.objects.all()
Ordered.objects.create(customer=adam)
Ordered.objects.create(customer=adam)
Ordered.objects.create(customer=james)
Ordered.objects.create(customer=james)
Ordered.objects.create(customer=james)
ordereds = Ordered.objects.all()
for ordered in ordereds:
print(ordered)
print(ordered.status)
print(ordered.get_status_display())
print(ordered.customer)
print(ordered.customer.email)
Ou seja, a partir da ordem de compra conseguimos ver o cliente.
Do cliente para os pedidos
E como fazemos para, a partir do cliente, ver as ordens de compra dele?
>>> adam.ordereds.all()
<QuerySet [<Ordered: 005-Adam>, <Ordered: 004-Adam>]>
>>> james.ordereds.all()
<QuerySet [<Ordered: 008-James>, <Ordered: 007-James>, <Ordered: 006-James>]>
Ou seja, pegamos os dados pelo related_name. O ordereds é exatamente o related_name='ordereds' que definimos na ForeignKey. Sem ele, o Django criaria o nome padrão ordered_set (adam.ordered_set.all()).
Resumindo: a ForeignKey fica no lado "muitos" (o pedido), aponta para o lado "um" (o cliente), vira uma coluna customer_id no banco e pode ser percorrida nos dois sentidos: ordered.customer e customer.ordereds.all().
Próxima dica: Dica 19.2 - Modelagem - OneToOne.