Table Joins

Об'єднання таблиць та INNER JOIN

Глибоке дослідження концепції JOIN операцій, декартового добутку та внутрішнього об'єднання в MS SQL Server

Об'єднання таблиць та INNER JOIN

Проблема: Чому однієї таблиці недостатньо?

Уявіть, що ви розробляєте систему управління студентами університету. Спочатку ви створили просту таблицю:

CREATE TABLE StudentsSimple (
    Id INT PRIMARY KEY,
    FirstName NVARCHAR(50),
    LastName NVARCHAR(50),
    GroupName NVARCHAR(20),        -- Назва групи
    Faculty NVARCHAR(100),          -- Факультет
    Specialization NVARCHAR(100),   -- Спеціалізація
    TeacherName NVARCHAR(100),      -- Куратор групи
    Grants DECIMAL(10,2)
);

Вставляємо дані:

INSERT INTO StudentsSimple VALUES
(1, 'Іван', 'Петренко', '30PR11', 'IT', 'Розробка ПЗ', 'Марія Іванова', 1200),
(2, 'Олена', 'Коваленко', '30PR11', 'IT', 'Розробка ПЗ', 'Марія Іванова', 1100),
(3, 'Петро', 'Сидоренко', '30PR12', 'IT', 'Кібербезпека', 'Олег Смирнов', 1300);

Проблеми такого підходу

Аномалії даних: Цей підхід призводить до серйозних проблем!

Кожен студент з групи 30PR11 містить однакову інформацію про факультет, спеціалізацію та куратора:

СтудентGroupNameFacultySpecializationTeacherName
Іван Петренко30PR11ITРозробка ПЗМарія Іванова
Олена Коваленко30PR11ITРозробка ПЗМарія Іванова

Марнування пам'яті: Якщо в групі 30 студентів, інформація про групу дублюється 30 разів!


Рішення: Нормалізація та зв'язки між таблицями

Принцип нормалізації

Нормалізація (Normalization) — процес організації даних у базі даних для мінімізації дублювання та забезпечення цілісності.

Правильна структура:

Loading diagram...
erDiagram
    Groups {
        int Id PK
        nvarchar GroupName
        nvarchar Faculty
        nvarchar Specialization
        int TeacherId FK
    }

    Students {
        int Id PK
        nvarchar FirstName
        nvarchar LastName
        int GroupId FK
        decimal Grants
    }

    Teachers {
        int Id PK
        nvarchar FirstName
        nvarchar LastName
    }

    Groups ||--o{ Students : "має багато"
    Teachers ||--o{ Groups : "курує багато"

Створення нормалізованої структури

-- Таблиця викладачів
CREATE TABLE Teachers (
    Id INT PRIMARY KEY IDENTITY,
    FirstName NVARCHAR(50),
    LastName NVARCHAR(50)
);

-- Таблиця груп
CREATE TABLE Groups (
    Id INT PRIMARY KEY IDENTITY,
    GroupName NVARCHAR(20) UNIQUE,
    Faculty NVARCHAR(100),
    Specialization NVARCHAR(100),
    TeacherId INT FOREIGN KEY REFERENCES Teachers(Id)
);

-- Таблиця студентів
CREATE TABLE Students (
    Id INT PRIMARY KEY IDENTITY,
    FirstName NVARCHAR(50),
    LastName NVARCHAR(50),
    GroupId INT FOREIGN KEY REFERENCES Groups(Id),
    Grants DECIMAL(10,2)
);

Переваги:

✅ Дані про групу зберігаються один раз
✅ Можна додати групу без студентів
✅ Оновлення викладача — один рядок
✅ Видалення студента не втрачає інформацію про групу


Типи зв'язків між таблицями

Loading diagram...
graph TD
    A[Типи зв'язків<br/>Relationships] --> B[1:1<br/>One-to-One]
    A --> C[1:N<br/>One-to-Many]
    A --> D[N:M<br/>Many-to-Many]

    B --> B1["Приклад:<br/>Студент ↔ Паспорт"]
    C --> C1["Приклад:<br/>Група → Студенти"]
    D --> D1["Приклад:<br/>Студенти ↔ Предмети"]

    style A fill:#3b82f6,color:#fff
    style C fill:#10b981,color:#fff

1:N (One-to-Many) — Найпоширеніший

Приклад: Одна група має багато студентів, але кожен студент належить одній групі.

Groups (Id=1, GroupName='30PR11')
    ↓ має багато
Students (Id=1, GroupId=1)
Students (Id=2, GroupId=1)
Students (Id=3, GroupId=1)

Реалізація: FOREIGN KEY у таблиці "багато" (Students.GroupId → Groups.Id)


Проблема об'єднання: Як отримати дані з кількох таблиць?

Тепер дані розподілені по таблицях. Як отримати список студентів з назвами груп?

Спроба 1: Два окремі запити (❌ Неефективно)

-- Запит 1: Отримати студентів
SELECT Id, FirstName, LastName, GroupId FROM Students;

-- Результат:
-- Id | FirstName | LastName  | GroupId
-- 1  | Іван      | Петренко  | 1
-- 2  | Олена     | Коваленко | 1

-- Запит 2: Для кожного GroupId дізнатися назву
SELECT GroupName FROM Groups WHERE Id = 1;  -- 30PR11

Проблеми:

  • Потрібно N+1 запитів (1 для студентів + N для груп)
  • Неможливо відфільтрувати або сортувати по GroupName

Спроба 2: Декартовий добуток (❌ Надмірність)

SELECT * FROM Students, Groups;

Що відбувається?

Loading diagram...
graph LR
    subgraph Students 3 рядки
        S1[Іван, GroupId=1]
        S2[Олена, GroupId=1]
        S3[Петро, GroupId=2]
    end

    subgraph Groups 4 рядки
        G1[30PR11]
        G2[30PR12]
        G3[32SS11]
        G4[32SS12]
    end

    S1 -.-> G1
    S1 -.-> G2
    S1 -.-> G3
    S1 -.-> G4

    S2 -.-> G1
    S2 -.-> G2

    S3 -.-> G1
    S3 -.-> G2

    Result[Результат: 3 × 4 = 12 рядків]

    style Result fill:#ef4444,color:#fff

Результат: 3 студенти × 4 групи = 12 рядків (замість потрібних 3!)

| FirstName | GroupId | GroupName | | :-------- | ------: | :-------- | -------------- | | Іван | 1 | 30PR11 | | Іван | 1 | 30PR12 | ← Неправильно! | | Іван | 1 | 32SS11 | ← Неправильно! | | Іван | 1 | 32SS12 | ← Неправильно! | | Олена | 1 | 30PR11 | | ... | ... | ... |

Декартовий добуток (Cartesian Product) — кожен рядок з першої таблиці поєднується з кожним рядком другої таблиці. Це майже завжди помилка!

INNER JOIN: Правильне рішення

INNER JOIN дозволяє об'єднати таблиці за умовою, залишаючи тільки відповідні рядки.

Синтаксис

SELECT <стовпці>
FROM <Таблиця_1>
INNER JOIN <Таблиця_2>
    ON <умова_зв'язку>

Базовий приклад

SELECT
    S.FirstName,
    S.LastName,
    G.GroupName
FROM Students AS S
INNER JOIN Groups AS G
    ON S.GroupId = G.Id;

Результат:

FirstNameLastNameGroupName
ІванПетренко30PR11
ОленаКоваленко30PR11
ПетроСидоренко30PR12

Анатомія коду:

  1. Рядок 3: FROM Students AS S — призначаємо псевдонім S для Students
  2. Рядок 4: INNER JOIN Groups AS G — приєднуємо таблицю Groups з псевдонімом G
  3. Рядок 5: ON S.GroupId = G.Idумова зв'язку: GroupId студента дорівнює Id групи
Псевдоніми (Aliases): AS S, AS G дозволяють скоротити назви таблиць та уникнути конфліктів імен.

Як працює INNER JOIN під капотом?

Loading diagram...
@startuml
skinparam backgroundColor #1e1e2e
skinparam defaultFontColor #cdd6f4

participant "Students" as S #a6e3a1
participant "JOIN Engine" as J #f9e2af
participant "Groups" as G #89b4fa
participant "Result" as R #cba6f7

S -> J: Читає всі рядки Students
activate J
J -> J: Іван (GroupId=1)
J -> J: Олена (GroupId=1)
J -> J: Петро (GroupId=2)

J -> G: Для кожного студента\nшукає Group.Id
activate G
G --> J: GroupId=1 → 30PR11
G --> J: GroupId=1 → 30PR11
G --> J: GroupId=2 → 30PR12
deactivate G

J -> R: Формує результат
activate R
R -> R: Іван + 30PR11
R -> R: Олена + 30PR11
R -> R: Петро + 30PR12
deactivate R

deactivate J

@enduml

Покроковий процес:

Крок 1: Сканування першої таблиці

SQL Server читає всі рядки з Students:
- Іван, GroupId=1
- Олена, GroupId=1
- Петро, GroupId=2

Крок 2: Для кожного рядка перевірка умови ON

Для Івана (GroupId=1):
  Шукає в Groups рядок де Id=1 → Знайдено: 30PR11

Для Олени (GroupId=1):
  Шукає в Groups рядок де Id=1 → Знайдено: 30PR11

Для Петра (GroupId=2):
  Шукає в Groups рядок де Id=2 → Знайдено: 30PR12

Крок 3: Об'єднання стовпців

Конкатенація рядків Students + Groups:
[Іван, Петренко, 1] + [1, 30PR11] = [Іван, Петренко, 30PR11]

Псевдоніми таблиць: Обов'язкова практика

Проблема без псевдонімів

-- ❌ ПОГАНО: Важко читати
SELECT Students.FirstName, Students.LastName, Groups.GroupName
FROM Students
INNER JOIN Groups ON Students.GroupId = Groups.Id;

З псевдонімами

-- ✅ ДОБРЕ: Читабельно
SELECT S.FirstName, S.LastName, G.GroupName
FROM Students AS S
INNER JOIN Groups AS G ON S.GroupId = G.Id;
Ключове слово AS можна опускати: FROM Students S працює так само, як FROM Students AS S.

Конфлікти імен стовпців

Проблема: Однакові назви

Обидві таблиці мають стовпець Id:

-- ❌ ПОМИЛКА: Неоднозначність!
SELECT Id, FirstName, GroupName
FROM Students AS S
INNER JOIN Groups AS G ON S.GroupId = G.Id;

Помилка:

Ambiguous column name 'Id'.

Рішення: Вказати таблицю

-- ✅ ПРАВИЛЬНО
SELECT
    S.Id AS StudentId,     -- Явно вказуємо S.Id
    S.FirstName,
    G.Id AS GroupId,       -- Явно вказуємо G.Id
    G.GroupName
FROM Students AS S
INNER JOIN Groups AS G ON S.GroupId = G.Id;

Результат:

StudentIdFirstNameGroupIdGroupName
1Іван130PR11
2Олена130PR11

Множинні JOIN: Об'єднання 3+ таблиць

Завдання: Отримати список студентів з назвою групи та ім'ям куратора.

Схема зв'язків

Loading diagram...
erDiagram
    Teachers ||--o{ Groups : "курує"
    Groups ||--o{ Students : "має"

    Teachers {
        int Id PK
        nvarchar FirstName
        nvarchar LastName
    }

    Groups {
        int Id PK
        nvarchar GroupName
        int TeacherId FK
    }

    Students {
        int Id PK
        nvarchar FirstName
        int GroupId FK
    }

Запит з двома JOIN

SELECT
    S.FirstName AS StudentName,
    G.GroupName,
    T.FirstName + ' ' + T.LastName AS TeacherName
FROM Students AS S
INNER JOIN Groups AS G ON S.GroupId = G.Id
INNER JOIN Teachers AS T ON G.TeacherId = T.Id;

Результат:

StudentNameGroupNameTeacherName
Іван30PR11Марія Іванова
Олена30PR11Марія Іванова
Петро30PR12Олег Смирнов

Анатомія коду:

  • Рядок 4: Конкатенація імені та прізвища викладача через +
  • Рядок 6: Перший JOIN — Students → Groups (через GroupId)
  • Рядок 7: Другий JOIN — Groups → Teachers (через TeacherId)
Порядок JOIN: SQL Server автоматично оптимізує порядок, але логічно краще йти від "головної" таблиці (Students) до залежних.

Порядок виконання множинних JOIN

FROM Students AS S           -- 1. Базова таблиця
INNER JOIN Groups AS G       -- 2. Приєднати Groups
    ON S.GroupId = G.Id
INNER JOIN Teachers AS T     -- 3. До результату (Students+Groups) приєднати Teachers
    ON G.TeacherId = T.Id
Loading diagram...
graph LR
    A[Students<br/>3 рядки] --> B{JOIN Groups}
    B --> C[Students + Groups<br/>3 рядки]
    C --> D{JOIN Teachers}
    D --> E[Students + Groups + Teachers<br/>3 рядки]

    style B fill:#3b82f6,color:#fff
    style D fill:#3b82f6,color:#fff
    style E fill:#10b981,color:#fff

WHERE після JOIN: Фільтрація результатів

Приклад: Студенти зі стипендією > 1150

SELECT
    S.FirstName,
    S.LastName,
    S.Grants,
    G.GroupName
FROM Students AS S
INNER JOIN Groups AS G ON S.GroupId = G.Id
WHERE S.Grants > 1150;

Результат:

FirstNameLastNameGrantsGroupName
ІванПетренко120030PR11
ПетроСидоренко130030PR12

Порядок виконання:

  1. FROM + INNER JOIN — об'єднання таблиць
  2. WHERE — фільтрація результату
  3. SELECT — вибірка стовпців

WHERE vs ON: Різниця критична!

FROM Students AS S
INNER JOIN Groups AS G ON S.GroupId = G.Id
WHERE G.GroupName = '30PR11';  -- Фільтрує результат JOIN

Процес:

  1. Об'єднує ВСІ студенти + групи
  2. Залишає тільки рядки де GroupName = '30PR11'
Для INNER JOIN різниці в результаті немає, але для LEFT JOIN це критично важливо (детально в наступному розділі).

##Типові помилки при використанні JOIN

❌ Помилка 1: Забули ON

-- ПОМИЛКА!
SELECT * FROM Students AS S
INNER JOIN Groups AS G;

Помилка:

The multi-part identifier "S.GroupId" could not be bound.
Syntax error near 'INNER JOIN'.

Рішення: Завжди вказуйте умову ON:

-- ✅ ПРАВИЛЬНО
INNER JOIN Groups AS G ON S.GroupId = G.Id

❌ Помилка 2: Неправильна умова ON

-- ЛОГІЧНА ПОМИЛКА: Порівнюємо Id з GroupId
SELECT * FROM Students AS S
INNER JOIN Groups AS G ON S.Id = G.Id;  -- ❌ Неправильно!

Проблема: Студент з Id=1 поєднається з групою Id=1, але це НЕ належність студента до групи!

Рішення:

-- ✅ ПРАВИЛЬНО: Порівнюємо FOREIGN KEY з PRIMARY KEY
ON S.GroupId = G.Id

❌ Помилка 3: Декартовий добуток випадково

-- Забули вказати зв'язок між Students і Groups
SELECT *
FROM Students AS S, Groups AS G, Teachers AS T;
-- Результат: 10 студентів × 4 групи × 5 викладачів = 200 рядків!
Завжди використовуйте явний синтаксис INNER JOIN замість старого стилю , (кома).

Performance: Індекси для JOIN

Проблема: Повільний JOIN без індексів

SELECT S.FirstName, G.GroupName
FROM Students AS S
INNER JOIN Groups AS G ON S.GroupId = G.Id;

Без індексу на Students.GroupId:

Table Scan на обох таблицях → O(N × M) операцій

З індексом:

CREATE INDEX IX_Students_GroupId ON Students(GroupId);

Результат:

Index Seek → O(N log M) операцій — в 100+ разів швидше!
Best Practice: Створюйте індекси на всі стовпці FOREIGN KEY, які використовуються у JOIN.

Execution Plan для JOIN

-- Execution Plan:
-- Nested Loop Join (Cost: 85%)
--   ├─ Table Scan Students (Cost: 50%)
--   └─ Table Scan Groups (Cost: 35%)

Best Practices для INNER JOIN

1. Завжди використовуйте псевдоніми

-- ✅ ДОБРЕ
FROM Students AS S INNER JOIN Groups AS G

-- ❌ ПОГАНО
FROM Students INNER JOIN Groups

2. Явно вказуйте таблицю для кожного стовпця

-- ✅ ДОБРЕ
SELECT S.Id, S.FirstName, G.GroupName

-- ❌ ПОГАНО (працює, але небезпечно)
SELECT Id, FirstName, GroupName

3. Обмежуйте стовпці у SELECT

-- ✅ ДОБРЕ: Тільки потрібні стовпці
SELECT S.FirstName, G.GroupName

-- ❌ ПОГАНО: Завантажує всі дані
SELECT *

4. Створюйте індекси на FK

CREATE INDEX IX_Students_GroupId ON Students(GroupId);
CREATE INDEX IX_Groups_TeacherId ON Groups(TeacherId);

5. Використовуйте WHERE для фільтрації

-- Спочатку JOIN, потім фільтрація
INNER JOIN Groups AS G ON S.GroupId = G.Id
WHERE G.Faculty = 'IT'

Практичні завдання

Завдання 1: Базовий JOIN

Отримати список студентів з назвою їхньої групи, відсортований за прізвищем.

Завдання 2: Три таблиці

Вивести студентів, їхню групу та куратора, тільки з факультету 'IT'.

Завдання 3: Агрегація з JOIN

Порахувати кількість студентів у кожній групі.


Резюме

Ключові висновки:
  1. Нормалізація вирішує проблеми дублювання даних
  2. INNER JOIN об'єднує таблиці за умовою, залишаючи тільки відповідні рядки
  3. Псевдоніми обов'язкові для читабельності
  4. ON задає умову зв'язку, WHERE фільтрує результат
  5. Індекси на FK критичні для продуктивності
  6. Можна об'єднувати 3+ таблиці послідовними JOIN
Що далі?В наступному розділі ви дізнаєтесь про:
  • LEFT JOIN — коли потрібні всі рядки з лівої таблиці
  • RIGHT JOIN та FULL JOIN
  • Робота з NULL значеннями після JOIN
  • Діаграми Венна для розуміння JOIN типів
Перейти до розділу "Зовнішні об'єднання" →

Додаткові ресурси

Copyright © 2026