SQL: мені тільки спитати. Як писати скрипти для отримання виборок
Всім привіт! Мене звуть Андрій, і трохи більше трьох років я займаюсь аналізом даних та використовую SQL для роботи з різними базами даних. Зараз працюю аналітиком даних у компанії Genesis. У статті розповім про те, як писати SQL-скрипти для отримання вибірок (з нуля в один), на прикладах.
Матеріал розрахований на читача з початковим рівнем володіння SQL для максимально швидкого занурення в технологію.
Передісторія
Одного разу компанія, в якій я обіймав посаду Data Analyst, вирішила навчити персонал основ роботи з базою даних і для цього найняла викладача. Мотивація полягала в тому, щоб колеги, маючи read-only доступ, отримували актуальну інформацію та просту статистику, не звертаючись до технічних фахівців. Після трьох занять учні не написали жодного запиту і були незадоволені матеріалом.
Програма курсу потрапила до мене на «переосмислення», як наслідок з неї було виключено історію, теорію, все, що стосується проєктування та створення БД, а залишилися способи отримання вибірок даних.
Це все нагадало мені, як колись я сам був змушений швидко опанувати SQL. Шпаргалки містили всілякі типи з’єднань таблиць та мінімалістичні приклади, а онлайн-курси розповідали про нормальні форми, процедури та типи даних, що мало підходило для моїх цілей.
Так і виникла ідея написати статтю, яка зможе послідовно і без води розповісти, як використовувати SQL для отримання вибірок з декількох джерел з потрібними обмеженнями, у потрібному вигляді та потрібних розрізах. Особливості використовуваної БД, оптимізація та інша теорія залишаються для самостійного вивчення.

Отже, почнемо.
Перший SELECT, сортування, LIMIT
У базі даних інформація зберігається в таблицях.
Припустимо, у нас є таблиця users, що містить дані про реєстрації користувачів, яка лежить у схемі (скажімо, папці) product. У таблиці є кілька десятків колонок (полів). Виберемо ті, що нас цікавлять:
SELECT reg_dt, id, gender, age, country_code, app — список полів, що цікавлять. FROM product.users — джерело, схема.назва_таблиці.LIMIT 5 — кількість рядків, які потрібно вивести.
Примітка. Великими літерами позначимо зарезервовані слова (команди).
Працюватиме і з малими літерами, але так прийнято для наочності.
Як любить казати мій колега: «SQL-запит — це ж просто англійський текст. Візьми це звідси, відфільтруй, відсортуй і так далі...»
Отже, ми отримали таблицю:
|
reg_dt |
id |
gender |
age |
country_code |
app |
|
|
4446022755 |
f |
37 |
US |
desktop |
|
|
4446556074 |
f |
40 |
CH |
android |
|
|
4481548107 |
m |
40 |
GE |
mobile |
|
|
4436447691 |
m |
49 |
US |
mobile |
|
|
4436702697 |
m |
61 |
CH |
desktop |
Те саме, відсортоване за віком та кодом країни:
SELECT reg_dt, id, gender, age, country_code, app FROM product.users ORDER BY age DESC, country_code— через кому зазначимо поля, за якими буде відсортовано отриману вибірку.
LIMIT 5 — цей рядок можна видалити, тоді у нас буде список з усіх користувачів (але їх багато).
За замовчуванням команда ORDER BY відсортує поле за зростанням (текстове за алфавітом, наприклад), але, вказавши DESC (descending) після назви поля, ми задамо сортування за спаданням.
(Щоб вибрати всі поля, вкажіть зірочку * замість назв полів SELECT * FROM product.users.)
Структура запиту, умови WHERE, логічні оператори
Модифікуємо запит, щоб знайти користувачів-жінок старше 45 років:
SELECT reg_dt, id, gender, age, country_code, app FROM product.users WHERE gender = 'f' AND age > 45 – вказуємо умови для полів, що виводяться. ORDER BY age DESC, country_code
'f' значення взято в одинарні лапки, щоб вказати, що це текст, а не назва поля.
Порядок команд у запиті фіксований:
SELECT 'стовпці або * для вибору всіх стовпців; обов\'язково' FROM 'таблиця'* WHERE 'умова/фільтрація; необов\'язково' GROUP BY 'стовпці, за якими хочемо згрупувати дані; необов\'язково' HAVING 'умова/фільтрація на рівні згрупованих даних; необов\'язково' ORDER BY 'стовпці, за якими хочемо відсортувати виведення; необов\'язково'
*Якщо вдаватись в деталі, можна написати запит без вказівки таблиці, приклад: SELECT 1 column1, 'text' column2. У цьому блоці можна записати кілька джерел, але про це поговоримо далі.
|
column1 |
column2 |
|
1 |
text |
В умові WHERE можна використовувати велику кількість критеріїв, пов’язаних логічними операторами AND та OR.
age > 45 (ще варіанти: =, >, <, <=, >= та != не дорівнює).
Варто додати, що 45 < age дасть еквівалентний результат, крім того, можна порівняти поля між собою age > steps_count.
country_code IN ('US', 'PL', 'CL') Якщо потрібно порівняти наші значення зі значеннями у списку, використовуємо IN, вказавши в дужках можливі варіанти через кому.
Умови також можна задавати з приставкою «не» country_code NOT IN ('RU', 'VE').
Це виключить з вибірки країни, перераховані у списку.
BETWEEN '2020-02-02' AND '2020-07-04' — значення між вказаними датами, включно.
У SQL дата записується в наступному форматі: '2020-12-29' (рік-місяць-день).
Дата і час: '2020-04-15 06:25:49.000009'. Якщо точність не потрібна, то запис можна скоротити '2020-04-15 06:25:49' (відкинута справа частина часу буде замінена нулями '2020-04-15 06:25:49.000000'). Відповідно, коли ми фільтруємо поле, яке містить дату і час, вказуючи лише дату BETWEEN '2020-02-02' AND '2020-07-04', це рівносильно '2020-02-02 00:00:00.000000' AND '2020-07-04 00:00:00.000000'.
Для наступних прикладів розглянемо таблицю:
|
reg_dt |
gender |
age |
site |
|
|
m |
34 | |
|
|
f |
28 | |
|
|
f |
55 | |
|
|
m |
48 | |
|
|
m |
44 |
Порівнювати рядки можна конструкцією site LIKE '%.com'.
Це формулювання допоможе знайти користувачів, зареєстрованих на ресурсах, які містять будь-який набір символів (задано зарезервованим символом %), потім .com, таким чином ми отримаємо користувачів, зареєстрованих на ресурсі з доменом .com.
SELECT site FROM product.users WHERE site LIKE '%.com';
|
site |
SELECT site FROM product.users WHERE site LIKE 'abcdefghij.%';
|
site |
Символ % може міститися в будь-якій частині рядка або бути вказаний кілька разів. Означає «будь-який набір символів або відсутність символу».
Написавши 'a%.com', ми знайдемо користувачів у домені .com, де назва сайту починається з літери a.
|
site |
Є ще один зарезервований символ _ (нижнє підкреслення), що позначає один символ. Таким чином знайдемо користувачів ресурсу, який містить другим символом літеру m і закінчується доменом .us, можна так: site LIKE '_m%.us'.
|
site |
Як і в курсі шкільної математики (множення виконується раніше, ніж віднімання), оператори мають порядок виконання.
При використанні логічного оператора OR («або») варто бути обережним і ставити дужки, чітко позначаючи альтернативи.
Якщо ми захочемо знайти користувачів, що зареєструвались в конкретному часовому діапазоні з доменом '%.com' або '%.us', напишемо запит:
SELECT reg_dt, gender, age, site FROM product.users WHERE reg_dt BETWEEN '2012-01-01' AND '2015-01-01' AND site LIKE '%.us' OR site LIKE '%.com'
У SQL можна використовувати переноси рядків та відступи для форматування, вони не впливають на виконання запиту.
Примітка. В умові хочеться написати site LIKE '%.com' OR '%.us', але так не працює, після команди LIKE очікується один шуканий патерн, потрібно повторити конструкцію для другого патерну.
Виконавши запит, отримаємо список користувачів. Деякі з них зареєстровані за межами виділеного нами діапазону часу
|
reg_dt |
gender |
age |
site |
|
|
m |
34 | |
|
|
f |
28 | |
|
|
f |
55 | |
|
|
m |
48 |
А все тому, що вказана в кінці умова OR скасовує всі попередні умови, пов’язані оператором AND.
Цю умову варто інтерпретувати як «дай вказані поля для користувачів, які зареєстровані в дату таку-то і мають домен .us або просто мають домен .com (без умови по даті)».
Відбувається це якраз з тієї причини, що AND виконується раніше, ніж OR.
Якщо ми хочемо, щоб умова дати зберігалась для обох доменів, варто явно виділити альтернативи для оператора OR за допомогою круглих дужок:
WHERE reg_dt BETWEEN '2012-01-01' AND '2015-01-01' AND (site LIKE '%.us' OR site LIKE '%.com')
|
reg_dt |
gender |
age |
site |
|
|
m |
34 | |
|
|
f |
28 |
Умова може бути сформульована і як результат перетворень:
SELECT age, site, LENGTH(site) FROM product.users WHERE age%2 != 0 OR LENGTH(site) > 17
Вік — непарне число (остача від ділення на 2 не дорівнює 0) або довжина ресурсу — більше 17 символів. Причому поле, для якого створена умова, необов’язково виводити в SELECT.
|
age |
site |
LENGTH |
|
34 |
18 | |
|
55 |
14 | |
|
48 |
19 |
JOIN, що видно і чого не видно. UNION
В основному доводиться використовувати більше одного джерела.
У нашій схемі, крім таблиці з реєстраціями:
|
reg_dt |
id |
gender |
age |
country_code |
... |
|
|
4446022755 |
f |
37 |
US |
... |
|
|
4446556074 |
f |
40 |
CH |
... |
|
|
4481548107 |
m |
40 |
GE |
... |
|
|
4436447691 |
m |
49 |
US |
... |
|
... |
... |
... |
... |
... |
... |
Є таблиця замовлень:
|
user_id |
dt |
order_id |
service_id |
... |
|
4446022755 |
|
2435206 |
14 |
... |
|
4446022755 |
|
2437018 |
14 |
... |
|
4481548107 |
|
2455378 |
18 |
... |
|
4481548107 |
|
2460491 |
14 |
... |
|
4481548107 |
|
2460602 |
5 |
... |
|
... |
... |
... |
... |
... |
І таблиця-довідник для розшифровки типів сервісів:
|
id |
service_name |
|
5 |
test_srv |
|
9 |
vip |
|
14 |
month |
|
18 |
90 day |
|
... |
... |
Щоб витягти дані з додаткових таблиць, використовуємо LEFT JOIN, що означає, що наша вихідна таблиця users ніби знаходиться зліва і ми приставляємо до неї відповідні рядки з таблиці orders_paid.
SELECT u.reg_dt, u.gender AS sex, u.age, u.id, op.user_id, order_id, op.dt order_dt, op.service_id, pay_services.* FROM product.users AS u LEFT JOIN product.orders_paid op ON u.id = op.user_id LEFT JOIN dictionary.pay_services ON pay_services.id = op.service_id WHERE u.reg_dt > '2013-02-02' – умови, застосовні до поля з будь-якої таблиці. ORDER BY u.reg_dt DESC
Для поля gender ми використовували так званий аліас (іншими словами, перейменували для нашої результуючої таблиці в sex) u.gender AS sex. Для цього можна використовувати слово AS або написати аліас через пробіл, працює ідентично dt order_dt.
Аліаси застосовні і до назв таблиць з тим самим синтаксисом (з AS або без).
Найчастіше аліас для таблиці — це абревіатура її назви orders_paid op.
Це зручніше, ніж назва t1, table_1, і дозволяє прискорити запис op.user_id замість orders_paid.user_id.
Незважаючи на зручність скорочення, іноді краще не використовувати аліаси, оскільки це може ускладнити розуміння скрипту.
Як видно у прикладі order_id, можна використовувати тільки назву поля, без вказівки таблиці, якщо ця назва унікальна. Але все ж більш наочний і універсальний спосіб писати таблиця.поле.
pay_services.* дозволяє вивести всі поля з таблиці pay_services.
В результаті виконання запиту отримали наступну таблицю:
|
reg_dt |
sex |
age |
id |
user_id |
order_id |
order_dt |
service_id |
id |
service_name |
|
|
m |
37 |
4481548107 |
4481548107 |
2460602 |
|
9 |
9 |
vip |
|
|
m |
37 |
4481548107 |
4481548107 |
2460491 |
|
14 |
14 |
month |
|
|
m |
37 |
4481548107 |
4481548107 |
2455378 |
|
18 |
18 |
90 day |
|
|
f |
37 |
4446556074 |
NULL |
NULL |
NULL |
NULL |
NULL |
NULL |
|
|
f |
37 |
4446022755 |
4446022755 |
2435206 |
|
14 |
14 |
month |
|
|
f |
37 |
4446022755 |
4446022755 |
2437018 |
|
14 |
14 |
month |
|
|
m |
37 |
4436702697 |
NULL |
NULL |
NULL |
NULL |
NULL |
NULL |
|
|
m |
37 |
4436447691 |
NULL |
NULL |
NULL |
NULL |
NULL |
NULL |
Для користувачів, які здійснили кілька покупок, рядки з user задублювалися.
Для користувачів, у яких немає оплат, ми отримали значення NULL. Його потрібно розуміти як «немає записів у таблиці, що приєднується».
Таблиці users та orders_paid пов’язані через ID покупця, його називають зв’язком (link). До одного рядка в користувачах може знаходитися кілька (в тому числі 0) рядків замовлень, таким чином можна сказати, що цей зв’язок має тип (1:М) один до багатьох.
Для того щоб коректно зіставити рядки таблиць, після ON вказуються умови, які повинні виконуватися, щоб рядок було додано у вибірку. У нашому випадку ми позначаємо, що рядки з таблиці замовлень мають бути виведені в тих випадках, коли ідентифікатор користувача в таблиці реєстрацій дорівнює ідентифікатору користувача в таблиці оплат u.id = op.user_id.
Аналогічно пов’язані ps.id = op.service_id за тим винятком, що довідник має лише по одному запису для кожного ідентифікатора і не збільшує кількість рядків.
Подальша взаємодія з отриманим набором даних не відрізняється від однієї таблиці — вибираємо поля, що нас цікавлять, фільтруємо та сортуємо.
Таким чином, якщо ми після об’єднання таблиць напишемо умову, яка залишить тільки ті рядки, де ідентифікатор замовлення невідомий, отримаємо список користувачів, для яких немає жодного замовлення.
SELECT u.reg_dt, u.gender, u.age, u.id, op.order_id FROM product.users AS u LEFT JOIN product.orders_paid op ON u.id = op.user_id WHERE op.order_id IS NULL
|
reg_dt |
gender |
age |
id |
order_id |
|
|
m |
37 |
4436702697 |
NULL |
|
|
m |
37 |
4436447691 |
NULL |
|
|
f |
37 |
4446556074 |
NULL |
Причому нам необов’язково виводити поле, яке перевіряємо у WHERE.
Порівняння з NULL проводиться через IS, адже NULL — невизначене значення і не дорівнює саме собі. Оскільки якщо ми використовуємо рівність, то результат порівняння order_id = NULL — брехня, навіть якщо order_id набуде значення NULL.
Щоб знайти рядки для користувачів, які мали замовлення, модифікуємо умову WHERE op.order_id IS NOT NULL або використовуємо інший вид з’єднання таблиць.
LEFT JOIN залишає всі значення таблиці «зліва» і додає значення із зазначеної таблиці, якщо виконується умова в ON.
INNER JOIN, або JOIN, впливає на обидві таблиці та повністю приховує рядок, у якому не виконується умова ON.
Для знаходження рядків, які мають відповідність в обох таблицях, можемо використовувати JOIN product.orders_paid op ON u.id = op.user_id. Потрібно враховувати особливість INNER JOIN приховувати значення.
Якщо JOIN’и використовуються для горизонтального об’єднання таблиць, існує спосіб вертикально об’єднати вибірки за допомогою UNION:
SELECT id, country_code FROM product.users WHERE id IN (4446556074, 4436447691, 4446022755) UNION ALL SELECT id, country_code FROM product.users WHERE id IN (4446022755, 4436702697, 4481548107)
UNION ALL здійснить вертикальне об’єднання всіх рядків.
|
id |
country_code |
|
4446556074 |
CH |
|
4436702697 |
CH |
|
4481548107 |
GE |
|
4436447691 |
US |
|
4446022755 |
US |
|
4446022755 |
US |
UNION при цьому відкине дублікати (за рахунок пошуку дублів виконується довше).
|
id |
country_code |
|
4446556074 |
CH |
|
4436447691 |
US |
|
4436702697 |
CH |
|
4446022755 |
US |
|
4481548107 |
GE |
Обидва запити повинні мати однакову кількість стовпців та відповідні типи даних для колонок (якщо у першому запиті цифри у вигляді int, то вони не зможуть об’єднатися з VARCHAR у другому).
Таким чином можна об’єднати велику кількість вибірок з відповідним набором полів, отримавши на виході одну таблицю.
Типи зв’язків, відмінність умов у WHERE та LEFT JOIN
Якщо перерахувати таблиці через кому у FROM, отримаємо результат як у CROSS JOIN, який зіставляє рядки «кожен з кожним» (кількість рядків у таблиці дорівнюватиме добутку кількості рядків вихідних таблиць). CROSS JOIN при цьому більш поширений та явний спосіб зробити декартів добуток множин.
SELECT u.id, op.user_id, op.order_id FROM product.users u, product.orders_paid op
Що еквівалентно:
SELECT u.id, op.user_id, op.order_id FROM product.users u CROSS JOIN product.orders_paid op
|
id |
user_id |
order_id |
|
4436447691 |
4446022755 |
2435206 |
|
4436447691 |
4446022755 |
2437018 |
|
4436447691 |
4481548107 |
2455378 |
|
4436447691 |
4481548107 |
2460491 |
|
4436447691 |
4481548107 |
2460602 |
|
4436702697 |
4446022755 |
2435206 |
|
... |
... |
... |
У нашому випадку для кожного користувача будуть записані замовлення всіх користувачів.
Вказавши в умові WHERE, що ID користувача повинен бути спільним для двох таблиць:
SELECT u.id, op.user_id, op.order_id FROM product.users u, product.orders_paid op WHERE u.id = op.user_id
Отримаємо результат, аналогічний наступному:
SELECT u.id, op.user_id FROM product.users u INNER JOIN product.orders_paid op ON u.id = op.user_id
|
id |
user_id |
order_id |
|
4481548107 |
4481548107 |
2455378 |
|
4446022755 |
4446022755 |
2437018 |
|
4481548107 |
4481548107 |
2460491 |
|
4481548107 |
4481548107 |
2460602 |
|
4446022755 |
4446022755 |
2435206 |
До кожного користувача віднесені рядки його замовлень з таблиці orders_paid (виключаючи користувачів без замовлень).
Відмінність умов у WHERE та LEFT JOIN
Умова, написана в LEFT JOIN, впливає тільки на поля з таблиці, що приєднується:

SELECT u.id, op.service_id, op.user_id FROM product.users u LEFT JOIN product.orders_paid op ON u.id = op.user_id AND op.service_id = 14
|
id |
service_id |
user_id |
|
4446022755 |
14 |
4446022755 |
|
4446022755 |
14 |
4446022755 |
|
4481548107 |
14 |
4481548107 |
|
4436702697 |
NULL |
NULL |
|
4436447691 |
NULL |
NULL |
|
4446556074 |
NULL |
NULL |
Спочатку було проведено фільтрацію таблиці orders_paid, і залишилися лише всі замовлення сервісу № 14, потім — об’єднання. Таким чином отримали з users всіх зареєстрованих користувачів. Для користувачів, у яких є замовлення сервісу № 14, будуть виведені рядки з таблиці замовлень, для інших — NULL.
Коли умова написана у WHERE, вона фільтрує вибірку вже після об’єднання таблиць.

До фільтрації:
SELECT u.id, op.service_id, op.user_id FROM product.users u LEFT JOIN product.orders_paid op ON u.id = op.user_id
|
id |
service_id |
user_id |
|
4446022755 |
14 |
4446022755 |
|
4446022755 |
14 |
4446022755 |
|
4436702697 |
NULL |
NULL |
|
4446556074 |
NULL |
NULL |
|
4436447691 |
NULL |
NULL |
|
4481548107 |
14 |
4481548107 |
|
4481548107 |
9 |
4481548107 |
|
4481548107 |
18 |
4481548107 |
Після фільтрації:
SELECT u.id, op.service_id, op.user_id FROM product.users u LEFT JOIN product.orders_paid op ON u.id = op.user_id WHERE op.service_id = 14
|
id |
service_id |
user_id |
|
4446022755 |
14 |
4446022755 |
|
4446022755 |
14 |
4446022755 |
|
4481548107 |
14 |
4481548107 |
У підсумку, незважаючи на використання LEFT JOIN, ми отримали аналогічну поведінку, як у INNER JOIN (у здатності приховувати рядки, що не підходять під умову однієї з таблиць).
Умови у блоці WHERE впливають на всю отриману після об’єднання вибірку, це потрібно враховувати. Як видно у прикладі вище ON u.id = op.user_id AND op.service_id = 14, блок ON не обмежується рівністю ідентифікаторів. Це просто блок з умовами (за тими самими правилами, що й у WHERE), які можуть бути різними залежно від ситуації.
Одна і та ж таблиця може бути приєднана кілька разів або приєднана сама до себе. У нашій таблиці з ордерами є поле parent_order_id. Воно вказує на замовлення, яке ініціювало підписку у користувача. Таким чином перші платежі та продовження зберігаються в одній таблиці, і ми пов’язуємо таблицю orders_paid саму з собою за ідентифікатором батьківського платежу.
Так само прив’яжемо довідник з назвами сервісів. Використовуємо INNER JOIN для зв’язку з orders_paid, щоб залишились тільки користувачі із замовленнями.
SELECT u.id, opp.order_id parent_order, opp.dt AS parent_dt, op.order_id, op.dt AS order_dt, ps.service_name FROM product.users u INNER JOIN product.orders_paid op ON u.id = op.user_id LEFT JOIN product.orders_paid opp ON op.parent_order_id = opp.order_id LEFT JOIN product.pay_services ps ON ps.id = op.service_id ORDER BY op.order_id
|
id |
parent_order |
parent_dt |
order_id |
order_dt |
service_name |
|
4446022755 |
2435206 |
|
2435206 |
|
month |
|
4446022755 |
2435206 |
|
2437018 |
|
month |
|
4481548107 |
2455378 |
|
2455378 |
|
90 day |
|
4481548107 |
2460491 |
|
2460491 |
|
month |
|
4481548107 |
NULL |
NULL |
2460602 |
|
vip |
Бачимо, що для ініціюючого платежу parent_order = order_id. А сервіс vip не має parent_order, оскільки це інший тип сервісу. Таким чином parent_dt — це дата початку підписки, а order_dt — дата конкретного платежу.
Агрегації (distinct та розрізи)
У SQL є агрегатні функції, які можуть перетворити кілька рядків на один:
SUM, COUNT, AVG, MIN, MAX і так далі.
Повертає один рядок:
SELECT COUNT(u.id) FROM product.users u
|
count |
|
8073565 |
8073565 — кількість рядків у таблиці users.
Функція COUNT дозволяє вписати *, щоб підрахувати кількість рядків у вибірці:
SELECT COUNT(*) FROM product.users u
У більшості випадків нам потрібно рахувати величини в будь-яких розрізах, залишаючи незгруповані поля.
Ми можемо знайти дату першої реєстрації та кількість зареєстрованих користувачів у кожній країні окремо для чоловіків та жінок (впорядковано від найбільшої групи):
SELECT country_code, gender, MIN(reg_dt), COUNT(id) id_count FROM product.users GROUP BY country_code, gender ORDER BY COUNT(id) DESC
|
country_code |
gender |
MIN |
id_count |
|
CH |
f |
|
983742 |
|
GE |
m |
|
464232 |
|
CH |
m |
|
534654 |
|
US |
f |
|
453645 |
|
US |
m |
|
236235 |
Загальне правило звучатиме так: для агрегації даних потрібно внести всі поля розрізів (поля, що не повинні бути агреговані) в конструкцію GROUP BY, а для полів, що мають бути агреговані, — вибрати агрегатні функції.
Також, зрозуміло, доступні математичні перетворення +, -, *, /.
Якщо у SELECT вказані тільки агреговані вирази, GROUP BY відсутній.
Якщо у SELECT всі поля без агрегацій і вони ж у GROUP BY — запит поверне тільки унікальні значення.
Важливо тримати в голові рівень деталізації даних, який виходить на кожному етапі запиту.
Інакше кажучи, чому відповідає один рядок даних в результаті виконання запиту.
SELECT u.country_code, COUNT(DISTINCT u.id) registered, COUNT(DISTINCT op.user_id) payers, COUNT(DISTINCT op.user_id) / COUNT(DISTINCT u.id) * 100 conv FROM product.users u LEFT JOIN product.orders_paid op ON u.id = op.user_id WHERE reg_dt BETWEEN '2020-02-02' AND '2020-02-03' GROUP BY u.country_code HAVING COUNT(DISTINCT op.user_id) > 100
Цим запитом ми хочемо порахувати відсоток користувачів, які здійснили хоча б одну оплату.
Для цього потрібно кількість унікальних користувачів COUNT(DISTINCT u.id), що зареєструвалися, поділити на кількість унікальних користувачів з таблиці оплат COUNT(DISTINCT op.user_id).
Слово DISTINCT видалить дублікати.
Підрахунок ми проведемо окремо для кожної локації GROUP BY u.country_code.
З отриманої вибірки HAVING COUNT(DISTINCT op.user_id) > 100 залишимо тільки рядки, що відповідають умові, у нашому випадку це локації, в яких більше 100 платників.
|
country_code |
registered |
payers |
conv |
|
GE |
491871 |
16164 |
3.286227486 |
|
CH |
232116 |
9128 |
3.9325165 |
|
US |
267327 |
13758 |
5.146505965 |
У блоці WHERE вказуються умови, що належать до даних у вихідних таблицях, а в HAVING — умови, які належать до результатів агрегації.
Запит обробляється в такому порядку:
- FROM (і JOIN’и)
- WHERE
- GROUP BY
- HAVING
- SELECT
- ORDER BY
Таким чином спочатку відбудеться об’єднання джерел даних в одну таблицю, аналогічне виконанню такого запиту:
SELECT u.country_code, u.id id_from_u, op.user_id id_from_op, op.order_id* FROM product.users u LEFT JOIN product.orders_paid op ON u.id = op.user_id WHERE reg_dt BETWEEN '2020-02-02' AND '2020-02-03'
*op.order_id виведено для демонстрації.
Бачимо, що для користувачів, у яких більше одного замовлення, з’явилось по кілька рядків.
І якби ми використовували COUNT без DISTINCT, то підрахували б кількість користувачів, помножену на кількість їхніх замовлень.
|
country_code |
id_from_u |
id_from_op |
order_id |
|
US |
4446022755 |
4446022755 |
2435206 |
|
US |
4446022755 |
4446022755 |
2437018 |
|
CH |
4436702697 |
NULL |
NULL |
|
CH |
4446556074 |
NULL |
NULL |
|
US |
4436447691 |
NULL |
NULL |
|
GE |
4481548107 |
4481548107 |
2460491 |
|
GE |
4481548107 |
4481548107 |
2460602 |
|
GE |
4481548107 |
4481548107 |
2455378 |
Потім будуть проведені агрегації та видалення зі списку локацій з малою кількістю користувачів HAVING COUNT(DISTINCT op.user_id) > 100.
Агрегатні функції будуть підраховані без урахування значень NULL.
Підсумок
Тут ми проаналізували, як витягувати дані з таблиць, коректно їх об’єднувати, фільтрувати та групувати.
Цього достатньо для того, щоб отримувати нескладні вибірки та рахувати статистику.
48 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівДякую за статтю, але коли ви пишете про SQL, потрібно завжди уточнювати — це ANSI SQL (якщо так, то якого року) або розширення (наприклад, T-SQL), то що 100% працювало на вашій СУБД, може цілком собі перестати працювати на інший.
Я так розумію, ви всі зазначені SQL запити тестували, цікаво, на який СУБД?
Дякую! Звісно, тестував на:
MySQL 5.6
PostgreSQL 9.6
PostgreSQL 9.3
SQLite
Vertica
BigQuery (Standard SQL)
В статті використаний базовий функціонал, спільний для більшості БД, з мінімальними відмінностями. Наприклад в MS SQL Server «LIMIT n» треба замінити на «TOP n» і написати в SELECT. А в Oracle не можна порівняти дату зі строковим типом даних, а треба зробити приведення типу, щось накшталт TO_DATE (‘2020/02/02’, ‘yyyy/mm/dd’). Таким чином вважаю, що питання розбіжності діалектів переоцінене і вирішуються за допомогою google-пошуку або місцевого DBA.
От тільки місцевому дба нічим зайнятися, крім як за вами ваші запити підбирати.
Скрипти важко читаються через різне іменування. Було би добре звести до одного.
Що мається на увазі під «іменування»?
Ну и кому ты это написал? Для того кто знает — это ликбез. Для того кто не знает — булщит крэп
Ну почему, я где-то по подобным описаниям получал начальные сведения типа «с чем это едят», от которых можно было плясать дальше. Просто надо адекватно пометить.
Просто надо найти в куче мусора крупицу истины. Собственно говоря, это и есть критерий полезности — чтобы мусора поменьше, пользы побольше, инфа структурирована так, что отвечает типичным шаблонам поиска.
Если этого всего нет — тогда стартовая страница гугла полезнее этого бла-бла-бла. И да, лучше написать 10 маленьких статеек с маленькой пользой, чем одну большую с отрицательной. Конечно, можно сделать магию вроде оглавления и внутренних ссылок... но не царское ж это дело, свой какантент структурировать.
Алексей, я не первый раз наталкиваюсь на ваши комментарии и каждый раз они в духе «обоже, как все плохо» (я тут смягчил) — хочу поинтересоваться, это у вас миссия такая и, может, вы пишите статьи совсем на другом уровне и можете похвастаться?
От себя хочу заметить, что его комментарии обычно раскрывают аспект, о котором не думаешь, и дают возможность задуматься и взглянуть под другим углом. Часто не согласен, но тут лучше интересная спорность, чем унылая банальность.
Я не писал «как всё плохо». Если бы вы прочитали, вы бы это знали. Но вы НЕ прочитали, лишь заметили слово-триггер, да и то в лучшем случае. В общем, прочитайте СТАТЬЮ, потом судите.
Нравится когда просто и хештегами → идите на Твитер, вам понравится! И меня там нет :)
Не сомневайтесь — я прочитал и статью, и комментарий)
Но не нашел ответ на мой вопрос, хотя, конечно, вы не обязаны отвечать)
Бедные войтишнеги
itzik ben gan давно все понятно изложил.
Прорекламирую пару вещей, чтобы от статьи хоть какая-то польза была.
www.sommarskog.se
use-the-index-luke.com
дякую за статтю, можливо можете підсказати як написати швидкий запит з наступними умовами є дві таблиці players і bets у яких спільне поле playerid
потрібно вивести перший запис у таблиці bets для кожного гравця не використовуючи функцію MAX()
Transact-SQL
Думаю, можно определить первую запись в таблице bets чем-то типа row_number, а дальше джоинится
docs.microsoft.com/...sql?view=sql-server-ver15
в
є first_value
я после Vertica не доверяю таким функциям ))
из доки про last_value:
хотя first_value работает, как нужно
диявол в нюансах)) там ще питання Null рахуєм чи не рахуєм))
тут не дуже зрозуміло, бо не вказано чи є дублі в плеєрах, що означає перший запис (таблиця відсортована?), які ключі і що означає «спільне поле»)) але ви мабуть щось таке шукаєте (діалект T-SQL як просили):
перший — це мається на увазі сортування, чи будь-який? Fetch next використовується разом з Order by
оскільки сильно багато інфи не було надано, то шось тіпа такого
критично важливо звернути увагу на підзапит — top 1 та order by b.field5 — цей трюк вибере «перший» чи «останній» запис — це з якого боку дивитись
order by весьма дорогая операция. Потому SQL и допиливают выражениями вроде last_value, чтобы применять дешёвую операцию вместо дорогой.
Дорога, якщо без розуму і без індексів використовувати.
Тут вона йде в парі з top 1, що дозволить це оптимізувати у пошук першого/останнього рядка.
order by в підзапитах потенційно проблемна навіть із індексами. Я ж кажу, зроби бенчмарк. Зокрема, по оперативі.
Особисто я для подібних целей створюю окрему таблицю, де вже код бекенду складає потрібну мені статистику, або одночасно із діями юзера, або робить агрегацію щоночі чи щотижня. Не скажу, на чому я вертів ваші «нормальні форми», коли треба оптимізувати швидкість.
Лан, повіримо на слово, ібо спеціально робити бенчмарки ліниво і довго :)
Коли доведеться — поміряю і скажу.
Канешно, що підготовлені дані — це буде супер швидко, на грані з чітерством.
Хоча, я б не плодив лишні таблиці, а зробив би флажки або теги
В IT немає поняття «читерство». Що в бюрократичній теорії зветься «о боже, небо на землю впаде», на практиці, як і у більш фундаментальній теорії, є ефективністю.
Я з того покоління, яке ще пам′ятає обчислення на калькуляторі. І навіть у стовпчик на папері. Тому в мене імунітет проти маразму «нормальних форм», бо вони навіть не зменшують ймовірності помилок, лише перекидають цю ймовірність в інше місце, де їх буде ще важче знайти.
Особливо «весело», що теорія вважає дані абсолютно точними, і сподівається на строго НУЛЬОВУ ймовірність збою як при збереженні даних, так і при їх обробці. Хоча техніка має статистику відмов, і вона далеко не нульова. Маючи агрегати, можна дуже швидко відновити цілісніть системи накопичення даних навіть попри втрату частини первинної інфи, а первинну інфу — неспішно підняти з бекапів. Спробуйте ж швидко підняти з бекапів десятки терабайт, які ще й у досить незручній формі зберігаються, і також можуть бути пошкодженими (і це неможливо перевірити, доки не впишеш в базу).
OLTP/OLAP помогает еще.
Аналітику — так. Але на продакшен для типових задач подібне ставити то треба неабияку експертизу мати.
Звучит как задачка из тестового для какого-нибудь parimatch tech
Не из тестового. Просто в работе столкнулся с потребностью проанализировать data. Информацию я получил но долго и коряво через вложенные селекты и функцию MAX().
Нужно разобраться как over partition работает. Походу полезная штука.
Нет ничего плохого во вложенных селектах. Ради интереса попробуй бенчмарк сделай этим запросам, убедишься что они летают. А вот с точки зрения человека как раз вложенные селекты наиболее читабельны.
погугли про віконні функції, тебе цікавить щось таке — www.sqlshack.com/...dow-functions-sql-server
Якщо таке буде потрібно в реалі, прямо таки зроби ОКРЕМУ таблицю, в якій вестимеш статистику гравця. Бо таблиця ставок — це мільйони записів, а таблиця гравців — в кращому випадку тисяч 10, і там все індексовано по primary key. І в ній собі колекціонуватимеш по кожному гравцю що потрібно, і змінюватимеш цю таблицю під кожну нову забажанку — а їх буде ойдофіга.
Що це дасть: миттєвий доступ до агрегатів. І можливість збирати ці агрегати не тільки онлайново, але й вночі. Бо найсвіжіші дані зазвичай не потрібні. А коли потрібні — ти аналізуватимеш таблицю сирих даних лише за сьогодні, накладаючи результат в коді бекенду (краще), або запитом із арифметикою (також швидко, але гірше читається код людиною).
Це класична задача «MAX() by group», яка в жодному діалекті SQL не вирішується в лоба. Або через сабселект з МАХ(), або через мінорну денормалізацію. Причому другому підходу слід віддавати перевагу, якщо таблиці великі, а частота таких запитів висока.
Денормалізація полягає в тому, що треба заводити другу ідентичну таблицю lastbets, в якій поле playerid матиме унікальний індекс. В основну таблицю писати, як і раніше, а в нову — через «INSERT ... ON DUPLICATE KEY UPDATE ...»
Накладні витрати при цьому трошки зростають (два інсерти замість одного), але зате результат отримується миттєво. Фактично, накладні витрати при використанні однієї таблиці теж немаленькі, але вони «концентровані» у часі, під час власне виборки. При денормалізації ж ці накладні витрати рівномірно розмазуються у часі. База веде себе більш стабільно, без просадок продуктивності.
Треба додати ():
Статтю можна було написати краще, описувати синтаксичний цукор BETWEEN та HAVING було зайве!
Замість HAVING можна було описати вкладені запити.
Треба було вказати назву БД з якою ви працюєте!
Треба було вказати як отримати список таблиць які є в БД!
Будь-ласка зробіть висновки та напишіть продовження краще!
Дякую за відгук!
HAVING зі стандарту SQL, вважаю що важливо було описати цю механіку, її часто використовують аналітики. Вкладені запити будуть в продовженні.
Намагався використати синтаксис, який працюватиме майже на всіх діалектах SQL (LIMIT не буде працювати в T-SQL, деякі запити не працюватимуть в ClickHouse).
Отримати список таблиць в різних БД має свої особливості.
а зачем еще «()»?
Ну на вскидку гайд подходит для старта работы с большинством sql бд — не описано ничего специфического по типу ilike и т.д.
Скобки потому что and имеет приоритет и выполняется раньше or. по этому для того что бы указать порядок вычисления, нужно ставить скобки.
в запросе из примера, который мы обсуждаем, есть скобки, про которые пишите вы и автор первого комментария в этой ветке о_О
Определённо лишний элемент, лол
А заодно и ещё парочку суб-оптимальных или альтернативных решений 👌 (почему не common table expression, кстати?)
Если без сарказма — то на простых примерах (2 таблицы, 1 group by) сложно представить что движок бд / оптимизатор запросов будет оптимизировать разными путями вложенный запрос с фильтрацией и HAVING
Стаття орієнтована на початківців а тому HAVING значно ускладнює статтю.
Якщо замінити HAVING на вкладені запити то це буде значно зрозуміліше для початківців.
HAVING взагалі досить дорога операція, бо зазвичай передбачає створення тимчасової таблиці. Це фактично постфільтрація.
Постфільтрація тимчасової таблиці без індексів.
Worst case possible.
Майже завжди тимчасова таблиця повністю вміщується в пам′яті, а фільтрація HAVING в будь-якому випадку це прохід усієї таблиці. Зазвичай HAVING використовується із агрегатними функціями, де результуючий набір ну дуууже невеликий (рідко сягає навіть тисячі записів), тому про його вартість навіть не згадують. Але факт, що із ним легко начудити, перекинувши у постфільтрацію те, що варто було відфільтрувати за допомогою WHERE.