Загрузка данных
SQL с нуля
SQL - язык запросов к таблицам. Таблица - набор строк одного вида с одинаковыми полями: одна строка - одно обращение в поддержку или один сотрудник.
Главное отличие от Python: в запросе описываешь, что хочешь получить, а не как это обойти. Ты не пишешь цикл по строкам - ты говоришь “сгруппируй по каналу и посчитай количество”, а база сама решает, как читать данные. То, что на Python занимает 30 строк со словарями, в SQL занимает 3.
Сегодня тебе хватит шести конструкций и одного приёма. Всё, что ниже раздела "Для бонуса", для обязательных заданий не нужно.
Как запускать запросы: пошагово
Что вообще происходит
Мы работаем с SQLite - это база данных, которая умеет жить без отдельного сервера. Модуль sqlite3 для работы с ней уже входит в Python - ставить ничего не нужно.
Каждый раз, когда ты запускаешь любой скрипт проекта, происходит одно и то же:
Скрипт src/db.py создаёт пустую базу в памяти.
Загружает в неё оба файла из data/: получаются таблицы agents и tickets.
Строит из них таблицу clean (очищенные обращения) и таблицу razmetka (обращения плюс твоя разметка из src/razmetka.csv).
Выполняет твои запросы и печатает результат.
Когда скрипт заканчивает работу, база исчезает.
Отсюда два следствия. Сломать данные запросом нельзя: при следующем запуске всё соберётся заново из файлов. И любое изменение в твоих файлах - например, в razmetka.csv - видно сразу при следующем запуске.
Шаг 1. Открой терминал
Ты уже отлично знаешь, что это. Нет? Спроси у пиров вокруг.
Шаг 2. Перейди в корень репозитория
$ cd путь/к/папке/проекта # cd - перейти в папку
$ pwd # pwd - показать, в какой папке ты сейчас
$ ls # ls - показать, что лежит в папке
CHANGELOG README.md check-list.yml data materials src testing
Если ls показывает README.md, src и data - ты в корне. Если ты склонировал репозиторий командой git clone …, папка проекта появилась там, где ты эту команду выполнял, и называется так же, как репозиторий.
Шаг 3. Проверь, что всё работает
$ python3 --version
Python 3.11.15 (или выше)
$ python3 src/db.py
agents: 20 строк
tickets: 7033 строк
clean: 6760 строк
razmetka: 6760 строк
python3 - программа, которая выполняет код на Python.
src/db.py - какой файл ей выполнить. Путь считается от папки, где ты сейчас находишься, поэтому команда работает только из корня.
Если видишь четыре строки с числами - база собирается, можно работать. Номер версии Python может быть другим, главное - чтобы он начинался с 3.
Шаг 4а. Запросы в .py-файле - задания 1 и 2
Файл открываешь в любом текстовом редакторе: VS Code, gedit, nano - что установлено на твоём компьютере. Вот минимальный скрипт, разберём его построчно:
from db import connect
con = connect()
for row in con.execute("SELECT login, team FROM agents LIMIT 3"):
print(row)
print(con.execute("SELECT COUNT(*) FROM agents").fetchone())
Строка
Что она делает
from db import connect
Берёт функцию connect из файла src/db.py. Скрипт лежит в той же папке src/, поэтому Python этот файл находит.
con = connect()
Собирает базу со всеми таблицами (шаги 1–3 из "Что вообще происходит"). В переменной con - соединение с базой: через него отправляются все запросы.
for row in con.execute("SELECT …"):
con.execute(…) отправляет в базу текст запроса и возвращает результат - строку за строкой. Цикл for перебирает эти строки.
print(row)
Печатает очередную строку результата.
print(con.execute("SELECT COUNT(*) …").fetchone())
fetchone() забирает из результата одну, первую строку. Удобно, когда ответ - одно число.
Чтобы попробовать, сохрани эти пять строк в файл src/proba.py и запусти его из корня:
Сейчас файла нет, нужно создать nano src/proba.py
$ python3 src/proba.py
('ivan_p', 'дневная смена')
('anna_k', 'дневная смена')
('pavel_m', 'дневная смена')
(20,)
src/proba.py - файл для проб: коммитить его не нужно.
Каждая строка результата приходит кортежем - поэтому в выводе скобки.
Кортеж - это несколько значений в круглых скобках через запятую. Он похож на список, только его нельзя изменить. В кортеже лежат значения полей строки - в том порядке, в каком ты перечислил поля после SELECT:
SELECT login, team … → ('ivan_p', 'дневная смена'): первое значение - логин, второе - смена;
SELECT COUNT(*) … → (20,): кортеж из одного значения. Запятая перед скобкой - не опечатка: так Python отличает кортеж из одного значения от обычного числа в скобках.
Достать отдельное значение можно по номеру, начиная с нуля: row[0] - первое, row[1] - второе. Если нужно само число без скобок: con.execute("SELECT COUNT(*) FROM agents").fetchone()[0] вернёт 20.
В заданиях 1 и 2 печатай результат как есть, кортежами - так сравнивать вывод с ожидаемым проще всего.
Как записывать запрос внутри Python:
Текст запроса - строка Python в двойных кавычках: "SELECT …". Текстовые значения внутри запроса - в одинарных: "SELECT login FROM agents WHERE team = 'ночная смена'".
Длинный запрос удобно писать на нескольких строках, в тройных кавычках:
query = """
SELECT team, COUNT(*)
FROM agents
GROUP BY team
"""
for row in con.execute(query):
print(row)
В ex01.py база собирается не через connect(), а вручную - прямо в начале файла. Так видно, как CSV-файл превращается в таблицу. Начиная с ex02.py - через connect().
Шаг 4б. Запросы в .md-файле - задания 3-7
В заданиях 3-6 и 8 ты не пишешь код на Python. Ответы и запросы живут в markdown-файле, а выполняет их готовый скрипт run_sql.py.
Где писать. Открой, например, src/ex03.md в редакторе. Каждый вопрос выглядит так:
### 1. Сколько строк в `tickets` …?
**Ответ:** 7033 строки, 6760 разных номеров, в clean 6760.
```sql
SELECT COUNT(*) FROM tickets;
SELECT COUNT(DISTINCT id) FROM tickets;
SELECT COUNT(*) FROM clean;
```
Заголовок ### - вопрос, он уже есть в шаблоне.
**Ответ:** - твой ответ словами и числами.
Между строкой ```sql и закрывающей ``` - запросы. Их может быть несколько: каждый заканчивается точкой с запятой ;.
Всё, что вне блока ```sql, скрипт не выполняет - там пиши пояснения.
Как запустить:
$ python3 src/run_sql.py src/ex03.md
python3 src/run_sql.py - запусти скрипт, который выполняет запросы;
src/ex03.md - из какого файла их брать.
Что делает скрипт: собирает базу (как connect()), подключает твои функции MEDIAN и unicode_lower из ex02.py, находит в файле все блоки ```sql и выполняет их сверху вниз. Для каждого печатает заголовок вопроса, твою строку "Ответ", сам запрос, стрелку и результат:
======================================================================
1. Сколько строк в `tickets`, сколько в ней разных номеров обращений и сколько строк в `clean`? Куда делись строки?
**Ответ:** 7033 строки, 6760 разных номеров, в clean 6760.
----------------------------------------------------------------------
SELECT COUNT(*) FROM tickets;
→
(7033,)
---------------------------------------------------------------------
SELECT COUNT(DISTINCT id) FROM tickets;
→
(6760,)
----------------------------------------------------------------------
SELECT COUNT(*) FROM clean;
→
(6760,)
Сразу видно, совпадает ли число в "Ответе" с тем, что дал запрос. Точно так же твой файл запустит проверяющий.
Если файл длинный, а нужен один вопрос, добавь в конце кусок его заголовка в кавычках - покажутся только блоки под этим заголовком:
$ python3 src/run_sql.py src/ex05.md "Сценарии"
Если в шаблоне ещё нет ни одного запроса, скрипт так и скажет: "Все блоки sql пока пустые - впиши запросы". Если у результата больше 30 строк, покажутся первые 30 и строка "… ещё N строк".
Шаг 4в. Попробовать один запрос, не записывая его в файл
Удобно, чтобы поискать ответ, прежде чем вписывать запрос в .md:
$ python3 src/run_sql.py -e "SELECT COUNT(*) FROM clean"
(6760,)
-e - "выполни этот запрос";
сам запрос - в двойных кавычках, текстовые значения внутри - в одинарных: -e "SELECT COUNT(*) FROM clean WHERE period = 'после'";
несколько запросов - через точку с запятой.
Стрелка вверх ↑ в терминале возвращает предыдущую команду - её можно поправить и запустить снова, не набирая заново.
Если что-то пошло не так
Что видишь
Что это значит
Что делать
ОШИБКА: no such table: clen
нет такой таблицы - опечатка в имени
проверь имя: agents, tickets, clean, razmetka
ОШИБКА: no such column: topik
нет такого поля - опечатка
список полей - в начале src/db.py
ОШИБКА: near "FROM": syntax error
запрос записан неправильно рядом с указанным словом
чаще всего пропущена скобка, запятая или кавычка перед этим словом
FileNotFoundError: … 'data/agents.csv'
скрипт запущен не из корня репозитория
cd .. до папки с README.md, потом запусти снова
can't open file '…/src/src/ex01.py'
ты уже внутри src/, а путь снова начинается с src/
вернись в корень: cd ..
MEDIAN(…) возвращает (None,)
в ex02.py ещё не дописан класс Median
доделай задание 2 или посчитай пока через AVG
команда думает и не заканчивается
запрос слишком тяжёлый или ждёт ввода
нажми Ctrl + C - это прервёт программу
Одна ошибка не останавливает остальные: run_sql.py печатает ОШИБКА и переходит к следующему запросу.
Шаг 5. Сохрани результат в GitLab
Когда вопрос или задание готовы - сохрани их коммитом и отправь в GitLab:
$ git status # что изменилось
$ git add src/ex03.md # какие файлы положить в коммит
$ git commit -m "паспорт: вопросы 1–3" # сохранить с понятным сообщением
$ git push origin develop # отправить в GitLab, в ветку develop
В задании 4 разметку коммить отдельно, раньше результатов задания 5: git add src/razmetka.csv src/ex04.md и git commit -m "разметка".
Таблицы в базе
Таблица
Что в ней
agents
справочник сотрудников, как в файле
tickets
выгрузка обращений, как в файле, со всей грязью
clean
те же обращения после чистки коллеги, с готовыми полями: period, written, first_reply_min, came_back и другими
razmetka
clean плюс поле blok из твоей таблицы-решения src/razmetka.csv (задание 4)
Полное описание полей - в начале src/db.py.
Шесть конструкций
1. SELECT … FROM … - взять поля из таблицы
SELECT login, hired_at FROM agents; -- два поля, все строки
SELECT * FROM clean LIMIT 5; -- все поля, первые 5 строк
LIMIT n - показать только первые n строк. Удобно, чтобы посмотреть, что лежит в таблице.
2. WHERE - оставить только подходящие строки
SELECT login FROM agents WHERE team = 'ночная смена';
SELECT COUNT(*) FROM clean WHERE period = 'после' AND written = 1;
SELECT COUNT(*) FROM clean WHERE channel IN ('чат', 'почта');
SELECT COUNT(*) FROM clean WHERE agent_id IS NULL;
SELECT COUNT(*) FROM tickets WHERE created_at LIKE '__.__.____%';
Сравнения: =, !=, <, >, <=, >=. Текст - в одинарных кавычках: 'после'.
AND - оба условия, OR - хотя бы одно.
IN ('а', 'б') - значение из списка.
IS NULL - значения нет (про NULL - ниже).
LIKE - похоже на шаблон: _ - ровно один любой символ, % - сколько угодно символов. '__.__.____%' - "две цифры, точка, две цифры, точка, четыре цифры, дальше что угодно".
3. ORDER BY - отсортировать
SELECT login, hired_at FROM agents ORDER BY hired_at; -- по возрастанию
SELECT login, hired_at FROM agents ORDER BY hired_at DESC; -- по убыванию
SELECT topic, COUNT(*) FROM clean GROUP BY topic ORDER BY 2 DESC; -- по второму полю результата
4. COUNT, SUM, AVG, MIN, MAX, ROUND - посчитать
SELECT COUNT(*) FROM clean; -- сколько строк
SELECT COUNT(DISTINCT client_id) FROM clean; -- сколько разных значений
SELECT SUM(came_back) FROM clean; -- сумма
SELECT ROUND(AVG(handle_min), 1) FROM clean; -- среднее, округлённое до 1 знака
SELECT MIN(created), MAX(created) FROM clean; -- самое раннее и самое позднее
SELECT MEDIAN(handle_min) FROM clean; -- медиана - твоя функция из задания 2
Арифметику можно делать прямо в запросе: SELECT MAX(num) - COUNT(*) FROM clean.
AVG, SUM, MIN, MAX и MEDIAN пропускают NULL: у обращений, которые закрыл бот, handle_min пустой, и в среднее они не попадут.
5. GROUP BY - посчитать отдельно по каждой группе
SELECT team, COUNT(*) FROM agents GROUP BY team;
SELECT period, written, ROUND(AVG(first_reply_min), 1) FROM clean GROUP BY period, written;
Всё, что стоит в SELECT рядом с COUNT/AVG, должно стоять и в GROUP BY.
6. HAVING - оставить только нужные группы
SELECT team, COUNT(*) FROM agents
WHERE hired_at < '2024-01-01' -- WHERE: отбор строк ДО группировки
GROUP BY team
HAVING COUNT(*) >= 3; -- HAVING: отбор групп ПОСЛЕ подсчёта
WHERE не может использовать COUNT(*): в момент, когда он работает, групп ещё нет.
Приём: доля - это AVG от условия
В SQLite условие даёт 1 (да) или 0 (нет). Среднее из единиц и нулей - это доля единиц. Умножь на 100 - получишь процент:
SELECT ROUND(AVG(team = 'ночная смена') * 100, 1) FROM agents; -- 25.0: каждая четвёртая строка - ночная смена
SELECT blok, ROUND(AVG(bot_result = 'закрыл') * 100, 1) -- доля закрытых ботом
FROM razmetka WHERE period = 'после' AND written = 1 GROUP BY blok;
SELECT ROUND(AVG(reopened = 0 AND came_back = 0) * 100, 1) FROM clean; -- доля обращений без переоткрытия и без возврата
Почти все запросы заданий 4–5 устроены по одному шаблону:
SELECT <по чему делим>, ROUND(AVG(<условие>) * 100, 1)
FROM razmetka
WHERE <кого сравниваем>
GROUP BY <по чему делим>;
Пустая строка, NULL и текст "NULL"
Пустая строка не равна отсутствию значения. NULL означает "значения нет". Пустая строка '' - это значение, просто пустое. Условия поле IS NULL и поле = '' ловят разные строки. В tickets пустые ячейки лежат пустыми строками, в clean коллега часть из них превратил в NULL.
Сравнение с NULL не возвращает ни "да", ни "нет". Оно возвращает "неизвестно", и строка не проходит фильтр - ни при = 'значение', ни при != 'значение'. Ошибки нет, строк меньше, никто не замечает.
Текст 'NULL' - это четыре буквы, а не отсутствие значения. SQL его никак не отличает от любого другого слова.
Свои функции
Встроенных функций SQLite не всегда хватает. Недостающие можно написать на Python и зарегистрировать в базе.
Обычная функция получает одно значение и возвращает одно значение - вызывается для каждой строки:
def double(x):
if x is None: # в поле NULL - в Python придёт None
return None
return x * 2
con.create_function("double", 1, double) # имя в SQL, число аргументов, функция
con.execute("SELECT double(21)").fetchone() # (42,)
Агрегатная функция получает много значений (все строки группы) и возвращает одно - как COUNT или AVG. Её пишут классом из двух методов: step вызывается для каждой строки группы, finalize - один раз в конце. Класс - просто "коробка", где между вызовами хранятся накопленные значения. Пример - функция, которая считает, сколько в группе непустых значений:
class CountFilled:
def __init__(self):
self.n = 0 # то, что копим между вызовами
def step(self, value): # SQLite вызывает для каждой строки
if value not in (None, ""):
self.n += 1
def finalize(self): # SQLite вызывает один раз в конце
return self.n
con.create_aggregate("count_filled", 1, CountFilled)
con.execute("SELECT team, count_filled(hired_at) FROM agents GROUP BY team").fetchall()
MEDIAN из Задания 2 устроена так же: step собирает значения в список, finalize возвращает медиану.
Для бонуса
Этого нет в обязательных заданиях. Для Задания 8 понадобятся инструменты SQLite, которых нет в основном наборе. Как именно их применить - разберись сам: документация SQLite, поиск и ИИ сегодня доступны.
CASE WHEN … THEN … ELSE … END — условие внутри запроса, как if;
substr(текст, с какого символа, сколько символов) и || — вырезать кусок текста и склеить строки;
CAST(… AS INTEGER) — превратить текст в число;
NULLIF(a, b) — получить NULL, если a равно b;
julianday(дата) — превратить дату в число дней, чтобы считать интервалы;
JOIN … ON — соединить две таблицы по общему полю;
EXISTS (подзапрос) — проверить, есть ли для строки подходящая строка в другой или той же таблице;
CREATE TABLE … AS SELECT … и CREATE INDEX — сохранить результат таблицей и ускорить поиск по полю.
Чего SQL не делает
Графики, нестандартные функции, разбор совсем кривых форматов - остаются на Python. Рабочая схема на сегодня: SQL считает числа, Python добавляет функции и рисует.