Урок 05

CRUD и запросы

CRUD

CRUDSQL
CreateINSERT
ReadSELECT
UpdateUPDATE
DeleteDELETE

INSERT

Одна строка:

INSERT INTO users (email, name) VALUES ('a@b.com', 'Алиса');

Несколько:

INSERT INTO users (email, name) VALUES
    ('a@b.com', 'Алиса'),
    ('c@d.com', 'Боб'),
    ('e@f.com', 'Катя');

Что даёт: один запрос вместо цикла. Быстрее в разы.

Вернуть id:

INSERT INTO users (email, name) VALUES ('a@b.com', 'Алиса');
SELECT LAST_INSERT_ID();

⚠️ MySQL не имеет RETURNING как PostgreSQL. LAST_INSERT_ID() работает только для AUTO_INCREMENT.

INSERT ... SELECT:

INSERT INTO users_backup (email, name)
SELECT email, name FROM users WHERE created_at < '2025-01-01';

ON DUPLICATE KEY UPDATE (upsert)

MySQL-специфично:

INSERT INTO users (email, name)
VALUES ('a@b.com', 'Алиса')
ON DUPLICATE KEY UPDATE
    name = VALUES(name),
    updated_at = NOW();

Что даёт: insert или update при конфликте PRIMARY KEY / UNIQUE.

VALUES(col) — то, что пытались вставить. В MySQL 8.0.20+ лучше:

INSERT INTO users (email, name)
VALUES ('a@b.com', 'Алиса') AS new
ON DUPLICATE KEY UPDATE
    name = new.name,
    updated_at = NOW();

⚠️ ON DUPLICATE KEY UPDATE срабатывает на любой UNIQUE-конфликт, не только на PK.

REPLACE INTO

REPLACE INTO users (id, email, name) VALUES (1, 'a@b.com', 'Алиса');

Что делает: DELETE + INSERT (или INSERT при отсутствии).

⚠️ Удаляет старую строку и создаёт новую. AUTO_INCREMENT другой, ON DELETE CASCADE сработает.

Правило: почти всегда — INSERT ... ON DUPLICATE KEY UPDATE, не REPLACE.

INSERT IGNORE

INSERT IGNORE INTO users (email, name) VALUES ('a@b.com', 'Алиса');

Что делает: при ошибке (дубликат, NULL, etc.) — пропускает.

⚠️ Игнорирует все ошибки, не только дубликаты. Опасно.

Правило: лучше ON DUPLICATE KEY UPDATE.

SELECT

SELECT * FROM users;
SELECT id, email FROM users;
SELECT email AS user_email FROM users;
SELECT DISTINCT status FROM orders;
SELECT * FROM users LIMIT 10 OFFSET 20;

⚠️ SELECT * в проде — плохо (тянет лишнее, ломается при ALTER).

WHERE

WHERE age = 25
WHERE age != 25
WHERE age <> 25
WHERE age > 18 AND country = 'RU'
WHERE status = 'paid' OR status = 'shipped'
WHERE status IN ('pending', 'paid')
WHERE age BETWEEN 18 AND 65
WHERE email LIKE '%@gmail.com'      -- регистрозависимо в utf8mb4_general_ci? НЕТ, зависит от коллации
WHERE email IS NULL
WHERE email IS NOT NULL

⚠️ LIKE регистронезависим в дефолтных коллациях (_ci). Для регистрозависимого — BINARY или _bin.

ORDER BY

ORDER BY created_at DESC
ORDER BY name ASC, age DESC
ORDER BY id LIMIT 10
ORDER BY created_at DESC NULLS LAST   -- в MySQL через IS NULL

NULLS LAST (эмуляция):

ORDER BY col IS NULL, col ASC;

GROUP BY

SELECT country, COUNT(*) AS cnt
FROM users
GROUP BY country
ORDER BY cnt DESC;

⚠️ В MySQL по умолчанию ONLY_FULL_GROUP_BY включён (с 5.7). Правило: все неагрегированные колонки в GROUP BY.

HAVING

SELECT country, COUNT(*) AS cnt
FROM users
GROUP BY country
HAVING COUNT(*) > 10;

Правило:

  • WHERE — до группировки.
  • HAVING — после.

UPDATE

UPDATE users SET name = 'Алиса' WHERE id = 1;

UPDATE users
SET name = 'Алиса',
    email = 'alice@b.com',
    updated_at = NOW()
WHERE id = 1;

Из другой таблицы (JOIN):

UPDATE orders o
JOIN (
    SELECT order_id, SUM(price * quantity) AS total
    FROM order_items
    GROUP BY order_id
) s ON s.order_id = o.id
SET o.total = s.total;

Что даёт: UPDATE ... JOIN — MySQL-специфичный синтаксис. В PostgreSQL — UPDATE FROM.

⚠️ Забыли WHERE — обновит всю таблицу.

Найти обновлённые строки:

SELECT ROW_COUNT();

DELETE

DELETE FROM users WHERE id = 1;

DELETE FROM orders
WHERE status = 'cancelled'
  AND created_at < NOW() - INTERVAL 1 YEAR;

С JOIN:

DELETE o FROM orders o
JOIN users u ON u.id = o.user_id
WHERE u.email = 'spam@example.com';

Multi-table delete:

DELETE o, oi
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'cancelled';

⚠️ DELETE без WHERE — снесёт всё. Проверяйте через SELECT сначала.

LIMIT

SELECT * FROM users LIMIT 10;
SELECT * FROM users LIMIT 20, 10;   -- OFFSET 20, LIMIT 10
SELECT * FROM users LIMIT 10 OFFSET 20;

Что даёт: пагинация.

⚠️ Большой OFFSET — медленно. Для больших таблиц — cursor pagination:

SELECT * FROM users WHERE id > 100000 ORDER BY id LIMIT 10;

Агрегаты

SELECT
    COUNT(*) AS total,
    COUNT(bio) AS with_bio,
    COUNT(DISTINCT country) AS countries,
    AVG(age) AS avg_age,
    MIN(created_at) AS first_user,
    MAX(created_at) AS last_user,
    SUM(total) AS revenue
FROM users;

⚠️ COUNT(col) игнорирует NULL.

CASE

SELECT
    email,
    CASE
        WHEN age < 18 THEN 'ребёнок'
        WHEN age < 65 THEN 'взрослый'
        ELSE 'пенсионер'
    END AS category
FROM users;

IF / IFNULL / COALESCE

SELECT IF(age >= 18, 'adult', 'minor') FROM users;
SELECT IFNULL(bio, 'нет описания') FROM users;
SELECT COALESCE(phone, email, 'нет контакта') FROM users;
SELECT NULLIF(country, '') FROM users;

Разница:

  • IF — тернарный оператор.
  • IFNULL(a, b) — если a NULL, вернуть b.
  • COALESCE(a, b, c, ...) — первое не-NULL.
  • NULLIF(a, b) — NULL, если a = b.

Транзакции

START TRANSACTION;

UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

COMMIT;
-- или ROLLBACK;

Autocommit:

SELECT @@autocommit;   -- 1 по умолчанию
SET autocommit = 0;    -- не рекомендуется

SAVEPOINT

START TRANSACTION;
INSERT INTO users (email) VALUES ('a@b.com');
SAVEPOINT sp1;
INSERT INTO users (email) VALUES ('a@b.com');   -- ошибка UNIQUE
ROLLBACK TO SAVEPOINT sp1;
COMMIT;

Параметризация

Node.js (mysql2):

// ❌
await pool.query(`SELECT * FROM users WHERE email = '${email}'`);

// ✅
await pool.query('SELECT * FROM users WHERE email = ?', [email]);

PHP (PDO):

$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);

Laravel:

User::where('email', $email)->first();
DB::select('SELECT * FROM users WHERE email = ?', [$email]);

⚠️ Никогда не конкатенируйте SQL.

Пример: полный цикл

-- 1. INSERT
INSERT INTO users (email, name) VALUES ('alice@example.com', 'Алиса');
SET @user_id = LAST_INSERT_ID();

-- 2. SELECT
SELECT id, email, name, created_at FROM users WHERE id = @user_id;

-- 3. UPDATE
UPDATE users SET name = 'Алиса П.' WHERE id = @user_id;

-- 4. Upsert
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Алиса Петрова')
ON DUPLICATE KEY UPDATE name = VALUES(name);

-- 5. DELETE
DELETE FROM users WHERE id = @user_id;

Частые ошибки

1. UPDATE / DELETE без WHERE:

Обновит/удалит всё

Решение: сначала SELECT.

2. INSERT в цикле:

foreach ($rows as $r) {
    $pdo->exec("INSERT ...");   // 1000 запросов
}

Решение: batch INSERT с несколькими VALUES.

3. SELECT *:

Лишний трафик, ломкость

Решение: явный список.

4. LIKE '%abc%' без индекса:

Full scan

Решение: FULLTEXT или pg_trgm (в MariaDB есть).

5. REPLACE INTO вместо upsert:

Сносит связанные данные через CASCADE

Решение: ON DUPLICATE KEY UPDATE.

6. Неявное приведение типов:

WHERE varchar_col = 123   -- ломает индекс

Решение: одинаковые типы.

7. Транзакция с HTTP внутри:

Блокировки надолго

Решение: HTTP — вне транзакции.

8. LAST_INSERT_ID при batch INSERT:

Возвращает id первой строки, не последней

Решение: знать о поведении.

Практика

1. Создайте таблицу и вставьте 3 строки одним INSERT.

2. Сделайте upsert через ON DUPLICATE KEY UPDATE.

3. Обновите с JOIN.

4. Удалите с JOIN.

5. Транзакция: переведите 100 между счетами и откатите.

6. Параметризованный запрос в своём приложении.

Итог

  • INSERT: batch, ON DUPLICATE KEY UPDATE, INSERT IGNORE.
  • SELECT: WHERE, ORDER BY, LIMIT.
  • UPDATE: с WHERE, поддержка JOIN.
  • DELETE: сначала SELECT, поддержка JOIN.
  • Транзакции: START TRANSACTION / COMMIT / ROLLBACK.
  • Параметризация: ? или :name.
  • LAST_INSERT_ID() — id после INSERT.

Дальше — JOIN и связи.