Введение

Недавно нам прилетело большое тестовое задание от Тиньков-Банка на должность аналитика данных. Там очень много задач, но сегодня мы разберем несколько — остановимся на мелочах и обратим внимание на тонкие моменты.

Да, продуктовые метрики мы тоже будем сегодня считать :)
Да, продуктовые метрики мы тоже будем сегодня считать :)

Материал создан командой Симулятора «SQL для анализа данных».

Описание базы данных

Итак, нам дана такая структура таблиц:

Структура таблиц в базе данных
Структура таблиц в базе данных

В какой СУБД мы будем работать — не сказано. По косвенным признакам мы предполагаем, что это PostgreSQL.

Хотя, по сути, это не особо важно — отличаться будут только некоторые функции. Все базовые операторы будут одинаковыми.

Кстати, интересный вопрос: представьте, что вы можете попросить интервьюера дать вам пример любого запроса.

Какой запрос вы попросите написать, чтобы понять, с какой СУБД вы имеете дело?

Ответ на этот вопрос предлагаем обсудить в комментариях, а мы переходим к задачам! ????

Задача 1

Условие

Необходимо получить список сотрудников в формате: «Иванова — Наталья – Юрьевна». ФИО должно быть прописано в одном столбике, разделение .

Вывести: новое поле, назовем его fio, birth_dt

Решение

Эта задача достаточно простая — здесь даже нет необходимости джойнить другие таблицы, достаточно поработать с таблицей Employees

Основная проблема — вывести ФИО через заданный разделитель. Многие решают эту задачу с помощью простой конкатенации:

select
	first_nm || '—' || middle_nm || '—' || last_nm as fio, 
	birth_dt
from employees

Но мы работаем в PostgreSQL, поэтому воспользуемся плюшкой — функцией CONCAT_WS. Она тоже делает конкатенацию строк, но первым аргументом принимает разделитель:

select 
	concat_ws('—', first_nm, middle_nm, last_nm) as fio, 
	birth_dt
from employees

Выглядит посимпатичней. Заодно и перед интервьюером блеснули знаниями ????

Задача 2

Условие

Вывести %% дозвона для каждого дня. Период с 01.10.2020 по текущий день.

%% дозвона – это доля принятых звонков (dozv_flg=1) от всех поступивших звонков (dozv_flg = 1 or dozv_flg = 0).

Вывести: date, sla (%% дозвона)

Решение

Здесь задача уже поинтересней — мы все еще работаем с одной таблицей, но многие соискатели на таких задачах начинают городить многоэтажные подзапросы. 

А на самом деле, все просто — достаточно просто знать, что условный оператор CASE можно использовать внутри агрегатных функций — например, COUNT

Итак, чтобы посчитать SLA, нам нужно:

  • посчитать кол-во звонков с dozv_flg = 1

  • посчитать общее количество звонков

  • разделить одно на другое

Давайте сделаем это в одном запросе, без подзапросов и CTE. 

select
	start_dttm::date as "date", 
	count(case when dozv_flg=1 then 1 end) /
  	count(case when dozv_flg in (1, 0) then 1 end) as sla
from calls
where start_dttm::date between '2020-10-01' and now()::date
group by start_dttm::date

Вот, собственно, и все. Но проговорим несколько важных моментов:

Почему мы написали не count(*), а count(case when dozv_flg in (1, 0) then 1 end)?

Мы просто перестраховались — вдруг там еще какие-то значения могут быть. Например, 2. Лишним не будет, в любом случае.

Зачем мы делаем преобразование с помощью ::date?

А потому что оператор between потеряет все записи за сегодня, если не преобразовать эти поля в дату (это особенность сравнения даты-времени в PostgreSQL). Опять же - мы просто перестраховались.

Задача 3

Условие

Дана таблица clinets:

  • id клиента

  • calendar_at - дата входа в мобильное приложение

Нужно написать запрос для расчета MAU.

Решение

Если что, MAU - monthly active users: количество уникальных клиентов, проявляющих активность в приложении в течение месяца. 

Многие по ошибке выводят MAU в виде таблицы со столбцами Месяц — Кол-во активных клиентов. Это неправильно - MAU всегда должно быть одним числом

Соответственно, решение задачи сводится к следующим пунктам:

  • посчитать количество уникальных клиентов за каждый месяц

  • усреднить данные по всем месяцам

Для решения задачи мы будем использовать CTE и оператор DISTINCT внутри COUNT:

with a as (
	select 
		to_char(calendar_dt, 'MM') as mon, 
		count(distinct id) as cnt
	from clients 
	group by mon
)
select avg(cnt) as mau
from a

Сразу отметим - MAU можно считать и по-другому. Например:

  • сразу брать цифры на примере одного месяца

  • находить медиану

  • как-то еще

Мы просто показали один из вариантов ????

Эпилог

На сегодня остановимся на этих 3 задачах. У нас в запасе еще много интересного — так что если вам зашло, мы скоро вернемся с новым разбором!

В целом, задачи не очень сложные. Но мы постарались «подсветить» некоторые тонкие моменты, без которых решение этих задач может стать проблемой.


Хотите глубже разобраться в том, как писать крутые SQL-запросы, делать эдхоки и считать продуктовые метрики? Пройдите обучение в Симуляторе по SQL от ребят из Simulative - там много крутых штук ????

Еще больше интересных материалов - в нашем телеграм канале.

Материал создан командой Симулятора «SQL для анализа данных».

Комментарии (57)


  1. s_f1
    27.07.2022 19:05
    +5

    Что у Тинькова с названиями полей? middle_nm? dozv_flg??? Они серьёзно? Или у них 'a' на клавиатуре отсутствует?


    1. DarkSavant
      27.07.2022 19:19
      +1

      Не работал в Тиньке, но работал много где еще. Такие именования видел, что аж поначалу страшно становилось от этих аббревиатур на 20+ символов.
      Но, разумеется, что мешает именовать нормально и без дурацких сокращений, чтоб было понятно по названию колонки, а не часа ковыряний доки?


      1. ITResume Автор
        27.07.2022 19:40
        +3

        Хотя кстати иногда лучше даже длинное название дать, но читаемое. Во всяком случае, лучше чем:

        * длинное и нечитаемое
        * короткое нечитаемое

        :D


        1. crckhd
          28.07.2022 10:14

          Автор книги "Чистый код" - Дядя Боб плачет. Вторая глава о наименовании тут совсем не работает)


          1. ITResume Автор
            28.07.2022 10:15

            Ну это не самый плохой вариант, серьезно) Видели и похуже)


      1. aleksandy
        28.07.2022 07:38
        +1

        что мешает именовать нормально и без дурацких сокращений

        Тяжёлое оракловая травма в начале карьеры. До 10 версии оракул точно не разрешал имена длиннее 30 символов. А если ещё и по каким-нибудь правилам у таблиц должны быть определённые префиксы, то вообще караул.


      1. gluck59
        28.07.2022 11:56

        ...а там где я работаю, нет доки совсем.
        И комментариев в таблицах и полях тоже нет.
        И связей между таблицами.
        И сокращения в названиях — ууу... Ладно 20 символов, тут хоть иногда можно догадаться о чем речь. А вот 2-3-4 символа это да.


    1. Nialpe
      27.07.2022 19:35
      +1

      Это вы с обфускацией наименований полей, таблиц, процедур и т.п. не сталкивались. Когда столкнулся (в финтехе тоже) был в шоке долгое время. Совсем как в фильме "Операция Ы". Помните причину почему Ы?


      1. doctorw
        28.07.2022 22:31

        чтобы никто не догадался?


    1. ITResume Автор
      27.07.2022 19:40
      +1

      Мы недавно столкнулись с названием полей одной крупной интернет-компании (не будем тыкать пальцем), вот там вообще треш)) Просто tbl_clt_fr_crm_ld_ydx_t_cpn - что-то в таком духе)


      1. AlexWinner
        27.07.2022 21:29
        +3

        ****ydx*** :D


        1. ITResume Автор
          28.07.2022 10:15

          Не, это типа рекламные кампании были, Яндекс тут не причем)


      1. doctorw
        28.07.2022 22:48

        *_crm_* приходилось видеть в бд битрикса :(


    1. Aleks_ja
      28.07.2022 02:47

      А то, что микс русской и английской транскрипции вас не смущает?

      first_nm + middle_nm + last_nm = fio ????

      Почему б не full_nm. И раз уж без гласных, то fll_nm.


      1. ITResume Автор
        28.07.2022 10:15

        fio оно свое, родное)


  1. pae174
    27.07.2022 20:06

    Структура таблиц в базе данных

    Что за разноцветная ерунда? Там что, не верят в UML что ли?


    1. ITResume Автор
      28.07.2022 10:17
      -1

      Зато красиво))


      1. ITResume Автор
        29.07.2022 11:57
        -1

        так а нам зачем минусы ставят, это же не мы базу рисовали :D


  1. mentin
    27.07.2022 20:07

    Если уж страховаться от левых значений в dozv_flg, то стоит застраховаться от отсутствия там значений вообще, и избежать деления на ноль


    1. Xander_d
      27.07.2022 21:05

      Вот да. И по хорошему, сначала посмотреть бы, а какие там данные вообще могут быть. А то вдруг там и 2, и 3...

      И окажется, что мы неправильно понимаем значение этого флага и это, скажем, количество, а не флаг, и надо будет просто ">0" в кейс записывать.


      1. ITResume Автор
        28.07.2022 10:17

        Так таблицы-то на листочке даны)


    1. ITResume Автор
      28.07.2022 10:17

      Разумно


  1. starik-2005
    27.07.2022 20:17

    Даже интересно стало, сколько они такому джуну предлагают бабла...


    1. ITResume Автор
      28.07.2022 10:17

      Ну в целом ЗП там неплохие, насколько я знаю. Но там тестовое заданий на 50, так что это не все))


  1. wadeg
    27.07.2022 20:21
    +7

    А потому что оператор between потеряет все записи за сегодня, если не преобразовать эти поля в дату (это особенность сравнения даты-времени в PostgreSQL). Опять же - мы просто перестраховались.

    Спасибо, мы вам перезвоним. Вы потеряли индекс по дате, условие стало non-sargable.

    вдруг там еще какие-то значения могут быть

    Даже комментировать не буду.


    1. FanatPHP
      27.07.2022 22:05

      Я правильно понимаю, что к дате надо было приводить сами граничные значения?


    1. ITResume Автор
      28.07.2022 10:18
      -2

      Мы ждали подобных комментариев) В них рождается истина))

      А про "комментировать не буду" - если можно, все-таки прокомментируйте)


      1. wadeg
        28.07.2022 20:35
        +2

        Ничего там зародиться не может, это просто детская безграмотность. Вы взялись за тему, в которой не понимаете абсолютно ничего, даже как работает индексный поиск.

        Комментировать различие между БД и помойкой ("вдруг там еще какие-то значения могут быть") тем более не планировал.


        1. ITResume Автор
          29.07.2022 12:00
          -1

          Вы очень счастливый человек, если работаете только в тех компаниях, где БД не помойка, а круто спроектированная штука. Жаль, что в 99% случаев это не так.

          Хотя, тогда странно, что вы такой не дружелюбный, раз на работе все так чудесно. Мы вроде до оскорблений не скатывались :)


          1. wadeg
            29.07.2022 12:11
            +2

            Вы очень счастливый человек, если работаете только в тех компаниях, где БД не помойка, а круто спроектированная штука. Жаль, что в 99% случаев это не так.
            Где неизвестнодаже содержимое полей — 99%? Вы и тут чепуху пишете и упираетесь.

            Мы вроде до оскорблений не скатывались
            Тут нет вообще никаких оскорблений. А есть толстый намек, что писать в сообщество разработчиков, не имея даже начальных представлений о предмете — очень, очень нелепо и просто неразумно.


            1. ITResume Автор
              29.07.2022 19:09
              -1

              1. Это не наша база. Дана картинка с табличками - решите тестовое. Причем тут вообще мы :)

              2. Если ваши представления настолько глубоки, что вы сходу можете сказать незнакомым людям, что "у них нет даже начальных представлений о предмете" - так может быть вы поделитесь свои опытом и напишете какую-нибудь статейку на тему? :) А то у вас статей 0, а такие "дилетанты" как мы вынуждены заполнять инфополе своим контентом)


              1. wadeg
                29.07.2022 19:37

                Вынуждаете повторяться. Если текст показывает, что незнакомые люди даже не понимают, что такое индексный поиск, то эти незнакомые люди вообще не представляют самых основ предмета, о которым эти незнакомые люди еще и зачем-то пишут на ресурсе, где предмет знаком многим. И да, это можно сказать сходу.

                А то у вас статей 0, а такие «дилетанты» как мы вынуждены заполнять инфополе своим контентом)
                Да, это действительно очень плохо. Но да, я статей лучше, чем, например, Брент Озар, Пол Рэндал или, если местами сразу по-русски, то Дмитрия Пилюгина — не напишу. И нет, это совсем не означает, что инфополе нужно гхм… «заполнять» абсолютно безграмотными статейками, лишь бы накрутить свою упоминаемость.


  1. Xander_d
    27.07.2022 21:12

    А потому что оператор between потеряет все записи за сегодня, если не преобразовать эти поля в дату (это особенность сравнения даты-времени в PostgreSQL). Опять же - мы просто перестраховались.

    Если уж перестраховываться, то лучше привести Now к концу суток (или вместо Now тупо задать 9999 год, как удобнее. Но тут возникает вопрос, не может ли оказаться во входящих данных будущее время).

    А если развить мысль, то, при условии нормальных данных, зачем там вообще верхняя граница?

    Отдельный вопрос, насколько правильно считать данные за неполный месяц. Вдруг там внутри месяца есть своя динамика, и основная активность приходится на конец месяца? Тогда ваш MAU будет занижен...


    1. grobitto
      27.07.2022 21:52

      Одна дата у кастомера тоже вызывает вопросы - если ставится дата первого обращения, то мау не посчитать, если дата последнего - имеет смысл только мау за последний некалендарный месяц (если кастомер может обращаться в компанию несколько раз)


      1. Xander_d
        27.07.2022 21:58

        Почему "одна"? Я так понял, что это таблица всех звонков (call_id) с таймстэмпом начала и окончания.


        1. grobitto
          28.07.2022 08:08

          Таблицы не было в схеме, ок

          Все равно большие вопросы - использование неполного текущего месяца потянет среднее вниз, и баг с тем, что используется только номер месяца, и если данные за несколько лет то все одинаковые месяцы склеятся в один


  1. HappyGroundhog
    27.07.2022 22:43
    +1

    Кстати насчет «MAU всегда должен быть одним числом» хотелось бы поспорить.

    В результате вашего запроса получается та самая «средняя температура по больнице».

    В случае столбца Месяц — MAU видна динамика продукта, например,

    Январь — 5000
    Февраль — 7000
    Март — 2000
    Апрель — 13000
    Май — 15000

    По этой динамике можно увидеть дыру в марте, связанную, например, с неудачным продуктовым решением в виде монетизации. А так же виден рост количества активных пользователей.

    Если же брать MAU как среднее за месяцы, то будет 8400. При этом абсолютно не видна динамика, может это уже остатки от прежних 5000 или наоборот, была 1000, а стало 8400 и это хорошо! Как абсолютное число смысл имеет, но небольшой.


    1. ITResume Автор
      28.07.2022 10:20

      Так мы поэтому и сказали, что это вообще далеко не единственный способ. Медиана, MAU за отдельные месяцы - все это имеет смысл сравнивать. Тогда что-то плюс-минус разумное можно получить.


  1. SergeiMinaev
    28.07.2022 00:46

    Во второй задачке я бы вместо case использовал filter, который как-то логически кажется более уместным. Но могу ошибаться.


    1. ITResume Автор
      28.07.2022 10:20

      Вариант, но не для всех СУБД же. Но постгря - так что норм)


  1. uaggster
    28.07.2022 09:40
    +1

    select 
    	concat_ws('—', first_nm, middle_nm, last_nm) as fio, 
    	birth_dt
    from employees

    Интересно, а как в постгресс ведет себя эта функция, если один из операндов - Null или пустая строка? (Я просто не знаю). Для MSSQLSERVER - все будет гораздо вычурнее:

    select 
    	Stuff(concat('—' + Nullif(first_nm, ''), '-' + Nullif(middle_nm, ''), '-' + Nullif(last_nm,'')), 1, 1, '') as fio, 
    	birth_dt
    from employees

    У человека может не быть отчества, фамилии или даже имени. В любом сочетании.

    select
    	start_dttm::date as "date", 
    	count(case when dozv_flg=1 then 1 end) /
      	count(case when dozv_flg in (1, 0) then 1 end) as sla
    from calls
    where start_dttm::date between '2020-10-01' and now()::date
    group by start_dttm::date

    Не знаю, как в постгрессе (опять же, не специалист), в MSSQLSERVER так делать категорически нельзя. Я про where start_dttm::date

    Это не саргабельно. В случае, если по start_dttm есть индекс, ОН НЕ БУДЕТ ЗАДЕЙСТВОВАН, ну, или будет задействован в режиме полного сканирования. Так что но, найн, нихт, отрывать руки по самый афедрон! Еще один момент: Функция count возвращает результат в int. Поделив int на int (а деление будет ЦЕЛОЧИСЛЕННЫМ, т.к. оба операнда - целые) - вы наверняка получите не то, что нужно.
    Выглядеть должно так (в MSSQLSERVER):

    select
    	Convert(date, start_dttm) as "date", 
    	(count(case when dozv_flg=1 then 1 end) * 1.0) /
      	count(case when dozv_flg in (1, 0) then 1 end) as sla
    from calls
    where start_dttm >= '20201001' and start_dttm <= Convert(date, Dateadd(day, 1, Current_timestamp))
    group by Convert(date, start_dttm)

    Как то так, остальные разборы разберу потом :-)


    1. ITResume Автор
      28.07.2022 10:22

      Про саргабельность - кажется, это тема заслуживает отдельной статьи. Уверены, что на эту позицию вполне допустимо такого не знать - по уровню заданий это понятно.

      Но отличная мысль для следующего материала)


    1. uaggster
      28.07.2022 10:27

      Вот так, конечно же start_dttm < Convert(date, Dateadd(day, 1, Current_timestamp)). Не вычитал, прошу прощения.


    1. lisper
      28.07.2022 14:09

      concat_ws завезли в MS SQL 2017 (https://docs.microsoft.com/en-us/sql/t-sql/functions/concat-ws-transact-sql).


    1. oxff
      29.07.2022 17:26

      Всё хорошо с null.

      concat_ws('-', 'A', 'B', null, 'D') вернёт 'A-B-D'


  1. oleg-petrovets
    28.07.2022 10:21

    а по фронтенду тоже разбираете?


    1. ITResume Автор
      28.07.2022 10:21

      Еще не было)


  1. md_backend_binance
    28.07.2022 10:21

    А можно психотип человека кто идет после всего в теперь гос компанию? )


    1. ITResume Автор
      28.07.2022 10:21

      Ну это не свежее тестовое, на самом деле.


  1. uaggster
    28.07.2022 10:23
    +1

    В следующем кейсе - явная ошибка, причем не технического плана, а именно ошибка реализации:

    with a as (
    	select 
    		to_char(calendar_dt, 'MM') as mon, 
    		count(distinct id) as cnt
    	from clients 
    	group by mon
    )
    select avg(cnt) as mau
    from a

    Дело в том, что в таблице clients могут быть данные ЗА РАЗНЫЕ ГОДЫ.

    И тогда ваш запрос вернет хрень (простите мой французский).

    Запрос должен выглядеть как:

    with a as (
    	select 
    		month(calendar_dt) as mon, 
    		count(distinct id) as cnt
    	from clients 
    	group by year(calendar_dt), month(calendar_dt)
    )
    select avg(cnt) as mau
    from a

    Ну, и не забываем, что среднее от int - будет int. А то вдруг вы там десятые ждете? Тогда avg(cnt * 1.0), ну, или явное преобразование к какому либо плавающему или фиксированному типу.


    1. Akina
      28.07.2022 14:23
      +1

      не забываем, что среднее от int - будет int.

      Зависит от СУБД. Скажем, в PostgreSQL это не так. https://dbfiddle.uk/?rdbms=postgres_14&fiddle=8b050e6e76058df1b23f3b8e498f0fd4


    1. EvilangelMD
      29.07.2022 11:22

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


      1. NikolasSumrak
        30.07.2022 08:55

        Потому что результатом выполнения этого запроса будет одно число, месяцы вы там не увидите


  1. Markscheider
    28.07.2022 11:37

    Условие

    Дана таблица clinets:

    - id клиента

    - calendar_at - дата входа в мобильное приложение...

    Либо у меня приступ временной слепоты, либо я чего-то не понимаю. В схеме данных НЕТ таблицы clients. По полям похожая - только system...

    ---

    Ну и резануло то, что в тексте задания расхождения по сравнению с кодом (clinets вместо clients, calendar_at вместо calendar_dt).

    Или это дополнительный уровень проверки?


  1. Akina
    28.07.2022 12:53
    +2

    Задача 1 ...

    Есть ещё проблема, которую Вы просмотрели. Видите ли, бывают люди, не имеющие отчества. В таблице в этом поле может оказаться NULL. И если использовать CONCAT() или оператор конкатенации, то некоторые СУБД вернут итогом NULL (что очевидно неверно), а некоторые отнесутся к нему как к пустой строке, и в конце итогового значения получится ни к чему не пристёгнутый дефис. В отличие от CONCAT_WS(). А если в поле может в описанном случае оказаться пустая строка - то потребуется ещё и NULLIF(). Ну или CASE - для универсальности.

    Кстати, PostgreSQL в этом случае вообще оригинал - функция конкатенации CONCAT() и оператор конкатенации || ведут себя по-разному. Для многих это оказывается сюрпризом - иногда неприятным.

    Задача 2

    Мы просто перестраховались — вдруг там еще какие-то значения могут быть. Например, 2.

    По-моему, условие чётко устанавливает, что для любого поступившего звонка значение поля может быть или 0, или 1 (или звонок принят, или нет). Никаких 2, никаких NULL и иных значений там быть не может, если считать условие задания верным. А поскольку непоступившие звонки в таблице присутствовать не могут, то насчёт перестраховки - это Вы совершенно безосновательно придумали.

    И, если принять, что условие не содержит ошибок, CASE вообще становится не нужен, а процент считается простейшим SUM(dozv_flg)/COUNT(dozv_flg). Либо SUM(dozv_flg)/COUNT(*). Что выбрать - опять зависит от конкретной СУБД, в некоторых эти выражения могут дать разную итоговую производительность запроса.

    where start_dttm::date between '2020-10-01' and now()::date

    А это "прощай производительность". Правильно - не приводить поле к дате, чтобы можно было использовать красивый BETWEEN, а where start_dttm >= '2020-10-01' and start_dttm < CURRENT_DATE + INTERVAL '1 day'.


  1. lokks
    28.07.2022 19:57

    " — " в условии первого задания окружено пробелами (Имя - Фамилия - Отчество), в результате же будет без них: Имя-Фамилия-Отчество.
    Ну и да, от нулов не перестраховались.


  1. FoxisII
    29.07.2022 16:14
    -1

    По поводу названий

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

    а в некоторых случаях плюнул и назвал колонки по Русски - что бы аналитики не приставали с вопросами - А где мне взять то-то ? А что здесь ?

    :) вот такой лайф хак :)