The recording for this lesson has not been published yet.
Your blog has exactly one table. Every post is an island: it has a title, a text, an author and a date, and nothing in the database knows that two posts are about the same subject or that a reader wrote a reply. Relations are how you tell the database "these rows belong together", and they are the single most valuable thing the ORM does for you.
Three field types cover almost everything:
| Field | Means | Example in the blog |
|---|---|---|
ForeignKey |
many rows point to one row | many posts in one category |
ManyToManyField |
many rows on both sides | a post has many tags, a tag has many posts |
OneToOneField |
at most one row on each side | a user has one profile |
The question to ask is always the same, and you ask it in both directions: "how many
Y can one X have?" Post to Category: one. Category to Post: many. That is a
ForeignKey on Post - the foreign key always lives on the
"many" side.
# blog/models.py
from django.conf import settings
from django.db import models
class Category(models.Model):
name = models.CharField(max_length=60, unique=True)
slug = models.SlugField(max_length=60, unique=True)
class Meta:
verbose_name_plural = "categories"
ordering = ["name"]
def __str__(self):
return self.name
class Post(models.Model):
title = models.CharField(max_length=200)
text = models.TextField()
published_date = models.DateTimeField(blank=True, null=True)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="posts",
)
category = models.ForeignKey(
Category,
on_delete=models.PROTECT,
related_name="posts",
null=True,
blank=True,
)
def __str__(self):
return self.title
on_delete is not optional and there is no sensible default, because only you
know what "the category was deleted" should mean for its posts.
| on_delete | What happens to the post | Use it for |
|---|---|---|
CASCADE | deleted with the parent | data that cannot exist alone: comments of a post |
PROTECT | the delete is refused | data you must not lose by accident: categories |
SET_NULL | category_id becomes NULL | optional links (null=True required) |
SET_DEFAULT | falls back to the default | an "Uncategorised" row that always exists |
RESTRICT | refused, unless the same delete also removes the child | a softer PROTECT |
related_name is the name of the reverse accessor. Without it Django
invents post_set; with related_name="posts" you write
category.posts.all(), which reads like English and works in templates.
If Post ever gets both author and editor
pointing at the user model, both need a distinct related_name
(posts and edited_posts), otherwise
manage.py check fails with a clash error.
class Comment(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name="comments",
)
author_name = models.CharField(max_length=80)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
approved = models.BooleanField(default=False)
class Meta:
ordering = ["created_at"]
def __str__(self):
return f"{self.author_name} on {self.post}"
The reverse side is a manager, so it is filterable and countable - not a plain list:
post.comments.all()
post.comments.filter(approved=True)
post.comments.count()
post.comments.create(author_name="Ada", body="Nice post") # post is set for you
And in a template the same accessor works, including a filtered count:
<h2>{{ post.comments.count }} comments</h2>
{% for comment in post.comments.all %}
<p><strong>{{ comment.author_name }}</strong> {{ comment.body }}</p>
{% endfor %}
class Tag(models.Model):
name = models.CharField(max_length=40, unique=True)
slug = models.SlugField(max_length=40, unique=True)
def __str__(self):
return self.name
class Post(models.Model):
...
tags = models.ManyToManyField(Tag, related_name="posts", blank=True)
There is no tags column in blog_post. Django creates a third
table with two foreign keys and a unique constraint on the pair:
CREATE TABLE "blog_post_tags" (
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
"post_id" bigint NOT NULL REFERENCES "blog_post" ("id"),
"tag_id" bigint NOT NULL REFERENCES "blog_tag" ("id")
);
CREATE UNIQUE INDEX "blog_post_tags_post_id_tag_id_uniq"
ON "blog_post_tags" ("post_id", "tag_id");
django = Tag.objects.create(name="django", slug="django")
post.tags.add(django) # idempotent - adding twice changes nothing
post.tags.remove(django)
post.tags.set([django, python_tag])
django.posts.all() # the reverse side, thanks to related_name
"No tags" means zero rows in the join table, so there is nothing that could be NULL.
null=True on a ManyToManyField has no effect at all.
The moment you want to store something about the link - who tagged it, when, how relevant it is - the generated table is not enough. Declare it yourself:
class Post(models.Model):
...
tags = models.ManyToManyField(Tag, through="Tagging", related_name="posts", blank=True)
class Tagging(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE)
tag = models.ForeignKey(Tag, on_delete=models.CASCADE)
tagged_at = models.DateTimeField(auto_now_add=True)
tagged_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True
)
class Meta:
constraints = [
models.UniqueConstraint(fields=["post", "tag"], name="unique_post_tag"),
]
With a through model you may still call add() and
set() in modern Django as long as every extra field has a default or allows
NULL; when it does not, create the row explicitly:
Tagging.objects.create(post=post, tag=django, tagged_by=request.user)
A one-to-one is a foreign key with a unique constraint. Use it to extend a model you do not own - the classic case being the built-in user:
# accounts/models.py
class Profile(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="profile",
)
bio = models.TextField(blank=True)
website = models.URLField(blank=True)
request.user.profile.bio # forward and reverse look identical
profile.user.username
If a user has no profile row, user.profile raises
Profile.DoesNotExist instead of returning None. In Python
guard it with hasattr(user, "profile"); in a template it silently renders
as empty, which hides the bug.
The double underscore follows a relation, in either direction, as deep as you like:
# forward: post -> category
Post.objects.filter(category__slug="django")
# forward through many-to-many
Post.objects.filter(tags__name="orm")
# backward: category -> posts (lowercased model name, or the related_name)
Category.objects.filter(posts__title__icontains="django")
# two steps at once: posts that have an approved comment by Ada
Post.objects.filter(comments__approved=True, comments__author_name="Ada").distinct()
Note the distinct(). Joining across a to-many relation multiplies rows: a
post with three matching comments comes back three times unless you ask the database to
collapse duplicates.
Category, Tag and Comment to
blog/models.py exactly as above, then run
python manage.py makemigrations blog and migrate.blog/admin.py and create two categories, four tags
and a handful of comments through the admin.python manage.py shell, print the number of posts per category using
only the reverse accessor category.posts.count().Post.category from PROTECT to SET_NULL,
migrate, and try deleting a category that still has posts. Explain the difference in
behaviour in one sentence.