add postgresql homework
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT PRIMARY KEY, /* сделал первичные ключи в каждой таблице BIGINT, кол-во
|
||||||
|
записей может быть большим */
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
age SMALLINT, -- Тип данных INT посчитал избыточным
|
||||||
|
email VARCHAR(100),
|
||||||
|
country VARCHAR(2), -- Идеально для ISO - кода страны
|
||||||
|
is_active BOOLEAN,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE products (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
category VARCHAR(50),
|
||||||
|
price DECIMAL(10,2) NOT NULL, -- Сделал тип данных для денежных операций
|
||||||
|
in_stock BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id BIGINT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
order_date TIMESTAMPTZ NOT NULL,
|
||||||
|
status VARCHAR(20), -- Читаемый статус нагляднее, не нужен справочник по кодам
|
||||||
|
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE order_items (
|
||||||
|
order_id BIGINT,
|
||||||
|
product_id BIGINT,
|
||||||
|
quantity INT NOT NULL, /* Предположил, что типа данных SMALL INT возможно не
|
||||||
|
хватит для обозначения количества */
|
||||||
|
price_at_purchase DECIMAL(10,2) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY(order_id, product_id),
|
||||||
|
FOREIGN KEY (order_id) REFERENCES orders(id),
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (id, name, age, email, country, is_active, created_at)
|
||||||
|
VALUES
|
||||||
|
(1, 'Alice', 25, 'alice@example.com', 'US', TRUE, '2024-01-10 10:00:00'),
|
||||||
|
(2, 'Bob', 30, 'bob@example.com', 'UK', TRUE, '2024-02-05 09:30:00'),
|
||||||
|
(3, 'Carol', 27, 'carol@example.com', 'US', FALSE, '2024-03-01 15:20:00'),
|
||||||
|
(4, 'Diana', 22, 'diana@GMAIL.com', 'US', TRUE, '2024-01-12 08:15:00'),
|
||||||
|
(5, 'Evan', 35, 'EVAN@example.com', 'UK', TRUE, '2024-01-18 19:40:00'),
|
||||||
|
(6, 'Farida', 29, 'farida@example.com', 'KZ', TRUE, '2024-02-20 11:05:00'),
|
||||||
|
(7, 'George', 31, 'george+test@example.com', 'US', TRUE, '2024-03-05 16:45:00'),
|
||||||
|
(8, 'Helen', 24, NULL, 'UK', FALSE, '2024-03-06 08:20:00'),
|
||||||
|
(9, 'Ivan', 26, 'ivan@example.com', 'RU', TRUE, '2024-01-11 14:30:00'),
|
||||||
|
(10, 'Julia', 28, 'julia@example.com', 'US', TRUE, '2024-03-07 18:05:00');
|
||||||
|
|
||||||
|
INSERT INTO products (id, name, category, price, in_stock)
|
||||||
|
VALUES
|
||||||
|
(1, 'iPhone 15', 'Электроника', 80000.00, TRUE),
|
||||||
|
(2, 'Kindle', 'Электроника', 15000.00, TRUE),
|
||||||
|
(3, 'Кроссовки Nike', 'Одежда', 9000.00, TRUE),
|
||||||
|
(4, 'Кроссовки NoName', 'Одежда', 3000.00, FALSE),
|
||||||
|
(5, 'Наушники Pro', 'Электроника', 12000.00, TRUE),
|
||||||
|
(6, 'Фитнес-браслет', 'Гаджеты', 6000.00, TRUE),
|
||||||
|
(7, 'Подарочная карта', 'Сервисы', 1000.00, TRUE);
|
||||||
|
|
||||||
|
INSERT INTO orders (id, user_id, order_date, status)
|
||||||
|
VALUES
|
||||||
|
(1, 1, '2025-01-10 12:00:00', 'paid'),
|
||||||
|
(2, 3, '2025-01-12 09:30:00', 'cancelled'),
|
||||||
|
(3, 1, '2025-01-15 18:20:00', 'paid'),
|
||||||
|
(4, 2, '2025-01-20 11:05:00', 'created'),
|
||||||
|
(5, 4, '2025-02-01 17:45:00', 'paid'),
|
||||||
|
(6, 5, '2025-02-10 10:10:00', 'refunded'),
|
||||||
|
(7, 7, '2025-03-01 09:00:00', 'paid'),
|
||||||
|
(8, 9, '2025-03-05 19:30:00', 'paid');
|
||||||
|
|
||||||
|
INSERT INTO order_items (order_id, product_id, quantity, price_at_purchase)
|
||||||
|
VALUES
|
||||||
|
(1, 1, 1, 78000.00),
|
||||||
|
(1, 3, 2, 8500.00),
|
||||||
|
(2, 2, 1, 14000.00),
|
||||||
|
(3, 5, 1, 11000.00),
|
||||||
|
(3, 7, 1, 1000.00),
|
||||||
|
(4, 2, 1, 15000.00),
|
||||||
|
(5, 6, 1, 6000.00),
|
||||||
|
(6, 1, 1, 82000.00),
|
||||||
|
(7, 3, 1, 9000.00),
|
||||||
|
(8, 4, 2, 2800.00);
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
-- Задача 1 — Товары для продажи
|
||||||
|
|
||||||
|
SELECT name,
|
||||||
|
category,
|
||||||
|
price,
|
||||||
|
(price * 1.3) price_with_markup,
|
||||||
|
(price * 1.5) price_with_vat
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
WHERE in_stock = TRUE
|
||||||
|
AND category != '%smartwatch%'
|
||||||
|
AND price BETWEEN 100 AND 1300;
|
||||||
|
|
||||||
|
/* - Что лучше читается BETWEEN 100 AND 1300 или price >= 100 AND price <= 1300 и почему?
|
||||||
|
|
||||||
|
По мне, так BETWEEN короче и лаконичнее. Разве что с датами надо быть осторожнее :)
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 2 — Скидочная корзина: дешёвые или дорогие
|
||||||
|
|
||||||
|
SELECT name,
|
||||||
|
price,
|
||||||
|
(price * 0.85) price_with_discount
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
WHERE (price < 100) OR (price > 1000);
|
||||||
|
|
||||||
|
/* - Почему важно явно писать скобки в условии с OR, даже если кажется, что и так понятно?
|
||||||
|
|
||||||
|
Если в условии есть и OR и AND — скобки обязательны. Можно ошибиться с логикой выполнения,
|
||||||
|
так как приоритет AND выше OR. Лучше перестраховаться
|
||||||
|
и ставить скобки везде */
|
||||||
|
|
||||||
|
-- Задача 3 — Категории для рекламы
|
||||||
|
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
price
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
WHERE category IN ('tablet', 'smartphone', 'mouse')
|
||||||
|
AND price >= 100
|
||||||
|
ORDER BY price DESC;
|
||||||
|
|
||||||
|
-- Задача 4 — Убрать iPhone из выборки
|
||||||
|
|
||||||
|
SELECT name,
|
||||||
|
category,
|
||||||
|
price
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
WHERE name NOT LIKE '%iPhone%';
|
||||||
|
|
||||||
|
-- Задача 5 — Поиск товаров по названию
|
||||||
|
|
||||||
|
SELECT name,
|
||||||
|
category,
|
||||||
|
price
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
WHERE name ILIKE '%pro%';
|
||||||
|
|
||||||
|
SELECT name,
|
||||||
|
category,
|
||||||
|
price
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
WHERE name ILIKE '%mx%';
|
||||||
|
|
||||||
|
/* - В одном предложении объясни, почему для поисковой строки лучше ILIKE '%pro%', а не = 'Pro'.
|
||||||
|
|
||||||
|
Потому что = 'Pro' найдёт только точное совпадение с учётом регистра,
|
||||||
|
а ILIKE '%pro%' найдёт любые вхождения "pro" в любом регистре
|
||||||
|
*/
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
-- Задача 1. Давность регистрации пользователей
|
||||||
|
|
||||||
|
SELECT name,
|
||||||
|
CAST(created_at AS DATE) AS created_date,
|
||||||
|
CURRENT_DATE - created_at::DATE AS days_since_registration,
|
||||||
|
is_active
|
||||||
|
FROM dmezentsev.users
|
||||||
|
WHERE CURRENT_DATE - created_at::DATE > 180
|
||||||
|
ORDER BY CURRENT_DATE - created_at::DATE DESC;
|
||||||
|
|
||||||
|
/* - Как бы ты использовал такое поле в реальном продукте
|
||||||
|
(что можно делать с очень «старыми» пользователями)?
|
||||||
|
|
||||||
|
В реальности можно давать какие-то бонусы самым лояльным клиентам. Ну или
|
||||||
|
наоборот, удалить их, если зарегистрированы давно, а активности никакой
|
||||||
|
|
||||||
|
- Почему удобнее сразу вычитать created_at::date, а не created_at как timestamp?
|
||||||
|
|
||||||
|
Если вычитать ::date, то получается число, удобно сравнивать, применять при
|
||||||
|
фильтрации, в арифметических выражениях. Если timestamp, то интервал
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 2. Нормализация email и доменов
|
||||||
|
|
||||||
|
SELECT name,
|
||||||
|
LOWER(email) AS email_clean,
|
||||||
|
SUBSTRING(LOWER(email) FROM POSITION('@' IN email) + 1) AS domain
|
||||||
|
FROM dmezentsev.users
|
||||||
|
WHERE email IS NOT NULL
|
||||||
|
ORDER BY SUBSTRING(email FROM POSITION('@' IN email) + 1), name;
|
||||||
|
|
||||||
|
/* - Зачем в реальных системах приводят email к нижнему регистру и выделяют домен отдельно?
|
||||||
|
|
||||||
|
email - адреса приводят к нижнему регистру, потому что в БД один и тот же
|
||||||
|
почтовый ящик в разном регистре будет считаться двумя почтовыми ящиками, что
|
||||||
|
приведет к дублям. Так же если пользователь введет свой почтовый ящик не в таком
|
||||||
|
регистре, в котором ящик сохранен в БД, то он не сможет залогиниться
|
||||||
|
Домен выделяют отдельно для анализа, маркетинга. Так же ради безопасности,
|
||||||
|
чтобы блочить подозрительные домены
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 3. Сегменты по возрасту
|
||||||
|
|
||||||
|
SELECT name,
|
||||||
|
age,
|
||||||
|
CASE
|
||||||
|
WHEN age < 25 THEN 'young'
|
||||||
|
WHEN age BETWEEN 25 AND 35 THEN 'adult'
|
||||||
|
ELSE 'senior'
|
||||||
|
END AS age_group
|
||||||
|
FROM dmezentsev.users
|
||||||
|
ORDER BY age;
|
||||||
|
|
||||||
|
/* - Почему удобнее держать логику сегментации в SQL (CASE), а не только в коде приложения?
|
||||||
|
|
||||||
|
Вот тут точно не знаю, но мне кажется, что сегментация в SQL вернет результат.
|
||||||
|
А если писать это в коде приложения, то выгрузка всех строк выльется в большой
|
||||||
|
объем данных, которые нужно будет еще и обработать
|
||||||
|
|
||||||
|
- Что произойдёт, если в CASE забыть ветку ELSE
|
||||||
|
(что получат пользователи, не попавшие ни в одно условие)?
|
||||||
|
|
||||||
|
Если забыть ELSE, то пользователи, которые не попадут ни под одно из условий,
|
||||||
|
получат NULL
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 4. «Горячие» пользователи по дате и активности
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
name,
|
||||||
|
created_at::DATE AS created_at_date,
|
||||||
|
CURRENT_DATE - created_at::DATE AS days_since_registration,
|
||||||
|
CASE
|
||||||
|
WHEN is_active AND (CURRENT_DATE - created_at::DATE <= 30) THEN 'hot'
|
||||||
|
ELSE 'regular'
|
||||||
|
END AS hot_flag
|
||||||
|
FROM dmezentsev.users
|
||||||
|
WHERE is_active AND (CURRENT_DATE - created_at::DATE <= 30);
|
||||||
|
|
||||||
|
/* - Чем отличается фильтрация по дате в WHERE от простого CASE с меткой 'hot',
|
||||||
|
но без фильтрации?
|
||||||
|
|
||||||
|
С фильтрацией в WHERE получим горячих с датой регистрации не больше 30 дней.
|
||||||
|
Если без WHERE, то получим всех пользователей (и горячих, и обычных)
|
||||||
|
|
||||||
|
- Какие типы рассылок/кампаний логично запускать только на «горячих»?
|
||||||
|
|
||||||
|
Предположу, что горячим можно рассылать специальные предложения, чтобы
|
||||||
|
дополнительно завлечь. Так же можно сделать рассылку по онбордингу, чтобы
|
||||||
|
легче вкатиться в фичи сервиса
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 5. Качество данных по email
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS total_users,
|
||||||
|
COUNT(email) AS with_email,
|
||||||
|
COUNT(*) FILTER (WHERE email IS NULL) AS without_email,
|
||||||
|
COUNT(*) FILTER (WHERE email IS NULL)::NUMERIC
|
||||||
|
/ COUNT(*) * 100 AS without_email_percent
|
||||||
|
FROM dmezentsev.users;
|
||||||
|
|
||||||
|
/* - Почему для проверки «долей» и «заполненности» полей
|
||||||
|
лучше использовать агрегаты в SQL, а не просто «на глаз» смотреть на таблицу?
|
||||||
|
|
||||||
|
Агрегаты гораздо легче воспринимать на глаз, чем смотреть всю таблицу.
|
||||||
|
По сути, это уже готовый ответ на поставленный вопрос. Другое дело
|
||||||
|
пытаться понять по всем данным, что вообще происходит.
|
||||||
|
Особенно, если там миллионы строк.
|
||||||
|
|
||||||
|
- Какие действия по данным ты бы предложил продукту,
|
||||||
|
если доля пользователей без email слишком большая?
|
||||||
|
|
||||||
|
Если бы доля юзеров, не заполняюших email, оказалась слишком большой, то я
|
||||||
|
бы советовал сделать ввод email обязательным, ну или использовать email как
|
||||||
|
логин. Как вариант, предложить какой-то бонус типа промокода
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 6. «Стаж» пользователя и его группа
|
||||||
|
|
||||||
|
SELECT name,
|
||||||
|
CASE
|
||||||
|
WHEN CURRENT_DATE - created_AT::DATE <= 30 THEN 'new'
|
||||||
|
WHEN CURRENT_DATE - created_AT::DATE BETWEEN 31 AND 180 THEN 'middle'
|
||||||
|
ELSE 'old'
|
||||||
|
END AS tenure_group,
|
||||||
|
CURRENT_DATE - created_AT::DATE AS days_since_registration,
|
||||||
|
is_active
|
||||||
|
FROM dmezentsev.users
|
||||||
|
ORDER BY tenure_group,
|
||||||
|
days_since_registration;
|
||||||
|
|
||||||
|
/* Чем сегментация по «стажу» отличается по смыслу от сегментации по возрасту?
|
||||||
|
|
||||||
|
Стаж - это время с момента регистрации. Думаю, такая сегментация необходима для того, чтобы
|
||||||
|
по этим сегментам проводить какую-то свою аналитику, отличную для каждого из них.
|
||||||
|
Например: стаж - аналитика лояльности, возраст - аналитика платежеспособности
|
||||||
|
|
||||||
|
- Если пересчитывать tenure_group прямо в SQL каждый день,
|
||||||
|
какие плюсы и минусы по сравнению с хранением статического поля segment?
|
||||||
|
|
||||||
|
Плюс перерасчета стажа каждый день - это актуальность данных, которые потом
|
||||||
|
будут использоваться аналитиками
|
||||||
|
*/
|
||||||
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
-- Задача 1. Сколько товаров в каждой категории
|
||||||
|
|
||||||
|
SELECT category,
|
||||||
|
count(id) AS products_count
|
||||||
|
FROM dmezentsev.products
|
||||||
|
GROUP BY category
|
||||||
|
ORDER BY count(id) DESC;
|
||||||
|
|
||||||
|
/* - Что будет, если убрать ORDER BY — как изменится чтение результата
|
||||||
|
для продукта/аналитика?
|
||||||
|
|
||||||
|
Если убрать сортировку, то строки в результирующей таблице будут выведены
|
||||||
|
произвольно, а точнее это будет зависеть от плана выполнения запроса
|
||||||
|
|
||||||
|
- Если добавить в таблицу новый товар с категорией smartphone,
|
||||||
|
как изменится строка для этой категории?
|
||||||
|
|
||||||
|
Если добавить новый товар с категорией, которая есть в таблице, то в поле
|
||||||
|
products_count будет +1 значение по этой категории
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 2. Выручка по каждому заказу
|
||||||
|
|
||||||
|
SELECT order_id,
|
||||||
|
SUM(quantity) AS total_quantity,
|
||||||
|
SUM(quantity * price_at_purchase) AS order_revenue
|
||||||
|
FROM dmezentsev.order_items
|
||||||
|
GROUP BY order_id
|
||||||
|
ORDER BY SUM(quantity * price_at_purchase) DESC;
|
||||||
|
|
||||||
|
/* - Что будет, если убрать GROUP BY и оставить SUM(quantity * price_at_purchase)?
|
||||||
|
|
||||||
|
Если убрать GROUP BY, то будет ошибка. Поле, запрашиваемое в SELECT и
|
||||||
|
не участвующее в агрегирующей функции, должно быть в GROUP BY
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 3. Выручка по категориям товаров
|
||||||
|
|
||||||
|
SELECT p.category,
|
||||||
|
SUM(oi.quantity * oi.price_at_purchase) AS category_revenue
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
JOIN dmezentsev.order_items oi ON p.id = oi.product_id
|
||||||
|
GROUP BY p.category
|
||||||
|
ORDER BY SUM(oi.quantity * oi.price_at_purchase) DESC;
|
||||||
|
|
||||||
|
/* - Почему группировка идёт по p.category, а не по p.id?
|
||||||
|
|
||||||
|
Группировка по p.category, а не по p.id, потому что нам нужно узнать
|
||||||
|
общую выручку по категориям, а не по товарам в категориях
|
||||||
|
|
||||||
|
- Если добавить в SELECT ещё p.name, что произойдёт с результатом и почему?
|
||||||
|
|
||||||
|
Если добавить в SELECT поле p.name, то запрос выдаст ошибку, что это
|
||||||
|
поле должно быть в GROUP BY, так как не является агрегирующей функцией
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 4. Только оплаченные заказы и их выручка
|
||||||
|
|
||||||
|
SELECT o.id AS order_id,
|
||||||
|
SUM(oi.quantity * oi.price_at_purchase) AS order_revenue
|
||||||
|
FROM dmezentsev.orders o
|
||||||
|
JOIN order_items oi ON o.id = oi.order_id
|
||||||
|
WHERE o.status = 'paid'
|
||||||
|
GROUP BY o.id
|
||||||
|
ORDER BY SUM(oi.quantity * oi.price_at_purchase) DESC;
|
||||||
|
|
||||||
|
/* - Чем результат этой задачи будет отличаться от задачи 2 (где статус не учитывался)?
|
||||||
|
|
||||||
|
В этом запросе в результирующую таблицу попадут только заказы со статусом 'paid'
|
||||||
|
|
||||||
|
- В какой реальной ситуации ты бы смотрел только на paid заказы, а не на все?
|
||||||
|
|
||||||
|
В реальной ситуации такая фильтрация была бы одной из основополагающих
|
||||||
|
и отображающих метрик успешности бизнеса
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 5. Категории с выручкой больше 1000
|
||||||
|
|
||||||
|
SELECT p.category,
|
||||||
|
SUM(oi.quantity * oi.price_at_purchase) AS category_revenue
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
JOIN dmezentsev.order_items oi ON p.id = oi.product_id
|
||||||
|
GROUP BY p.category
|
||||||
|
HAVING SUM(oi.quantity * oi.price_at_purchase) > 1000
|
||||||
|
ORDER BY SUM(oi.quantity * oi.price_at_purchase) DESC;
|
||||||
|
|
||||||
|
/* - Почему нельзя написать WHERE category_revenue > 1000?
|
||||||
|
|
||||||
|
Условие в WHERE после GROUP BY не сработает, так как оно фильтрует данные до
|
||||||
|
группировки
|
||||||
|
|
||||||
|
- Чем WHERE price > 1000 принципиально отличается от HAVING SUM(...) > 1000?
|
||||||
|
|
||||||
|
WHERE price > 1000 отличается от HAVING SUM(...) > 1000 тем, что в WHERE
|
||||||
|
будет отфильтрованы товары с ценой больше 1000, а HAVING отфильтрует сгруппированные
|
||||||
|
категории, у которых общая выручка больше 1000
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 6. Страны с достаточным числом активных пользователей
|
||||||
|
|
||||||
|
SELECT country,
|
||||||
|
COUNT(id) AS active_users_count
|
||||||
|
FROM dmezentsev.users
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
GROUP BY country
|
||||||
|
HAVING COUNT(id) >= 2
|
||||||
|
ORDER BY COUNT(id) DESC;
|
||||||
|
|
||||||
|
/* - Почему фильтр по активности (is_active = true) логичнее писать в WHERE, а не в HAVING?
|
||||||
|
|
||||||
|
Логичнее условие писать в WHERE, потому что производительнее сперва будет
|
||||||
|
отфильтровать нужные данные, а по ним уже потом проводить группировку
|
||||||
|
|
||||||
|
- Что покажет запрос, если в данных пока нет ни одной страны с active_users_count >= 2?
|
||||||
|
|
||||||
|
Если под условие в HAVING не попадет ни один агрегат, то запрос не выведет ни
|
||||||
|
одной строки
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 7. Топ‑товары по количеству продаж
|
||||||
|
|
||||||
|
SELECT name AS product_name,
|
||||||
|
SUM(quantity) AS total_quantity_sold
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
JOIN dmezentsev.order_items oi ON p.id = oi.product_id
|
||||||
|
GROUP BY name
|
||||||
|
ORDER BY SUM(quantity) DESC
|
||||||
|
LIMIT 3;
|
||||||
|
|
||||||
|
/* - Чем рейтинг по total_quantity_sold отличается от
|
||||||
|
рейтинга по выручке (SUM(quantity * price_at_purchase))?
|
||||||
|
|
||||||
|
- В каких случаях бизнесу важнее смотреть на количество, а не на деньги?
|
||||||
|
|
||||||
|
В поле total_quantity_sold выводим товары, которые продавались чаще всего.
|
||||||
|
SUM(quantity * price_at_purchase) вывел бы выручку наиболее часто продаваемых товаров
|
||||||
|
Предположу, что кол-во важнее выручки в случае, когда важно завоевать место в нише.
|
||||||
|
Как только увеличится база лояльных клиентов, обычно потом начинают поднимать цену
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задача 8. Заказы с «маленькой» выручкой
|
||||||
|
|
||||||
|
SELECT order_id,
|
||||||
|
SUM(quantity * price_at_purchase) AS order_revenue
|
||||||
|
FROM dmezentsev.order_items
|
||||||
|
GROUP BY order_id
|
||||||
|
HAVING SUM(quantity * price_at_purchase) < 100
|
||||||
|
ORDER BY SUM(quantity * price_at_purchase);
|
||||||
|
|
||||||
|
/* - Какие реальные причины могут быть у очень маленьких заказов (1–2 гипотезы)?
|
||||||
|
|
||||||
|
Маленький по выручке заказ - это покупка чего-то дешевого (либо повседневного). Или же
|
||||||
|
использование скидок/купонов.
|
||||||
|
|
||||||
|
- Что проще увидеть глазами: просто все заказы с суммой или
|
||||||
|
именно отфильтрованные «микро‑заказы»?
|
||||||
|
|
||||||
|
В этом полезность фильтрации, чтобы не размывать взгляд на абсолютно все
|
||||||
|
заказы, а оставить конкретные (< 100, например). И уже проанализировать их
|
||||||
|
*/
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
-- Задание 1: Заказы + кто заказал
|
||||||
|
|
||||||
|
SELECT u.name AS user_name,
|
||||||
|
u.country,
|
||||||
|
o.id AS order_id,
|
||||||
|
o.order_date,
|
||||||
|
o.status
|
||||||
|
FROM dmezentsev.users u
|
||||||
|
LEFT JOIN dmezentsev.orders o ON o.user_id = u.id;
|
||||||
|
|
||||||
|
SELECT u.name AS user_name,
|
||||||
|
u.country,
|
||||||
|
o.id AS order_id,
|
||||||
|
o.order_date,
|
||||||
|
o.status
|
||||||
|
FROM dmezentsev.users u
|
||||||
|
JOIN dmezentsev.orders o ON o.user_id = u.id;
|
||||||
|
|
||||||
|
/* - С какой таблицы логичнее начать: orders или users? Почему?
|
||||||
|
|
||||||
|
Логичнее начать с таблицы users. А дальше вариантами джойна выбирать, что
|
||||||
|
нужно: если оставить только тех пользователей, у которых был хотя бы один заказ,
|
||||||
|
то использовать INNER JOIN. Если вывести так же пользователей, у которых заказа
|
||||||
|
не было, то LEFT JOIN
|
||||||
|
|
||||||
|
- Какой тип JOIN здесь уместнее: INNER или LEFT? Напиши запрос в обоих вариантах и посмотри,
|
||||||
|
где пропадут строки.
|
||||||
|
|
||||||
|
При INNER JOIN пропадут три строки с пользователями, у которых не было ни одного заказа
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 2: Чек одного заказа
|
||||||
|
|
||||||
|
SELECT oi.order_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
oi.quantity,
|
||||||
|
oi.price_at_purchase
|
||||||
|
FROM dmezentsev.order_items oi
|
||||||
|
JOIN dmezentsev.products p ON oi.product_id = p.id
|
||||||
|
WHERE oi.order_id = 101;
|
||||||
|
|
||||||
|
/* - Что изменится, если эту фильтрацию order_id = 101 перенести в JOIN (после ON)?
|
||||||
|
|
||||||
|
При использовании INNER JOIN оба варианта (WHERE order_id = 101 или
|
||||||
|
перенести в JOIN (после ON)) дадут одинаковый результат.
|
||||||
|
|
||||||
|
Но:
|
||||||
|
LEFT JOIN dmezentsev.products p ON oi.product_id = p.id
|
||||||
|
WHERE oi.order_id = 101
|
||||||
|
Сначала делается джойн, потом фильтруются данные по условию. Вернутся строки,
|
||||||
|
удовлетворяющие условию фильтрации
|
||||||
|
|
||||||
|
LEFT JOIN dmezentsev.products p ON oi.product_id = p.id AND oi.order_id = 101
|
||||||
|
Тут возможно будет больше строк, так как вернутся строки из order_items c NULL
|
||||||
|
в таблице products
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 3: Полный чек по всем заказам
|
||||||
|
|
||||||
|
SELECT o.id AS order_id,
|
||||||
|
u.name AS user_name,
|
||||||
|
p.name,
|
||||||
|
oi.quantity,
|
||||||
|
oi.price_at_purchase
|
||||||
|
FROM dmezentsev.orders o
|
||||||
|
JOIN dmezentsev.users u ON o.user_id = u.id
|
||||||
|
JOIN dmezentsev.order_items oi ON o.id = oi.order_id
|
||||||
|
JOIN dmezentsev.products p ON oi.product_id = p.id;
|
||||||
|
|
||||||
|
/* - почему после JOIN заказ 3 появляется в нескольких строках?
|
||||||
|
|
||||||
|
Заказ 3 появился в двух строках, потому что при джойне кол-во строк может
|
||||||
|
увеличиваться. Это связано с тем, что в этом заказе было два наименования,
|
||||||
|
отсюда и увеличение строк
|
||||||
|
|
||||||
|
- Чем результат отличается от задания 2 (по количеству строк и информации)?
|
||||||
|
|
||||||
|
В этом запросе строк и информации больше, потому что информация запрошена
|
||||||
|
по всему магазину, а не по конкретному заказу 101
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 4: Все пользователи + их заказы (включая тех, у кого заказов нет)
|
||||||
|
|
||||||
|
SELECT u.name AS user_name,
|
||||||
|
o.id AS order_id,
|
||||||
|
o.status
|
||||||
|
FROM dmezentsev.users u
|
||||||
|
LEFT JOIN dmezentsev.orders o ON u.id = o.user_id;
|
||||||
|
|
||||||
|
/* - Чем этот запрос отличается от такого же, но с INNER JOIN?
|
||||||
|
|
||||||
|
Если бы тут был INNER JOIN, то пользователи без заказов не попали бы в
|
||||||
|
результирующую таблицу
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 5: Пользователи без заказов
|
||||||
|
|
||||||
|
SELECT u.id AS user_id,
|
||||||
|
u.name AS user_name
|
||||||
|
FROM dmezentsev.users u
|
||||||
|
LEFT JOIN dmezentsev.orders o ON u.id = o.user_id
|
||||||
|
WHERE o.id IS NULL;
|
||||||
|
|
||||||
|
/* - Почему тут важно фильтровать именно по колонке из **`orders`**, а не из **`users`**?
|
||||||
|
|
||||||
|
Фильтрую по полю orders.id, потому что это первичный ключ таблицы,
|
||||||
|
который подразумевает собой факт покупки. То есть если после джойна
|
||||||
|
это поле IS NULL, то у пользователя не было ни одного заказа
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 6: Заказы, где есть Kindle
|
||||||
|
|
||||||
|
SELECT o.id AS order_id,
|
||||||
|
u.name AS user_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
oi.quantity
|
||||||
|
FROM dmezentsev.orders o
|
||||||
|
JOIN dmezentsev.users u ON o.user_id = u.id
|
||||||
|
JOIN dmezentsev.order_items oi ON o.id = oi.order_id
|
||||||
|
JOIN dmezentsev.products p ON oi.product_id = p.id
|
||||||
|
WHERE p.name LIKE '%Kindle%';
|
||||||
|
|
||||||
|
-- Задание 7: Товары и в каких заказах они покупались
|
||||||
|
|
||||||
|
SELECT p.name AS product_name,
|
||||||
|
oi.order_id,
|
||||||
|
oi.quantity
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
LEFT JOIN dmezentsev.order_items oi ON p.id = oi.product_id;
|
||||||
|
|
||||||
|
/* - Что будет в order_id, если товар ни разу не покупали?
|
||||||
|
|
||||||
|
Если бы товар не был ни разу куплен, то в поле order_id был NULL
|
||||||
|
из-за использования LEFT JOIN
|
||||||
|
|
||||||
|
- Чем это отличается от варианта, если использовать INNER JOIN?
|
||||||
|
|
||||||
|
При использовании INNER JOIN товар, который ни разу не заказывали,
|
||||||
|
не попал бы в итоговую таблицу
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 8: Только оплаченные заказы + их состав
|
||||||
|
|
||||||
|
SELECT o.id AS order_id,
|
||||||
|
u.name AS user_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
oi.quantity,
|
||||||
|
oi.price_at_purchase
|
||||||
|
FROM dmezentsev.orders o
|
||||||
|
JOIN dmezentsev.users u ON o.user_id = u.id
|
||||||
|
JOIN dmezentsev.order_items oi ON o.id = oi.order_id
|
||||||
|
JOIN dmezentsev.products p ON oi.product_id = p.id
|
||||||
|
WHERE o.status = 'paid';
|
||||||
|
|
||||||
|
SELECT o.id AS order_id,
|
||||||
|
u.name AS user_name,
|
||||||
|
p.name AS product_name,
|
||||||
|
oi.quantity,
|
||||||
|
oi.price_at_purchase
|
||||||
|
FROM dmezentsev.orders o
|
||||||
|
JOIN dmezentsev.users u ON o.user_id = u.id AND o.status = 'paid'
|
||||||
|
JOIN dmezentsev.order_items oi ON o.id = oi.order_id
|
||||||
|
JOIN dmezentsev.products p ON oi.product_id = p.id;
|
||||||
|
|
||||||
|
/* - Что будет, если условие status = 'paid' написать
|
||||||
|
в JOIN с orders (там же, где ON)? Изменится ли результат сейчас и почему?
|
||||||
|
|
||||||
|
Результат будет один и тот же, потому что используется INNER JOIN.
|
||||||
|
Строки по условию o.status = 'paid' отсекутся в любом случае, что
|
||||||
|
при WHERE, что на шаге JOIN
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 9: Заказы пользователя Alice
|
||||||
|
|
||||||
|
SELECT o.id AS order_id,
|
||||||
|
o.order_date,
|
||||||
|
p.name AS product_name,
|
||||||
|
oi.quantity,
|
||||||
|
oi.price_at_purchase
|
||||||
|
FROM dmezentsev.orders o
|
||||||
|
JOIN dmezentsev.users u ON o.user_id = u.id
|
||||||
|
JOIN dmezentsev.order_items oi ON o.id = oi.order_id
|
||||||
|
JOIN dmezentsev.products p ON oi.product_id = p.id
|
||||||
|
WHERE u."name" = 'Alice';
|
||||||
|
|
||||||
|
/* - Если у Alice нет ни одного заказа, сколько строк вернёт твой запрос и почему?
|
||||||
|
|
||||||
|
Если бы у Alice не было заказов, то запрос не вернул ничего, так как
|
||||||
|
не было бы ни одной строки, удовлетворяющей условию фильтрации
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 10: Заказы без товаров
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
o.id AS order_id,
|
||||||
|
o.user_id,
|
||||||
|
o.status
|
||||||
|
FROM dmezentsev.orders o
|
||||||
|
LEFT JOIN dmezentsev.order_items oi ON o.id = oi.order_id
|
||||||
|
WHERE oi.order_id IS NULL;
|
||||||
|
|
||||||
|
/* - Что будет, если вместо LEFT JOIN написать INNER JOIN — изменится ли результат и как?
|
||||||
|
|
||||||
|
Если изменить LEFT JOIN на INNER JOIN, то результирующих строк вообще не будет.
|
||||||
|
INNER JOIN оставит только те заказы, у которых есть хотя бы одна запись в order_items.
|
||||||
|
То есть при INNER JOIN колонка oi.order_id никогда не будет NULL
|
||||||
|
*/
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
-- Задание 1. Активные и неактивные пользователи в одном списке
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
CASE
|
||||||
|
WHEN is_active THEN 'active'
|
||||||
|
END AS user_status
|
||||||
|
FROM dmezentsev.users
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
CASE
|
||||||
|
WHEN NOT is_active THEN 'inactive'
|
||||||
|
END AS user_status
|
||||||
|
FROM dmezentsev.users
|
||||||
|
WHERE is_active = FALSE
|
||||||
|
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
/* - Что будет, если вместо UNION ALL написать UNION?
|
||||||
|
|
||||||
|
Кол-во строк при UNION и UNION ALL не изменится, потому что все строки
|
||||||
|
в таблице users уникальны
|
||||||
|
|
||||||
|
- Зачем в результат добавлять колонку user_status, если флаг активности
|
||||||
|
уже есть в таблице users?
|
||||||
|
|
||||||
|
Предположу, что добавляем колонку user_status только лишь для удобства
|
||||||
|
понимания результирующей таблицы всем тем, кто далек от TRUE или FALSE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 2. Заказы до и после даты в одной ленте
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
id AS order_id,
|
||||||
|
user_id,
|
||||||
|
order_date,
|
||||||
|
'old' AS period
|
||||||
|
FROM dmezentsev.orders
|
||||||
|
WHERE order_date < '2024-01-01'
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
id AS order_id,
|
||||||
|
user_id,
|
||||||
|
order_date,
|
||||||
|
'new' AS period
|
||||||
|
FROM dmezentsev.orders
|
||||||
|
WHERE order_date >= '2024-01-01'
|
||||||
|
|
||||||
|
ORDER BY order_date, order_id;
|
||||||
|
|
||||||
|
/* - Как ты по колонке period поймёшь, сколько заказов было
|
||||||
|
до и после даты‑границы (словами, без SQL)?
|
||||||
|
|
||||||
|
Нужно посчитать количество строк, где в колонке period стоит
|
||||||
|
значение 'old' — это и будет число заказов до 2024-01-01.
|
||||||
|
А количество строк со значением 'new' — это число заказов начиная с 2024-01-01 и позже.
|
||||||
|
|
||||||
|
- Какой подход тебе кажется понятнее для новичка в команде:
|
||||||
|
два SELECT + UNION ALL или один SELECT с CASE WHEN? Почему?
|
||||||
|
|
||||||
|
Вот совсем для новичка, пожалуй, вариант с UNION ALL выглядит понятнее.
|
||||||
|
Два простых запроса, которые потом объединены в общую таблицу, к тому же
|
||||||
|
их можно выполнить отдельно
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 3. Товары, которые покупали, и которые не покупали
|
||||||
|
|
||||||
|
SELECT DISTINCT
|
||||||
|
p.id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
'ordered' AS product_status
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
JOIN dmezentsev.order_items oi ON p.id = oi.product_id
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
p.id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
'never_ordered' AS product_status
|
||||||
|
FROM dmezentsev.products p
|
||||||
|
LEFT JOIN dmezentsev.order_items oi ON p.id = oi.product_id
|
||||||
|
WHERE oi.product_id IS NULL
|
||||||
|
|
||||||
|
ORDER BY product_name;
|
||||||
|
|
||||||
|
/* - Почему в запросе на «не покупали» проверка IS NULL должна быть
|
||||||
|
по полю из order_items, а не из products?
|
||||||
|
|
||||||
|
LEFT JOIN гарантирует, что все строки из products останутся в результате.
|
||||||
|
Если для товара нет записей в order_items, то все колонки из order_items будут NULL.
|
||||||
|
|
||||||
|
- Какой сигнал для бизнеса даёт список с product_status = 'never_ordered'
|
||||||
|
(1–2 идеи: что можно с этими товарами сделать)?
|
||||||
|
|
||||||
|
Список never_ordered даст понимание бизнесу, что либо товар не востребован
|
||||||
|
(очень дорогой, непродающее описание), либо о товаре никто не знает (проблемы
|
||||||
|
с рекламой и продвижением). То есть решив эти кейсы, ситуацию можно изменить
|
||||||
|
в лучшую сторону.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- Задание 1 — Спроектируй «карточку пользователя»
|
||||||
|
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
country,
|
||||||
|
created_at,
|
||||||
|
email
|
||||||
|
FROM dmezentsev.users u
|
||||||
|
ORDER BY created_at DESC, name;
|
||||||
|
|
||||||
|
/* - Почему выбрал именно эти столбцы и зачем такая сортировка?
|
||||||
|
|
||||||
|
Сортировка нужна для того, чтобы вывести пользователей с датой регистрации по убыванию.
|
||||||
|
Если даты бы совпадали, то была дополнительная сортировка по имени внутри одной даты
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 2 — Сегментация по странам (с выбором)
|
||||||
|
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
country
|
||||||
|
FROM dmezentsev.users u
|
||||||
|
WHERE country = 'US'
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
country
|
||||||
|
FROM dmezentsev.users u
|
||||||
|
WHERE country = 'UK'
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
/* - Какой из двух запросов маркетологу полезнее и почему?
|
||||||
|
|
||||||
|
Предположу, что маркетологу выгоднее рассматривать пользователей из US,
|
||||||
|
потому что их больше (5 US > 3 UK)
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 3 — Контакты для рассылки
|
||||||
|
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
email
|
||||||
|
FROM dmezentsev.users u
|
||||||
|
WHERE email IS NOT NULL
|
||||||
|
ORDER BY name;
|
||||||
|
|
||||||
|
SELECT DISTINCT(LOWER(email)) email
|
||||||
|
FROM dmezentsev.users u
|
||||||
|
WHERE email IS NOT NULL;
|
||||||
|
|
||||||
|
/* - Зачем вообще приводить email к одному регистру и убирать дубликаты на стороне базы?
|
||||||
|
|
||||||
|
Нужно приводить email к одному виду, потому что хранение одного и того же email
|
||||||
|
в разных регистрах приведет к дублированию данных.
|
||||||
|
По факту же один пользователь - один email
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- Задание 4 — Новички и активность
|
||||||
|
|
||||||
|
SELECT id,
|
||||||
|
name,
|
||||||
|
created_at
|
||||||
|
FROM dmezentsev.users u
|
||||||
|
WHERE created_at < '2024-03-11'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 2 OFFSET 1;
|
||||||
|
|
||||||
|
/* - В каком реальном сценарии тебе пригодится OFFSET?
|
||||||
|
|
||||||
|
Предположу, OFFSET понадобится, когда нужно убрать из выборки
|
||||||
|
какой-то топ пользователей. Допустим, нужны все, кроме первой тройки
|
||||||
|
*/
|
||||||
Reference in New Issue
Block a user