Місяць 1 · Заняття 04

Навіщо база даних, якщо є текстовий файл

Спроєктувати таблиці свого продукту, створити їх у базі, додати перші справжні записи й віддати їх назовні окремою адресою, яку зможе відкрити будь-яка програма.

90 хвилин розбір 10 хв теорія 20 хв практика 45 хв публікація 10 хв підсумок 5 хв

Що ти зробиш сьогодні

Ти опишеш на папері таблицю свого продукту — які в ній поля, які з них обов’язкові, який тип у кожного, — а потім створиш цю таблицю в базі даних однією командою CREATE TABLE. У базу ти додаси п’ять справжніх записів свого сервісу.

Після цього твоє API навчиться віддавати ці записи. Одна адреса API називається ендпоінт. Дані за нею передають у форматі JSON: це текст, у якому один об’єкт записаний у фігурних дужках парами «назва»: значення, а список об’єктів — у квадратних дужках через кому. За адресою на кшталт /u/<логін>/api/habits будь-хто з інтернету отримає такий текст із твоїми записами.

Наприкінці автоперевірка порахує записи в базі, порахує об’єкти у відповіді й порівняє два числа — вони мають збігтися.

Теорія

1. Текстовий файл і місце, де він перестає працювати

Дані сервісу справді можна зберігати у звичайному текстовому файлі: один рядок — один запис, поля через кому. Для двадцяти рядків це працює, і для першої спроби так роблять. Проблеми починаються далі, і їх п’ять.

  1. Пошук. Щоб знайти один запис, програма читає файл від початку до кінця й розбирає кожен рядок на частини. У файлі на 50 000 рядків із п’ятьма полями це чверть мільйона дій на кожен запит — і так щоразу, коли хтось відкриває сторінку.
  2. Зміна одного запису. Замінити рядок посеред файлу неможливо, якщо новий рядок іншої довжини: доводиться переписувати весь файл. Якщо програма зупиниться посеред перезапису, від файлу лишиться половина.
  3. Двоє одночасно. Через кілька занять твій сервіс буде відкритий для інших людей. Якщо два запити пишуть у файл в одну й ту саму секунду, частина одного рядка потрапляє всередину іншого, і прочитати такий файл уже не вдається.
  4. Немає перевірок. У файл можна записати запис без назви, з датою «завтра ввечері» і з текстом там, де мало бути число. Ти помітиш це через місяць, коли рахуватимеш підсумки.
  5. Питання до даних. «Скільки записів за минулий тиждень», «останні п’ять», «найдовший за часом» — у файлі кожне таке питання це окремий код на Python.

База даних — програма, яка зберігає дані у вигляді таблиць і відповідає на запити до них. На кожен із цих п’яти пунктів у бази є відповідь:

Ми беремо SQLite — базу даних, яка тримає все в одному файлі на диску й не потребує окремої служби. Служба — це програма, яку операційна система запускає сама й тримає ввімкненою у фоні, поки працює сервер. Іншим базам даних така програма потрібна, SQLite обходиться без неї. Твоя база лежатиме у файлі ~/data/app.db. Саме через це її зручно копіювати й відновлювати: на занятті 11 ти робитимеш із неї резервні копії звичайною командою копіювання файлу.

Твій сервіс зберігає записи в текстовому файлі. Записів стало 40 000, і сторінка, яка показує записи за один вибраний день, відкривається чотири секунди. Що зміниться, якщо перекласти ті самі дані в базу?

2. Таблиця, рядок, стовпець і що таке схема

Дані в базі лежать у таблицях. Розберемо три слова, які далі будуть у кожному реченні:

Приклад, який ти бачив: журнал успішності. Стовпці там — прізвище, дата, оцінка. Рядок — одна виставлена оцінка. Кожен рядок має ті самі стовпці, що й решта; порожні клітинки бувають, а от «у цього учня є ще один стовпчик, якого нема в інших» — не буває. Це і є головна властивість таблиці.

Схема — повний перелік таблиць твого продукту, їхніх стовпців, типів і правил. Схему пишуть до того, як створювати таблиці: переробити її потім, коли в базі вже є дані, значно складніше.

Назву таблиці пиши в однині, латиницею, малими літерами: habit, game, plant. Латиниця тому, що ця назва потрапляє в кожен запит і в код програми. Однина тому, що один рядок — це одна звичка.

Одна з таблиць у тебе головна — та, записи якої показує список сервісу. Її назву ти запишеш у project.yaml: це файл-опис твого проєкту, у якому зібрані всі його назви й адреси, і такий файл називають маніфестом. Саме цю таблицю рахуватиме автоперевірка.

ПродуктГоловна таблицяСтовпці
Щоденник звичокhabitid, title, day, minutes, note
Список пройдених ігорgameid, title, platform, finished_on, rating
Догляд за кімнатними рослинамиplantid, name, place, watered_on, days_between

Ти робиш каталог кімнатних рослин. Для кожної рослини треба зберігати назву й місце, а ще історію поливань — кілька дат на одну рослину. Як це розкласти по таблицях?

3. Первинний ключ і заборона порожніх значень

Первинний ключ (англійською primary key) — стовпець, значення якого не повторюється в таблиці й ніколи не буває порожнім. За ним база знаходить конкретний рядок, і за ним на цей рядок посилаються інші таблиці.

Найпростіший і найнадійніший варіант — окремий числовий стовпець id, який база заповнює сама:

id INTEGER PRIMARY KEY AUTOINCREMENT

Наступне число — 1, 2, 3 — база ставить сама вже через INTEGER PRIMARY KEY: тобі не треба ані передавати це значення, ані пам’ятати, яке було останнім. Слово AUTOINCREMENT додає до цього одне правило: номери видалених рядків більше не використовуються повторно. Без нього база може віддати новому рядку номер, який раніше належав видаленому.

Може виникнути думка взяти ключем назву — вона ж і так унікальна. Дві причини цього не робити: назви повторюються (у щоденнику звичок «Пробіжка» буде десятки разів, і це нормально), а ще назву іноді виправляють — після виправлення всі посилання на старе значення стають хибними. Номер id не змінюється ніколи.

Тепер про поля, які лишили незаповненими. NULL — окреме значення, яке означає «сюди нічого не записали». Воно не дорівнює нулю й не дорівнює порожньому рядку: нуль хвилин — це відомий факт, а NULL — відсутність факту.

NOT NULL — правило для стовпця: тут порожнього значення бути не може. Його ставлять на поля, без яких запис не має сенсу. У щоденнику звичок це назва й дата: запис без дати неможливо ані показати в списку, ані відфільтрувати. Якщо спробувати додати рядок без такого поля, база відмовить і поверне помилку NOT NULL constraint failed.

Поруч є ще два правила, які знадобляться далі:

На кроці 2 практики ви з сусідом по парті поставите одне одному питання «а якщо два записи з однаковою назвою?» Правильна відповідь: нічого не станеться, вони різняться значенням id. Якщо ти зробив ключем назву — станеться помилка, і другий запис у базу не потрапить.

У таблиці game ти зробив первинним ключем поле title. Через місяць виявилося, що дві різні гри в тебе записані з однаковою назвою, а ще в одній назві є помилка, яку хочеться виправити. Що буде?

4. Типи даних і чому дату записують саме так

Тип даних — вид значення, яке лежить у стовпці. У SQLite їх небагато:

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

Окремого типу для дати в SQLite немає. Через це дату записують текстом у певному форматі, і саме тут найчастіше помиляються. Формат такий: рік-місяць-день, чотиризначний рік і двозначні місяць та день, 2026-09-06. Причина проста: у такому записі порівняння тексту дає той самий результат, що й порівняння дат. Рядок "2026-09-06" більший за "2026-08-31" і як текст, і як дата, тому сортування й відбір за проміжком часу працюють без жодних перетворень.

Якщо писати 06.09.2026, база порівнюватиме спочатку день. У списку, впорядкованому за такою «датою», 06 вересня опиниться поруч із 06 березня, а відбір «за останній тиждень» доведеться писати самому в Python.

І ще одна особливість саме SQLite: вона не забороняє записати текст у стовпець, оголошений як INTEGER. Тип тут — це домовленість, яку тримає твій код. А ось правила NOT NULL, UNIQUE, PRIMARY KEY і CHECK база перевіряє по-справжньому й порушити їх не дасть. CHECK — умова, яку база перевіряє під час кожного запису, наприклад CHECK (minutes >= 0): від’ємної кількості хвилин після цього в таблиці не з’явиться.

Ти зберігав дати у вигляді 06.09.2026. Тепер треба показати записи за останній тиждень і впорядкувати їх від найновіших. Що станеться з таким форматом?

5. SQL: чотири команди, яких вистачить надовго

SQL — мова запитів до бази. Нею ти кажеш, який результат тобі потрібен; як саме його дістати з файлу, база вирішує сама. Команди традиційно пишуть великими літерами — так їх видно серед назв стовпців; база великі й малі літери в ключових словах не розрізняє.

CREATE TABLE — створити таблицю

CREATE TABLE habit (
    id      INTEGER PRIMARY KEY AUTOINCREMENT,
    title   TEXT    NOT NULL,
    day     TEXT    NOT NULL,
    minutes INTEGER NOT NULL DEFAULT 0,
    note    TEXT
);

У дужках перелічені стовпці: назва, тип і правила. Стовпець note без NOT NULL — необов’язковий, його можна лишити порожнім.

INSERT — додати рядок

INSERT INTO habit (title, day, minutes) VALUES ('Пробіжка 2 км', '2026-09-01', 18);

Спочатку перелік стовпців, потім значення в тому самому порядку. Текст у одинарних лапках, числа без лапок. Стовпець id не вказуємо — база поставить його сама.

SELECT — дістати рядки

SELECT * FROM habit ORDER BY id DESC;
SELECT id, title, day FROM habit WHERE day >= '2026-09-01' ORDER BY day DESC;
SELECT id, title FROM habit ORDER BY id DESC LIMIT 10;
SELECT COUNT(*) FROM habit;

Що тут за що відповідає:

DELETE — видалити рядки

DELETE FROM habit WHERE id = 7;

Без WHERE команда видаляє всі рядки таблиці й нічого не перепитує, тому умову пиши завжди.

Останнє про запити: значення в них ніколи не склеюють із текстом запиту через + або f-рядок. Замість значення ставлять знак питання, а саме значення передають окремим списком:

db.execute("INSERT INTO habit (title, day) VALUES (?, ?)", ("Пробіжка", "2026-09-01"))

Так база отримує текст запиту й дані окремо й ніколи не сприймає дані як команду. Що буває інакше, розберемо на занятті 08, коли перевірятимемо сервіси одне одного на слабкі місця в захисті.

У твоїй таблиці 12 рядків. Ендпоінт списку повертає 10 об’єктів, і автоперевірка пише, що числа не збігаються. Який рядок запиту найімовірніше винен?

6. Як дані з бази потрапляють у відповідь API

Нагадування із заняття 02: ендпоінт — це адреса, за якою сервер щось конкретне відповідає. Сьогодні ти додаєш ендпоінт списку — третій після /api/ping і /api/about.

У теці ~/app уже лежить готовий файл db.py. Його редагувати не треба, але треба знати, що в ньому:

Підключити db.py до свого файлу можна двома способами, і сьогодні тобі трапляться обидва. Рядок import db підключає весь файл, і далі до всього в ньому звертаються через крапку: db.query(...), db.DB_PATH. Рядок from db import query підключає лише одну функцію, і тоді пишуть query(...) — без db і крапки. Перш ніж писати виклик, подивись, який із цих двох рядків стоїть на початку файлу, у якому ти працюєш.

Сам ендпоінт після цього займає три рядки:

@app.get("/api/habits")
def list_items():
    return query("SELECT * FROM habit ORDER BY id DESC")

Рядок, що починається зі знака @ й стоїть просто над функцією, називають декоратором: він прив’язує функцію до адреси. @app.get("/api/habits") читається так: «коли хтось запитає цю адресу методом GET, виклич функцію, написану нижче». GET — вид запиту, яким просять дані; саме такий запит робить браузер, коли ти відкриваєш адресу.

FastAPI — бібліотека, якою написаний твій main.py. Вона зіставляє адресу запиту з потрібною функцією й перетворює те, що функція повернула, на JSON. Наша функція повертає список словників, і у відповіді він стає масивом JSON: квадратні дужки, всередині об’єкти у фігурних дужках через кому.

[{"id": 5, "title": "Читання", "day": "2026-09-04", "minutes": 40, "note": ""},
 {"id": 4, "title": "Пробіжка 2 км", "day": "2026-09-03", "minutes": 17, "note": "дощ"}]

Звідси й береться критерій заняття: кількість об’єктів у цьому масиві дорівнює кількості рядків, які повернув SELECT. Якщо в запиті немає ані WHERE, ані LIMIT, вона дорівнює COUNT(*) усієї таблиці.

Дві речі, без яких критерій не зарахується. Перша: адресу свого ендпоінта треба записати в маніфест project.yaml. Це єдине місце, де вона зафіксована, і саме звідти її беруть і викладач, і скрипти перевірки — самі вони назв не вгадують.

Друга: твій сервіс тримає ввімкненим uvicorn — програма, яка приймає запити з мережі й передає їх функціям із main.py. Файл main.py вона читає один раз, під час запуску, тому після кожної зміни файлу службу треба перезапустити. Службами в Linux керує systemd — частина системи, яка запускає програми у фоні й стежить, щоб вони працювали; команди їй дають через systemctl:

systemctl --user restart club-api

І ще одне, що знадобиться вже в наступному питанні. Крім даних сервер повертає на кожен запит код відповіді — тризначне число, яким він каже, чим усе скінчилося. Сьогодні трапляться три: 200 — усе гаразд, відповідь у тілі; 404 — сервер не знає такої адреси; 500 — адреса є, але функція під час роботи завершилася з помилкою.

Ти дописав ендпоінт списку, зберіг main.py і одразу відкрив адресу в браузері. Сервер відповідає 404. Найімовірніша причина?

Слова цього уроку

СловоЩо означає
база данихПрограма, яка зберігає дані таблицями й відповідає на запити до них
SQLiteБаза, що тримає все в одному файлі й не потребує окремої служби
SQLМова запитів до бази
таблиця, рядок, стовпецьНабір записів одного виду, один запис, одна характеристика
схемаПерелік таблиць, їхніх стовпців, типів і правил
первинний ключСтовпець, значення якого унікальне й ніколи не порожнє; за ним знаходять рядок
INTEGER PRIMARY KEYЧисловий первинний ключ: наступний номер база ставить сама
AUTOINCREMENTДодаткове правило: номери видалених рядків не використовуються повторно
NULLЗначення «нічого не записано». Не нуль і не порожній рядок
NOT NULLПравило: у цьому стовпці порожнього значення бути не може
UNIQUE, DEFAULT, CHECKПравила: значення не повторюється; підставити своє, якщо не передали; умова для кожного запису
тип данихВид значення в стовпці: INTEGER, REAL, TEXT, BLOB
CREATE TABLEСтворює таблицю з описаними стовпцями й правилами
INSERTДодає рядок у таблицю
SELECTДістає рядки; WHERE — умова, ORDER BY — порядок, LIMIT — обмеження кількості
COUNT(*)Рахує кількість рядків
DELETEВидаляє рядки за умовою
транзакціяГрупа дій, яка виконується або повністю, або ніяк
індексСлужбова структура, що дає знайти рядок без перегляду всіх
PRAGMA table_infoЗапит, який показує опис стовпців таблиці
APIНабір адрес, за якими сервер віддає дані іншій програмі, а не сторінку людині
ендпоінтОдна така адреса
JSONТекстовий формат передавання даних: об’єкт у фігурних дужках, список — у квадратних
код відповідіЧисло, яким сервер каже, чим скінчився запит: 200, 404, 500
маніфестФайл project.yaml, у якому записані назви й адреси проєкту
службаПрограма, яку система тримає ввімкненою у фоні. Керує службами systemd, команда — systemctl
портНомер, за яким система розрізняє програми, що приймають запити на одній машині

Практика

Кроки виконуй послідовно. Приклади написані для щоденника звичок і таблиці habit — скрізь підставляй назву своєї таблиці й свої стовпці.

1 Зайти на сервер

ssh ЛОГІН@91.219.61.4

Замість ЛОГІН — свій, наприклад bohdan.g9. Пароль під час введення не показується: ні крапок, ні зірочок. Набирай і натискай Enter.

2 Намалювати схему на папері

Схему пиши на папері до того, як відкриєш редактор: на аркуші видно всю таблицю одразу, і виправити її там швидше, ніж переробляти вже створену. Випиши на аркуші:

  1. назву головної таблиці — латиницею, малими літерами, в однині;
  2. від чотирьох до шести стовпців. Для кожного: назва латиницею, тип (INTEGER чи TEXT), обов’язковий він чи ні;
  3. окремо познач стовпець id — це первинний ключ.

Дві перевірки, які треба пройти до того, як писати код. Перша: запис без якого поля не має сенсу? Ці поля отримують NOT NULL. Друга: у чому вимірюється кожне числове поле — у хвилинах, штуках, гривнях? Одиницю виміру пиши в назві стовпця або в коментарі, інакше через місяць не згадаєш.

Далі поміняйтеся аркушами з сусідом і поставте одне одному два питання:

Якщо на друге питання відповіді немає, у схемі бракує стовпця з датою у форматі 2026-09-06. Додай його зараз, поки таблиці ще не існує.

3 Записати схему в project.yaml

nano ~/app/project.yaml

Це той самий маніфест — файл-опис проєкту, який ти почав заповнювати на занятті 01. Знайди в ньому розділ database і заповни його. Приклад для щоденника звичок:

database:
  file: app.db
  main_table: habit
  main_table_pk: id
  main_table_required: [title, day]
  secondary_tables: []

Що означає кожен рядок:

Зберегти — Ctrl+O, потім Enter. Вийти — Ctrl+X. Пам’ятай про відступи. Цей файл написаний у форматі YAML: у кожному рядку назва, двокрапка й значення, а вкладеність показана відступом зліва. Один рівень вкладеності — два пробіли; табуляція замість пробілів ламає файл.

4 Написати CREATE TABLE

nano ~/app/init_db.py

Файл новий, він буде порожній. Набери в ньому:

"""Створює таблиці. Можна запускати скільки завгодно разів."""
import db

SCHEMA = """
CREATE TABLE IF NOT EXISTS habit (
    id      INTEGER PRIMARY KEY AUTOINCREMENT,
    title   TEXT    NOT NULL,
    day     TEXT    NOT NULL,
    minutes INTEGER NOT NULL DEFAULT 0,
    note    TEXT
);
"""

conn = db.connect()
conn.executescript(SCHEMA)
conn.commit()
print("таблиці на місці, база:", db.DB_PATH)

Розбір коду:

5 Створити таблицю

cd ~/app && /opt/club/venv/bin/python init_db.py

Довгий шлях /opt/club/venv/bin/python — це Python зі спільного оточення групи, у якому вже стоять потрібні бібліотеки. Звичайний python3 їх не побачить.

У відповідь має надрукуватися шлях до бази. Перевір, що файл справді з’явився:

ls -l ~/data/app.db

Файл займе близько 12 кілобайтів — рівно 12288 байтів. Для порожньої таблиці це нормально: база одразу відводить місце під службові структури. Після п’яти записів число буде те саме, і це теж нормально.

6 Подивитися, що вийшло

cd ~/app && /opt/club/venv/bin/python -c 'import db
for r in db.query("PRAGMA table_info(habit)"):
    print(r)'

PRAGMA table_info — службовий запит SQLite, який показує опис стовпців таблиці. Ти побачиш приблизно таке:

{'cid': 0, 'name': 'id',      'type': 'INTEGER', 'notnull': 0, 'dflt_value': None, 'pk': 1}
{'cid': 1, 'name': 'title',   'type': 'TEXT',    'notnull': 1, 'dflt_value': None, 'pk': 0}
{'cid': 2, 'name': 'day',     'type': 'TEXT',    'notnull': 1, 'dflt_value': None, 'pk': 0}
{'cid': 3, 'name': 'minutes', 'type': 'INTEGER', 'notnull': 1, 'dflt_value': '0',  'pk': 0}
{'cid': 4, 'name': 'note',    'type': 'TEXT',    'notnull': 0, 'dflt_value': None, 'pk': 0}

Дивись на дві колонки. pk має дорівнювати 1 рівно в одного стовпця, а notnull — дорівнювати 1 в усіх стовпцях, які ти перелічив у main_table_required. Саме це звірятиме викладач на розборі.

У рядку id стоїть notnull: 0 — так і має бути. Стовпець INTEGER PRIMARY KEY порожнім не буває: якщо значення не передали, база сама ставить наступний номер. З ключем іншого типу так не виходить — у SQLite, наприклад, у стовпець title TEXT PRIMARY KEY порожнє значення записати можна. Це ще одна причина робити ключем числовий id.

7 Додати перші п’ять записів

nano ~/app/seed.py
"""Додає перші записи. Запускати один раз, інакше будуть дублікати."""
import db

rows = [
    ("Пробіжка 2 км",         "2026-09-01", 18, "стадіон біля школи"),
    ("Англійська, слова",     "2026-09-01", 25, "40 нових слів"),
    ("Гітара, вправа на бій", "2026-09-02", 30, ""),
    ("Пробіжка 2 км",         "2026-09-03", 17, "дощ, бігли коротше"),
    ("Читання",               "2026-09-04", 40, "60 сторінок"),
]

for row in rows:
    db.execute(
        "INSERT INTO habit (title, day, minutes, note) VALUES (?, ?, ?, ?)",
        row,
    )

print("додано записів:", len(rows))

Записи мають бути справжні — те, що ти справді робив цього тижня, або справжні дані твого продукту. Рядки «тест1, тест2» тут не підходять: на розборі домашнього завдання викладач читає їх очима, і вигадані дані видно одразу. До того ж на занятті 06 із цими самими записами працюватиме сторонній користувач.

Зверни увагу на знаки питання в запиті. Значення передаються окремим набором — база отримує текст запиту й дані нарізно й ніколи не сприймає дані як частину команди.

8 Запустити й порахувати

cd ~/app && /opt/club/venv/bin/python seed.py

Ця команда має надрукувати додано записів: 5. Якщо замість цього з’явився рядок sqlite3.IntegrityError: NOT NULL constraint failed, у якомусь із рядків списку порожнє обов’язкове поле. У повідомленні після двокрапки вказано, у якому саме стовпці. Виправ рядок і запусти seed.py ще раз.

Тепер порахуємо, що лежить у базі:

cd ~/app && /opt/club/venv/bin/python -c 'import db; print(db.query("SELECT COUNT(*) AS n FROM habit"))'

Ця команда має надрукувати [{'n': 5}]. Якщо там 10 — файл seed.py запустився двічі, і в базі дублікати. Як їх прибрати, написано в розділі «Якщо щось не працює».

9 Написати SELECT із фільтром

nano ~/app/check_db.py
import db

total = db.query("SELECT COUNT(*) AS n FROM habit")[0]["n"]
print("усього записів:", total)

print("за останні 7 днів:")
for row in db.query(
    "SELECT id, day, title FROM habit "
    "WHERE day >= date('now', '-7 days') "
    "ORDER BY day DESC, id DESC"
):
    print("  ", row["id"], row["day"], row["title"])
cd ~/app && /opt/club/venv/bin/python check_db.py

date('now', '-7 days') — функція SQLite, яка повертає сьогоднішню дату мінус сім днів у вигляді тексту 2026-08-30. Порівняння працює саме тому, що твої дати записані в тому самому форматі. Це і є відповідь на друге з двох питань, які ви ставили одне одному на кроці 2.

query повертає список словників, тому до значень звертаємося за назвою стовпця: row["title"].

10 Додати ендпоінт списку

nano ~/app/main.py

Приблизно в середині файлу є закоментований блок із заголовком «ЗАНЯТТЯ 04» — три рядки, кожен починається зі знака #. Нижче в тому самому файлі стоїть ще один такий блок, «ЗАНЯТТЯ 05»; його не чіпай, він для наступного заняття. У трьох рядках блоку «ЗАНЯТТЯ 04» прибери # разом із пробілом одразу після нього й заміни habits та habit на свої назви:

@app.get("/api/habits")
def list_items():
    return query("SELECT * FROM habit ORDER BY id DESC")

Адресу ендпоінта називай за своєю таблицею: /api/games, /api/plants. Тут прийнято множину — за адресою повертається список.

Відступи в Python значущі: перед return має лишитися рівно чотири пробіли, і жодної табуляції. У заготовці рядок записаний як # return ..., тож якщо прибрати самий #, а пробіл після нього залишити, пробілів стане п’ять і Python видасть помилку.

Функція query вже підключена на початку файлу рядком from db import query, тому в main.py її викликають без префікса: query(...), а не db.query(...), як в init_db.py. Рядка import db у цьому файлі немає, і дописувати його не треба.

11 Записати адресу ендпоінта в маніфест

nano ~/app/project.yaml
endpoints:
  ping: "GET /api/ping"
  about: "GET /api/about"
  list: "GET /api/habits"

Заповнюй рядок list — решту не чіпай, вони для наступних занять. Формат такий самий, як у сусідніх рядків: метод, пробіл, шлях. Зверни увагу, що це не готова адреса, яку можна відкрити: у рядку записані два значення підряд, і той, хто читає маніфест, бере з нього другу частину. Маніфест — єдине місце, де адреса твого списку зафіксована; за нею її знаходять і викладач, і скрипти перевірки.

12 Перезапустити службу й перевірити зсередини

systemctl --user restart club-api
systemctl --user is-active club-api

Друга команда друкує стан служби. Потрібне слово — active. Решта відповідей означає ось що:

Тепер запит із самого сервера. Кожна програма, яка приймає запити з мережі, слухає свій порт — номер, за яким система розрізняє такі програми на одній машині. Номер твоєї служби записаний у її файлі налаштувань, у рядку ExecStart після --port. Ця команда дістає його звідти й кладе у змінну PORT; змінна зберігається лише до виходу з ssh, тому після нового входу її задають знову:

PORT=$(grep -oP '(?<=--port )\d+' ~/.config/systemd/user/club-api.service)
echo "порт: $PORT"

Якщо echo надрукував порт: і далі порожньо, номер не знайшовся. Тоді подивись файл очима — cat ~/.config/systemd/user/club-api.service — знайди в рядку ExecStart число після --port і підстав його в наступну команду замість $PORT.

curl -s http://127.0.0.1:$PORT/api/habits

curl — програма, яка робить запит за адресою й друкує відповідь просто в термінал: те саме, що робить браузер, тільки без оформлення. Адреса 127.0.0.1 означає «ця сама машина», тобто запит навіть не виходить у мережу. У відповідь має прийти масив JSON із твоїми п’ятьма записами в один довгий рядок.

13 Якщо служба у стані failed — прочитати журнал

journalctl --user -u club-api -n 30

Читай знизу вгору й шукай перший рядок зі словом Error. Три помилки, які трапляються сьогодні найчастіше:

Виправ, збережи, знову systemctl --user restart club-api. Якщо служба кілька разів поспіль завершилася з помилкою, systemd припиняє спроби запуску: тоді потрібні дві команди — спочатку systemctl --user reset-failed club-api, яка скидає цей стан, потім restart.

14 Перевірити ззовні й порівняти числа

curl -s http://91.219.61.4/u/ЛОГІН/api/habits | /opt/club/venv/bin/python -c 'import sys, json; print("елементів у відповіді:", len(json.load(sys.stdin)))'

Тут curl забирає відповідь із публічної адреси й передає її Python, а той рахує довжину масиву. Це рівно те, що робить автоперевірка. Порівняй число з тим, яке дає база:

cd ~/app && /opt/club/venv/bin/python check_db.py

check_db.py друкує два блоки. Порівнювати треба з першим його рядком — усього записів: це число має збігтися з числом після елементів у відповіді з попередньої команди. Список «за останні 7 днів» тут ні до чого, у ньому записів менше. Якщо числа не збігаються, причина майже завжди в запиті ендпоінта: зайвий LIMIT або зайва умова WHERE.

15 Зберегти зміни в git

cd ~/app
git add -A
git status
git commit -m "Схема бази, перші записи і ендпоінт списку"
git push origin master

Два слова, які тут знадобляться. Коміт — збережений стан твоїх файлів із підписом, що саме змінилося; історія проєкту складається з таких станів. Репозиторій — сховище цієї історії; твоє лежить на сервері в теці ~/repo.git, і команда git push надсилає коміти саме туди. Слово master в останній команді — назва основної лінії історії в твоєму репозиторії. В інструкціях з інтернету на цьому місці часто стоїть main; у нас так не налаштовано, і з main команда відповість src refspec main does not match any.

Команда git status перед комітом показує, що саме потрапить в історію. У списку мають бути init_db.py, seed.py, check_db.py, main.py, project.yaml. Файлу бази там бути не повинно: він лежить у теці ~/data, а git стежить лише за тим, що всередині ~/app. Є й другий запобіжник — файл .gitignore: у ньому переліковано те, що git не бере в історію, навіть якщо воно лежить у потрібній теці. Рядок *.db там є ще із заняття 03. База змінюється щохвилини, і в історії змін коду їй не місце — для неї на занятті 11 буде окремий спосіб зберігання копій.

16 Подивитися кабінет

Відкрий http://91.219.61.4/k/<логін>/. Пункти сьогоднішнього заняття мають стати зарахованими протягом кількох хвилин після наступного прогону перевірки. Якщо якийсь пункт червоний, шукай його код у таблиці нижче.

Що перевіряє автоматика

Чотири пункти. Перший — обов’язковий мінімум заняття: без нього решта не рахується. Останній перевіряє викладач очима на розборі домашнього завдання.

КодКритерійЩо робить скриптЯкщо не зараховано
L04.1 первинний ключ і NOT NULL на обов’язкових полях Бере з project.yaml назву головної таблиці, відкриває ~/data/app.db і перевіряє, що така таблиця в базі справді є. Первинний ключ і NOT NULL на полях зі списку main_table_required звіряє викладач — за виводом PRAGMA table_info з кроку 6. Спершу виконай крок 6 і подивись, що показує PRAGMA table_info — часто виявляється, що в main_table записана не та назва, що в базі. Якщо NOT NULL справді забули, найпростіше зараз, поки записів мало: видалити таблицю й створити заново (команди в розділі нижче).
L04.2 у базі щонайменше 5 записів Виконує SELECT COUNT(*) з головної таблиці й чекає число не менше п’яти. Допиши записи у seed.py і запусти його ще раз — але тільки з новими рядками, інакше попередні додадуться повторно. Перевір результат командою з кроку 8.
L04.3 ендпоінт списку збігається з COUNT(*) головної таблиці Робить запит ззовні на твою публічну адресу /u/<логін>/api/…, рахує кількість об’єктів у масиві JSON і незалежно рахує рядки в базі. Числа мають бути однакові — підставити своє число у відповідь не вийде. Перевір по черзі: чи заповнено endpoints.list; чи немає в запиті LIMIT або WHERE; чи та це таблиця, що вказана в main_table; чи перезапущено службу після зміни main.py. Якщо ендпоінт узагалі віддає 404 — адреса в маніфесті не збігається з адресою в декораторі @app.get.
L04.4 записи справжні, а не «тест1» Автоматика цього не перевіряє. На розборі домашнього завдання викладач вибірково відкриває кілька записів і читає їх. Заміни вигадані рядки на справжні дані свого продукту. Видалити зайве можна командою DELETE з умовою за id.

Домашнє завдання

  1. Довести базу до 25 записів. Скрипт рахує тільки кількість, але записи мають бути справжні: за минулий місяць, за свій список ігор, за свої рослини. Найзручніше дописувати їх у seed.py частинами: додав п’ять нових рядків у список — запустив — перевірив кількість командою з кроку 8. Якщо додав ті самі рядки вдруге, дивись розділ про дублікати.
  2. Запит із власною умовою. Додай у check_db.py ще один SELECT, який відповідає на осмислене питання про твої дані: скільки хвилин загалом за тиждень (SUM(minutes)), або п’ять найдовших записів, або всі записи однієї категорії. Запиши поруч у коментарі, на яке питання відповідає запит.
  3. Розділ «Дані» у README. Відкрий ~/app/README.md і додай розділ, у якому переліч свої стовпці: назва, тип, обов’язковий чи ні, що означає. Цей розділ читатиме людина, яка бачить проєкт уперше.
  4. Перевірка з телефона. Вимкни Wi-Fi і відкрий на телефоні адресу свого списку: http://91.219.61.4/u/<логін>/api/…. Ти маєш побачити текст JSON зі своїми записами.
  5. Коміт домашнього завдання. Коли допишеш записи, новий запит і розділ у README, збережи ці нові зміни в git тими самими чотирма командами, що й у кроці 15. Повідомлення коміта має описувати зміну, а не бути словом «update».

На початку наступного заняття буде питання «поясни рядок»: викладач покаже будь-який рядок із твого init_db.py або seed.py і попросить сказати, що він робить.

Якщо щось не працює

sqlite3.OperationalError: unable to open database file

Python не зміг ані відкрити, ані створити файл бази. Найчастіша причина — немає теки ~/data: шлях до бази складається з неї, але сама вона не створюється. Перевір і, якщо теки немає, створи:

ls -ld ~/data
mkdir -p ~/data

Після цього повтори крок 5. Друга можлива причина — у project.yaml у полі file написано не ім’я файлу, а шлях із похилими рисками. Там має стояти саме app.db.

sqlite3.OperationalError: no such table: habit

База відкрилася, але таблиці в ній немає. Перевір по черзі три речі. Перше: чи запускався init_db.py — виконай його ще раз, він безпечний для повторного запуску. Друге: чи збігаються назви — у CREATE TABLE, у запиті й у полі main_table має стояти те саме слово, великі й малі літери теж мають збігатися. Третє: чи не з’явилася друга база. Ця команда покаже всі файли баз у твоїй домашній теці:

find ~ -name "*.db" -not -path "*/venv/*" -ls

Правильний файл один — ~/data/app.db. Якщо знайшовся ще один файл бази, подивись у project.yaml поле file: там має бути тільки ім’я app.db, без тек і похилих рисок — решту шляху db.py додає сам. Виправ поле, видали зайвий файл і повтори крок 5.

Записів удвічі більше, ніж додавав

seed.py запустився двічі, і кожен рядок додався повторно. Подивись, що саме в базі:

cd ~/app && /opt/club/venv/bin/python -c 'import db
for r in db.query("SELECT id, day, title FROM habit ORDER BY id"):
    print(r)'

Дублікати — це рядки з більшими номерами. Якщо перші п’ять записів правильні, а решта зайва, видали все, що після п’ятого:

cd ~/app && /opt/club/venv/bin/python -c 'import db; db.execute("DELETE FROM habit WHERE id > 5")'

Команда DELETE без WHERE видаляє всю таблицю й нічого не перепитує, тому умову перечитай перед натисканням Enter.

Забув NOT NULL або первинний ключ

Додати NOT NULL до наявного стовпця однією командою SQLite не дає. Поки записів п’ять, найпростіше створити таблицю заново. Спочатку виправ опис у init_db.py, потім видали стару таблицю й запусти обидва файли ще раз:

cd ~/app
/opt/club/venv/bin/python -c 'import db; c = db.connect(); c.execute("DROP TABLE habit"); c.commit()'
/opt/club/venv/bin/python init_db.py
/opt/club/venv/bin/python seed.py

DROP TABLE видаляє таблицю разом з усіма записами без можливості скасувати. Роби це лише зараз, поки даних мало; коли записів набереться 25, так уже не вийде, і таблицю доведеться переносити копіюванням.

Ендпоінт відповідає 500

Код 500 означає, що запит дійшов до твоєї функції і вона завершилася з помилкою. Дивись журнал: journalctl --user -u club-api -n 30. Найчастіші причини сьогодні — помилка в назві таблиці всередині SELECT (див. перший пункт) і неправильна назва стовпця в ORDER BY. Текст помилки називає стовпець прямо. Наприклад, no such column: data — це типова описка: стовпець із датою зветься day, а в запиті набрано data.

attempt to write a readonly database

Файл бази є, але записувати в нього твоя програма не може. Перевір права — і на теку, і на файл:

ls -ld ~/data
ls -l ~/data/

Власником і теки, і файлу має бути твій логін. Право на запис потрібне на обидва: під час запису SQLite створює поруч із базою тимчасовий файл app.db-journal, тому дозволу тільки на app.db не вистачить. Якщо власник правильний, а помилка лишається, дай собі права:

chmod u+rwx ~/data
chmod u+rw ~/data/app.db

Якщо власник інший — сам ти цього не виправиш, скажи викладачеві.