ER-діаграми та типи зв'язків¶
Три типи зв'язків між таблицями. Питання «де живе FK?» — це не технічне, це архітектурне.
Типи зв'язків¶
1:1 — OneToOneField¶
FK + UNIQUE · "user has exactly one profile"
USER USER_PROFILE
┌──────────────┬──────────┐ ┌────────────────┬──────────────────┐
│ id bigint PK│ │──►│ id bigint PK│ │
│ username │ │ │ user_id bigint │ UNIQUE FK │
└──────────────┴──────────┘ │ display varchar│ │
│ timezone │ │
└────────────────┴──────────────────┘
class UserProfile(models.Model):
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name='profile',
)
display_name = models.CharField(max_length=60, blank=True)
timezone = models.CharField(max_length=40, default='UTC')
avatar_url = models.URLField(blank=True)
def __str__(self):
return f'Profile({self.user.username})'
SQL що генерує Django:
CREATE TABLE "hello_app_userprofile" (
"id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
"user_id" bigint NOT NULL UNIQUE REFERENCES "auth_user" ("id") ON DELETE CASCADE,
"display_name" varchar(60) NOT NULL DEFAULT '',
"timezone" varchar(40) NOT NULL DEFAULT 'UTC',
"avatar_url" varchar(200) NOT NULL DEFAULT ''
);
Реверс — доступ з User:
user = User.objects.get(username='alice')
user.profile # → UserProfile (не QuerySet! UNIQUE FK → один об'єкт)
user.profile.timezone # → 'Europe/Kyiv'
Чому це 1:1? Звичайний
FK+UNIQUEна колонці. Реверс — атрибут (не QuerySet):user.profile. Корисно для вертикального партиціонування: 5 базових полів у User, 20 опціональних у Profile — не забруднюємо головну таблицю.
Коли використовувати 1:1?
- "Вертикальне партиціонування": 5 базових полів у User, 20 опціональних у Profile
- Різний доступ: User змінюється часто, Profile рідко → окремий QuerySet
- Різні права: view_user без view_userprofile
1:N — ForeignKey¶
FK на "Many" стороні · "notebook has many notes"
NOTEBOOK NOTE NOTE NOTE
┌──────────┬──────────┐ ┌───────────────────────┐
│ id │ bigint PK│──┬─►│ id bigint PK │
│ title │ varchar │ │ │ notebook_id bigint FK │← FK тут!
└──────────┴──────────┘ ├─►│ title varchar │
│ └───────────────────────┘
├─► (ще одна нотатка)
└─► (ще одна нотатка)
class Note(models.Model):
notebook = models.ForeignKey(
'Notebook', # ← рядок уникає кругових імпортів
on_delete=models.SET_NULL, # Видалення записника → NULL (не CASCADE!)
null=True, # NULL обов'язковий при SET_NULL
blank=True, # Форма: поле необов'язкове
related_name='notes', # notebook.notes.all() → QuerySet нотаток
)
Де живе FK? Завжди на Many-стороні.
Notebookне зберігає список нотаток —Noteзберігаєnotebook_id. Це фізика SQL: один стовпець = одне значення.
ПОМИЛКА думати так: ПРАВИЛЬНО думати так:
Notebook має список notes Note belongs to Notebook
→ Notebook.notes = [...] → Note.notebook_id = FK
→ JOIN відбуватиметься → Notebook.notes.all() = реверс
Notebook НЕ зберігає список Note IDs!
Це б порушило нормальну форму і ускладнило JOIN.
Реверс — доступ з «One» сторони:
notebook = Notebook.objects.get(id=1)
notebook.notes.all() # ← related_name='notes' → QuerySet
notebook.notes.filter(is_pinned=True) # Фільтруємо
notebook.notes.count() # Кількість
note = Note.objects.get(id=1)
note.notebook # ← FK attr → Notebook об'єкт (або None)
note.notebook_id # ← raw integer FK без JOIN
note.notebook.title # ← JOIN: SELECT ... WHERE notebook_id=...
M:N — ManyToManyField¶
Через junction table · Django створює автоматично
NOTE NOTE_TAGS (junction) TAG
┌──────────┬──────┐ ┌──────────┬──────────┐ ┌──────────┬──────────┐
│ id │ PK │──►│ note_id │ bigint FK│ │ id │ PK │
│ title │ │ │ tag_id │ bigint FK│─►│ name │ varchar │
└──────────┴──────┘ └──────────┴──────────┘ └──────────┴──────────┘
UNIQUE(note_id, tag_id)
Django: hello_app_note_tags
class Note(models.Model):
tags = models.ManyToManyField(
'Tag',
blank=True, # Нотатка може бути без тегів
related_name='notes', # tag.notes.all() → нотатки з цим тегом
)
# Django автоматично створює junction таблицю: hello_app_note_tags
Операції з M:N:
note = Note.objects.get(id=1)
tag_py = Tag.objects.get(name='python')
tag_dj = Tag.objects.get(name='django')
# Додати теги
note.tags.add(tag_py, tag_dj) # INSERT INTO note_tags (note_id, tag_id)
# Повний список
note.tags.all() # SELECT * FROM tags JOIN note_tags ...
# Видалити конкретний тег
note.tags.remove(tag_py) # DELETE FROM note_tags WHERE ...
# Замінити всі теги
note.tags.set([tag_dj]) # DELETE старих + INSERT нових
# Очистити всі
note.tags.clear() # DELETE FROM note_tags WHERE note_id=...
# Реверс — нотатки з тегом:
tag_py.notes.all() # SELECT * FROM notes JOIN note_tags WHERE tag_id=...
Junction = окрема таблиця. Тут немає чарів — це звичайна таблиця з двома FK. Хочеш додати
added_atабоroleдо зв'язку? Передайthrough=і опиши свою junction модель.
on_delete — що відбувається при видаленні батька?¶
Помилка: дефолт CASCADE для всього → одне видалення користувача може стерти сотні рядків. Завжди свідомо вибирай стратегію. Це бізнес-рішення, не технічне.
# CASCADE — видалити дочірні записи разом з батьком
class Reminder(models.Model):
note = models.ForeignKey(Note, on_delete=models.CASCADE, related_name='reminders')
# Видалення Note → видаляються всі Reminder цієї Note
# Логіка: Reminder без Note безглузде → CASCADE правильний вибір
# SET_NULL — FK стає NULL, дочірній запис залишається
class Note(models.Model):
notebook = models.ForeignKey(
Notebook, on_delete=models.SET_NULL,
null=True, blank=True, # ← null=True ОБОВ'ЯЗКОВИЙ при SET_NULL
)
# Видалення Notebook → Note.notebook = NULL
# Логіка: нотатка має бути збережена! Тільки записник видаляється.
# PROTECT — забороняє видалення якщо є дочірні записи
class Note(models.Model):
tag = models.ForeignKey(Tag, on_delete=models.PROTECT)
# Видалення Tag → ProtectedError (виняток)
# Логіка: не дай видалити тег поки він використовується
# RESTRICT — схоже на PROTECT але дозволяє каскад з вищого рівня
# Складні ієрархії, рідко використовується
# SET_DEFAULT — FK стає default значенням
class Note(models.Model):
notebook = models.ForeignKey(
Notebook, on_delete=models.SET_DEFAULT,
default=1, # ← pk дефолтного записника
)
# DO_NOTHING — залишає orphan запис (FK вказує на неіснуючий id)
# УНИКАЙ: призводить до IntegrityError або брудних даних
Матриця вибору on_delete¶
| Стратегія | Що робить | Коли вибирати |
|---|---|---|
CASCADE |
Видалити дочірні автоматично | Reminder → Note: нагадування без нотатки не має сенсу |
SET_NULL |
FK → NULL, дочірні живуть далі | Note → Notebook: нотатка існує без записника. Поле має бути null=True |
SET_DEFAULT |
FK → значення default |
Note → Notebook: перенести в «Default» записник |
PROTECT |
ProtectedError, видалення заборонене |
Tag: не дозволити видаляти тег поки він використовується |
RESTRICT |
Заборонити, але дозволити каскад з вищого рівня | Складні домени з кількома рівнями власності |
DO_NOTHING |
БД сама вирішує (часто = orphan) | Майже ніколи. Тільки якщо керуєш consistency вручну (triggers) |
Конкретно для Notes Platform:
| Якщо видалити... | Що хочемо з дочірніми? | on_delete |
|---|---|---|
| Note | Видалити Reminder (не мають сенсу без Note) | CASCADE |
| Notebook | Зберегти Note (нотатка важливіша за записник) | SET_NULL |
| User | Видалити всі Note (контент belongs to user) | CASCADE |
| Tag | Заборонити видалення (тег використовується) | PROTECT |
Повна схема: 11 таблиць Notes Platform¶
schema_v1 · 11 tables · 1 junction table · postgres-ready
─────────────────────────────────────────────────────────
USER (auth_user) USER_PROFILE ←── 1:1 OneToOne
┌──────────────┬─────────────┐ ┌────────────────┬──────────────────┐
│ id │ bigint PK │──┐ │ id │ bigint PK │
│ username │ varchar(150)│ └──►│ user_id │ bigint UNIQUE FK │
│ email │ varchar(254)│ │ display_name │ varchar(60) │
│ password │ varchar(128)│ │ timezone │ varchar(40) │
│ date_joined │ timestamp │ │ avatar_url │ text │
└──────────────┴─────────────┘ └────────────────┴──────────────────┘
│ owns 1:N │ writes 1:N │ owns tags 1:N
▼ ▼ ▼
NOTEBOOK NOTE (центральна) TAG
┌─────────────┬──────────┐ ┌───────────────┬──────────────────┐ ┌──────────┬───────────────────┐
│ id │ bigint PK│ │ id │ bigint PK │ │ id │ bigint PK │
│ user_id │ bigint FK│ │ user_id │ bigint FK │ │ user_id │ bigint FK │
│ title │ varchar │ │ notebook_id │ bigint NULL FK │ │ name │ varchar(50) │
│ color │ varchar │ │ title │ varchar(200) │ │ color │ varchar(7) │
│ is_default │ boolean │ │ content │ text │ └──────────┴───────────────────┘
└─────────────┴──────────┘ │ priority │ smallint │ unique_together(user_id, name)
│ contains 1:N │ is_pinned │ boolean │
└─────────────────────►│ is_archived │ boolean │◄──── M:N через NOTE_TAGS
│ created_at │ timestamp │
│ updated_at │ timestamp │
│ views_count │ integer │
└───────────────┴──────────────────┘
│ reminds 1:N
▼
REMINDER NOTE_TAGS (junction)
┌───────────┬──────────────┐ ┌──────────┬────────────┐
│ id │ bigint PK │ │ note_id │ bigint FK │
│ note_id │ bigint FK │ │ tag_id │ bigint FK │
│ remind_at │ timestamp │ └──────────┴────────────┘
│ is_sent │ boolean │ UNIQUE(note_id, tag_id)
│ repeat │ varchar(20) │ Django створює авто
└───────────┴──────────────┘
TODO_LIST TODO_ITEM
┌─────────────┬──────────┐ ┌────────────────┬──────────────┐
│ id │ bigint PK│ │ id │ bigint PK │
│ user_id │ bigint FK│───►│ todo_list_id │ bigint FK │
│ title │ varchar │ │ text │ varchar(500) │
│ is_completed│ boolean │ │ is_done │ boolean │
└─────────────┴──────────┘ │ order_position │ integer │
│ due_date │ date NULL │
└────────────────┴──────────────┘
SHOPPING_LIST SHOP_ITEM
┌─────────────┬──────────┐ ┌──────────────────┬──────────────────┐
│ id │ bigint PK│ │ id │ bigint PK │
│ user_id │ bigint FK│───►│ shopping_list_id │ bigint FK │
│ title │ varchar │ │ name │ varchar(120) │
│ store_name │ varchar │ │ quantity │ decimal(8,2) │
└─────────────┴──────────┘ │ unit │ varchar(20) │
│ is_purchased │ boolean │
│ estimated_price │ decimal(10,2) │
└──────────────────┴──────────────────┘
⚠️ DecimalField для грошей — не FloatField!
Mermaid ER-діаграма¶
erDiagram
USER ||--o| USER_PROFILE : "1:1 has profile"
USER ||--o{ NOTEBOOK : "1:N owns"
USER ||--o{ NOTE : "1:N writes"
USER ||--o{ TAG : "1:N owns tags"
USER ||--o{ TODO_LIST : "1:N owns"
USER ||--o{ SHOPPING_LIST : "1:N owns"
NOTEBOOK ||--o{ NOTE : "1:N contains (SET_NULL)"
NOTE }o--o{ TAG : "M:N tagged with"
NOTE ||--o{ REMINDER : "1:N reminds (CASCADE)"
TODO_LIST ||--o{ TODO_ITEM : "1:N has items"
SHOPPING_LIST ||--o{ SHOP_ITEM : "1:N has items"
USER {
bigint id PK
string username
string email
}
NOTE {
bigint id PK
bigint user_id FK
bigint notebook_id FK
string title
text content
int priority
boolean is_pinned
timestamp updated_at
}
NOTEBOOK {
bigint id PK
bigint user_id FK
string title
string color
boolean is_default
}
TAG {
bigint id PK
bigint user_id FK
string name
string color
}
REMINDER {
bigint id PK
bigint note_id FK
timestamp remind_at
boolean is_sent
}
TODO_LIST {
bigint id PK
bigint user_id FK
string title
}
TODO_ITEM {
bigint id PK
bigint todo_list_id FK
string text
boolean is_done
int order_position
}
SHOPPING_LIST {
bigint id PK
bigint user_id FK
string title
}
SHOP_ITEM {
bigint id PK
bigint shopping_list_id FK
string name
decimal quantity
boolean is_purchased
decimal estimated_price
}
Flowchart: процес проектування моделей¶
flowchart TD
A[Описати домен словами] --> B[Знайти сутності\nіменники у тексті]
B --> C[Визначити атрибути\nкожної сутності]
C --> D{Зв'язок між\nсутностями?}
D -->|"1:1"| E[OneToOneField\nFK + UNIQUE]
D -->|"1:N"| F[ForeignKey\nна стороні Many]
D -->|"M:N"| G[ManyToManyField\nDjango junction table]
E --> H[Вибрати on_delete]
F --> H
G --> I[Django створить\njunction table]
H --> J[Нормалізація:\nприбрати дублювання]
J --> K[Написати Django модель]
Навігація¶
- Попередня: Проектування домену
- Наступна: Django моделі