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

2014-01-02 17:30:21

4446022755

f

37

US

desktop

2014-01-02 21:07:08

4446556074

f

40

CH

android

2014-01-14 21:07:15

4481548107

m

40

GE

mobile

2013-12-30 8:33:09

4436447691

m

49

US

mobile

2013-12-30 11:04:15

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

2012-08-01 0:43:09

m

34

ametconsectetur.us

2014-02-02 0:49:19

f

28

abcdefghij.com

2018-04-02 4:07:42

f

55

loremipsum.com

2021-05-08 12:00:02

m

48

ametconsectetur.com

2021-09-09 10:10:37

m

44

abcdefghij.us

Порівнювати рядки можна конструкцією site LIKE '%.com'.

Це формулювання допоможе знайти користувачів, зареєстрованих на ресурсах, які містять будь-який набір символів (задано зарезервованим символом %), потім .com, таким чином ми отримаємо користувачів, зареєстрованих на ресурсі з доменом .com.

SELECT site FROM product.users WHERE site LIKE '%.com';

site

abcdefghij.com

loremipsum.com

ametconsectetur.com

SELECT site FROM product.users WHERE site LIKE 'abcdefghij.%';

site

abcdefghij.com

abcdefghij.us

Символ % може міститися в будь-якій частині рядка або бути вказаний кілька разів. Означає «будь-який набір символів або відсутність символу».

Написавши 'a%.com', ми знайдемо користувачів у домені .com, де назва сайту починається з літери a.

site

abcdefghij.com

ametconsectetur.com

Є ще один зарезервований символ _ (нижнє підкреслення), що позначає один символ. Таким чином знайдемо користувачів ресурсу, який містить другим символом літеру m і закінчується доменом .us, можна так: site LIKE '_m%.us'.

site

ametconsectetur.us

Як і в курсі шкільної математики (множення виконується раніше, ніж віднімання), оператори мають порядок виконання.

При використанні логічного оператора 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 очікується один шуканий патерн, потрібно повторити конструкцію для другого патерну.

Виконавши запит, отримаємо список користувачів. Деякі з них зареєстровані за межами виділеного нами діапазону часу 2012–2014 рр.

reg_dt

gender

age

site

2012-08-01 0:43:09

m

34

ametconsectetur.us

2014-02-02 0:49:19

f

28

abcdefghij.com

2018-04-02 4:07:42

f

55

loremipsum.com

2021-05-08 12:00:02

m

48

ametconsectetur.com

А все тому, що вказана в кінці умова 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

2012-08-01 0:43:09

m

34

ametconsectetur.us

2014-02-02 0:49:19

f

28

abcdefghij.com

Умова може бути сформульована і як результат перетворень:

SELECT age, site, LENGTH(site) 
FROM product.users
WHERE age%2 != 0 OR LENGTH(site) > 17

Вік — непарне число (остача від ділення на 2 не дорівнює 0) або довжина ресурсу — більше 17 символів. Причому поле, для якого створена умова, необов’язково виводити в SELECT.

age

site

LENGTH

34

ametconsectetur.us

18

55

loremipsum.com

14

48

ametconsectetur.com

19

JOIN, що видно і чого не видно. UNION

В основному доводиться використовувати більше одного джерела.

У нашій схемі, крім таблиці з реєстраціями:

reg_dt

id

gender

age

country_code

...

2014-01-02 17:30:21

4446022755

f

37

US

...

2014-01-02 21:07:08

4446556074

f

40

CH

...

2014-01-14 21:07:15

4481548107

m

40

GE

...

2013-12-30 8:33:09

4436447691

m

49

US

...

...

...

...

...

...

...

Є таблиця замовлень:

user_id

dt

order_id

service_id

...

4446022755

2014-01-02 18:30:01

2435206

14

...

4446022755

2014-02-02 18:25:17

2437018

14

...

4481548107

2014-01-14 21:08:45

2455378

18

...

4481548107

2014-04-11 15:11:18

2460491

14

...

4481548107

2014-04-13 12:10:09

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

2014-01-14 21:07:15

m

37

4481548107

4481548107

2460602

2014-04-13 12:10:09

9

9

vip

2014-01-14 21:07:15

m

37

4481548107

4481548107

2460491

2014-04-11 15:11:18

14

14

month

2014-01-14 21:07:15

m

37

4481548107

4481548107

2455378

2014-01-14 21:08:45

18

18

90 day

2014-01-02 21:07:08

f

37

4446556074

NULL

NULL

NULL

NULL

NULL

NULL

2014-01-02 17:30:21

f

37

4446022755

4446022755

2435206

2014-01-02 18:30:01

14

14

month

2014-01-02 17:30:21

f

37

4446022755

4446022755

2437018

2014-02-02 18:25:17

14

14

month

2013-12-30 11:04:15

m

37

4436702697

NULL

NULL

NULL

NULL

NULL

NULL

2013-12-30 8:33:09

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

2013-12-30 11:04:15

m

37

4436702697

NULL

2013-12-30 8:33:09

m

37

4436447691

NULL

2014-01-02 21:07:08

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

2014-01-02 18:30:01

2435206

2014-01-02 18:30:01

month

4446022755

2435206

2014-01-02 18:30:01

2437018

2014-02-02 18:25:17

month

4481548107

2455378

2014-01-14 21:08:45

2455378

2014-01-14 21:08:45

90 day

4481548107

2460491

2014-04-11 15:11:18

2460491

2014-04-11 15:11:18

month

4481548107

NULL

NULL

2460602

2014-04-13 12:10:09

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

2014-01-02 21:07:08

983742

GE

m

2014-01-14 21:07:15

464232

CH

m

2013-12-30 11:04:15

534654

US

f

2014-01-02 17:30:21

453645

US

m

2013-12-30 8:33:09

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 — умови, які належать до результатів агрегації.
Запит обробляється в такому порядку:

  1. FROM (і JOIN’и)
  2. WHERE
  3. GROUP BY
  4. HAVING
  5. SELECT
  6. 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.

Підсумок

Тут ми проаналізували, як витягувати дані з таблиць, коректно їх об’єднувати, фільтрувати та групувати.
Цього достатньо для того, щоб отримувати нескладні вибірки та рахувати статистику.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось17
До обраногоВ обраному23
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Так и возникла идея написать статью, которая сможет последовательно и без воды рассказать, как использовать SQL для получения выборок из нескольких источников с нужными ограничениями, в нужном виде и разрезах.

Дякую за статтю, але коли ви пишете про SQL, потрібно завжди уточнювати — це ANSI SQL (якщо так, то якого року) або розширення (наприклад, T-SQL), то що 100% працювало на вашій СУБД, може цілком собі перестати працювати на інший.

и использую SQL для работы с разными БД.

Я так розумію, ви всі зазначені 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

SELECT *
FROM somes
WHERE some_id IN (
    SELECT (
               SELECT some_id
               FROM somes s
               WHERE s.user_id = u.user_id
               LIMIT 1
           ) AS some_id
    FROM users u
)

Думаю, можно определить первую запись в таблице bets чем-то типа row_number, а дальше джоинится
docs.microsoft.com/...​sql?view=sql-server-ver15

я после Vertica не доверяю таким функциям ))

из доки про last_value:

Due to default window semantics, LAST_VALUE does not always return the last value of a partition.

хотя first_value работает, как нужно

диявол в нюансах)) там ще питання Null рахуєм чи не рахуєм))

тут не дуже зрозуміло, бо не вказано чи є дублі в плеєрах, що означає перший запис (таблиця відсортована?), які ключі і що означає «спільне поле»)) але ви мабуть щось таке шукаєте (діалект T-SQL як просили):

select
player.id
,first_value (bets.id) over (partition by player.id order by bets.id)
from players
left join bets
on players.playerid = bets.playerid

перший — це мається на увазі сортування, чи будь-який? Fetch next використовується разом з Order by

оскільки сильно багато інфи не було надано, то шось тіпа такого

select p.field1, p.field2, bet.field3, bet.field4
from players p
outer apply (
    select top 1 b.field3, b.field4
    from bets b
    where b.playerid = p.playerid
    order by b.field5
) bet (field3, field4)

критично важливо звернути увагу на підзапит — top 1 та order by b.field5 — цей трюк вибере «перший» чи «останній» запис — це з якого боку дивитись

order by весьма дорогая операция. Потому SQL и допиливают выражениями вроде last_value, чтобы применять дешёвую операцию вместо дорогой.

Дорога, якщо без розуму і без індексів використовувати.
Тут вона йде в парі з top 1, що дозволить це оптимізувати у пошук першого/останнього рядка.

order by в підзапитах потенційно проблемна навіть із індексами. Я ж кажу, зроби бенчмарк. Зокрема, по оперативі.

Особисто я для подібних целей створюю окрему таблицю, де вже код бекенду складає потрібну мені статистику, або одночасно із діями юзера, або робить агрегацію щоночі чи щотижня. Не скажу, на чому я вертів ваші «нормальні форми», коли треба оптимізувати швидкість.

Лан, повіримо на слово, ібо спеціально робити бенчмарки ліниво і довго :)
Коли доведеться — поміряю і скажу.

Канешно, що підготовлені дані — це буде супер швидко, на грані з чітерством.
Хоча, я б не плодив лишні таблиці, а зробив би флажки або теги

В IT немає поняття «читерство». Що в бюрократичній теорії зветься «о боже, небо на землю впаде», на практиці, як і у більш фундаментальній теорії, є ефективністю.

Я з того покоління, яке ще пам′ятає обчислення на калькуляторі. І навіть у стовпчик на папері. Тому в мене імунітет проти маразму «нормальних форм», бо вони навіть не зменшують ймовірності помилок, лише перекидають цю ймовірність в інше місце, де їх буде ще важче знайти.

Особливо «весело», що теорія вважає дані абсолютно точними, і сподівається на строго НУЛЬОВУ ймовірність збою як при збереженні даних, так і при їх обробці. Хоча техніка має статистику відмов, і вона далеко не нульова. Маючи агрегати, можна дуже швидко відновити цілісніть системи накопичення даних навіть попри втрату частини первинної інфи, а первинну інфу — неспішно підняти з бекапів. Спробуйте ж швидко підняти з бекапів десятки терабайт, які ще й у досить незручній формі зберігаються, і також можуть бути пошкодженими (і це неможливо перевірити, доки не впишеш в базу).

Аналітику — так. Але на продакшен для типових задач подібне ставити то треба неабияку експертизу мати.

Звучит как задачка из тестового для какого-нибудь 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 ...»

Накладні витрати при цьому трошки зростають (два інсерти замість одного), але зате результат отримується миттєво. Фактично, накладні витрати при використанні однієї таблиці теж немаленькі, але вони «концентровані» у часі, під час власне виборки. При денормалізації ж ці накладні витрати рівномірно розмазуються у часі. База веде себе більш стабільно, без просадок продуктивності.

Треба додати ():

Если мы захотим найти пользователей, что зарегистрировались в конкретном временном диапазоне с доменом ’%.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')

Статтю можна було написати краще, описувати синтаксичний цукор BETWEEN та HAVING було зайве!
Замість HAVING можна було описати вкладені запити.

Треба було вказати назву БД з якою ви працюєте!
Треба було вказати як отримати список таблиць які є в БД!

Будь-ласка зробіть висновки та напишіть продовження краще!

Дякую за відгук!

HAVING зі стандарту SQL, вважаю що важливо було описати цю механіку, її часто використовують аналітики. Вкладені запити будуть в продовженні.

Намагався використати синтаксис, який працюватиме майже на всіх діалектах SQL (LIMIT не буде працювати в T-SQL, деякі запити не працюватимуть в ClickHouse).

Отримати список таблиць в різних БД має свої особливості.

Треба додати ()

а зачем еще «()»?

Треба було вказати назву БД з якою ви працюєте!

Ну на вскидку гайд подходит для старта работы с большинством sql бд — не описано ничего специфического по типу ilike и т.д.

Скобки потому что and имеет приоритет и выполняется раньше or. по этому для того что бы указать порядок вычисления, нужно ставить скобки.

в запросе из примера, который мы обсуждаем, есть скобки, про которые пишите вы и автор первого комментария в этой ветке о_О

Треба додати ():

Определённо лишний элемент, лол

Замість HAVING можна було описати вкладені запити

А заодно и ещё парочку суб-оптимальных или альтернативных решений 👌 (почему не common table expression, кстати?)

Если без сарказма — то на простых примерах (2 таблицы, 1 group by) сложно представить что движок бд / оптимизатор запросов будет оптимизировать разными путями вложенный запрос с фильтрацией и HAVING

Стаття орієнтована на початківців а тому HAVING значно ускладнює статтю.

Якщо замінити HAVING на вкладені запити то це буде значно зрозуміліше для початківців.

HAVING взагалі досить дорога операція, бо зазвичай передбачає створення тимчасової таблиці. Це фактично постфільтрація.

Постфільтрація тимчасової таблиці без індексів.
Worst case possible.

Майже завжди тимчасова таблиця повністю вміщується в пам′яті, а фільтрація HAVING в будь-якому випадку це прохід усієї таблиці. Зазвичай HAVING використовується із агрегатними функціями, де результуючий набір ну дуууже невеликий (рідко сягає навіть тисячі записів), тому про його вартість навіть не згадують. Але факт, що із ним легко начудити, перекинувши у постфільтрацію те, що варто було відфільтрувати за допомогою WHERE.

Підписатись на коментарі