📖 Теория: онлайн-магазин книг, часть 1

📁 Блок: Практикум / Базы данных ⏱️ Время изучения: ~120 мин 🎯 Сложность: Средняя
#if #not #from #int #decimal #open #strip #split #reader

⚡ Кратко

Создаём консольный магазин книг на PyMySQL: база bookstore, таблицы books и users, меню, загрузка книг из CSV, регистрация и вход. Главное правило безопасности — параметризованные запросы вместо f-строк в SQL.

  • CREATE DATABASE IF NOT EXISTS + SHOW DATABASES для проверки.
  • CREATE TABLE IF NOT EXISTS с типами INT, VARCHAR, DECIMAL и CHECK.
  • CSV-файл читаем построчно, разбиваем по , и вставляем/обновляем записи.
  • Перед регистрацией проверяем логин через SELECT; при входе — сверяем пароль.
  • Используйте connection.commit() после изменений и оборачивайте работу в try/except.

🎯 Общая идея проекта

Практикум 21 — это пошаговое создание консольного приложения «Онлайн-магазин книг». Программа работает с MySQL и в будущих частях будет дополнена MongoDB. В первой части мы реализуем фундамент:

  1. Создание базы данных bookstore.
  2. Создание таблиц books и users.
  3. Главное меню: загрузка книг, регистрация, вход, выход.
  4. Подменю пользователя после успешного входа.

🗄️ Этап 1. Создание базы данных

Перед созданием таблиц нужна база данных. В MySQL база данных — это именованное пространство, в котором живут таблицы. Чтобы не падать с ошибкой, если база уже существует, используем IF NOT EXISTS:

import pymysql

config = {
    "host": "ich-edit.edu.itcareerhub.de",
    "user": "ich1",
    "password": "ich1_password_ilovedbs",
}

db_name = "bookstore"

with pymysql.connect(**config) as connection:
    with connection.cursor() as cursor:
        cursor.execute(f"CREATE DATABASE IF NOT EXISTS {db_name}")
        cursor.execute("SHOW DATABASES")
        databases = [row[0] for row in cursor]

        if db_name in databases:
            print(f"Database '{db_name}' created or already exists.")

Обратите внимание: имя базы данных здесь вставлено f-строкой. Это допустимо, потому что db_name — константа, заданная программистом, а не пользовательский ввод. Для любых пользовательских данных всегда используйте параметризованные запросы.

📋 Этап 2. Создание таблиц

Таблица books хранит книги, таблица users — зарегистрированных клиентов. Поле id делаем автоинкрементным первичным ключом:

CREATE TABLE IF NOT EXISTS books (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200),
    author VARCHAR(100),
    price DECIMAL(10, 2),
    stock INT CHECK (stock >= 0)
);

CREATE TABLE IF NOT EXISTS users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(100),
    password VARCHAR(100),
    balance DECIMAL(10, 2) CHECK (balance >= 0)
);

Тип DECIMAL(10, 2) выбран для денежных значений: он хранит числа с фиксированной точностью, в отличие от FLOAT, где возможны погрешности. CHECK-ограничения защищают от отрицательного количества книг и отрицательного баланса.

После создания таблиц можно вывести их список:

cursor.execute(f"USE {db_name}")
cursor.execute("SHOW TABLES")
tables = [row[0] for row in cursor]
print(f"Tables in '{db_name}':")
for table in tables:
    print(f"- {table}")

Меню — бесконечный цикл while True, который предлагает пользователю выбрать действие. Выход из цикла происходит при выборе пункта 0. Для каждого действия вызывается отдельная функция, которой передаётся объект соединения connection.

with pymysql.connect(**config) as connection:
    while True:
        print("\n--- Bookstore Menu ---")
        print("1. Load books from file")
        print("2. Register new user")
        print("3. Login as user")
        print("0. Exit")
        choice = input("Choose action: ")

        if choice == "1":
            filename = input("Enter file name: ")
            load_books_from_file(filename, connection)
        elif choice == "2":
            register_user(connection)
        elif choice == "3":
            user_id = try_login(connection)
            if user_id:
                user_menu(connection, user_id)
        elif choice == "0":
            break
        else:
            print("Invalid option.")

📁 Этап 3.1. Загрузка книг из CSV-файла

Файл с книгами имеет простой формат: каждая строка — четыре поля через запятую. Пример:

1984,George Orwell,8.99,5
Brave New World,Aldous Huxley,9.50,3

Алгоритм загрузки:

  1. Открыть файл с кодировкой UTF-8.
  2. Для каждой строки разбить по запятой и проверить, что получилось ровно 4 части.
  3. Преобразовать цену в float, количество в int.
  4. Проверить, есть ли книга с таким названием и автором.
  5. Если есть — обновить количество, иначе — вставить новую запись.
  6. После обработки всех строк вызвать connection.commit().
def load_books_from_file(filename: str, connection) -> None:
    added = 0
    with open(filename, encoding="utf-8") as file:
        with connection.cursor() as cursor:
            cursor.execute(f"USE {db_name}")
            for line in file:
                parts = line.strip().split(",")
                if len(parts) != 4:
                    continue
                title, author, price, stock = parts
                price = float(price)
                stock = int(stock)

                cursor.execute(
                    "SELECT id, stock FROM books WHERE title = %s AND author = %s",
                    (title, author),
                )
                result = cursor.fetchone()

                if result:
                    book_id, current_stock = result
                    cursor.execute(
                        "UPDATE books SET stock = %s WHERE id = %s",
                        (current_stock + stock, book_id),
                    )
                else:
                    cursor.execute(
                        "INSERT INTO books (title, author, price, stock) VALUES (%s, %s, %s, %s)",
                        (title, author, price, stock),
                    )
                added += 1
    connection.commit()
    print(f"{added} books loaded.")
⚠️ Проверить по документации: для реальных CSV-файлов, в которых поля могут содержать запятые или кавычки, лучше использовать модуль csv из стандартной библиотеки вместо ручного split(",").

📝 Этап 3.2. Регистрация клиента

Пользователь вводит логин, пароль и начальный баланс. Перед вставкой проверяем, что логин ещё не занят. Это типичный guard clause: если условие не выполнено — выходим из функции раньше, без вложенных блоков.

def register_user(connection) -> None:
    username = input("Enter username: ")
    password = input("Enter password: ")
    try:
        balance = float(input("Enter initial balance: "))
    except ValueError:
        print("Invalid balance.")
        return

    with connection.cursor() as cursor:
        cursor.execute(f"USE {db_name}")
        cursor.execute("SELECT id FROM users WHERE username = %s", (username,))
        if cursor.fetchone():
            print("Username already exists.")
            return

        cursor.execute(
            "INSERT INTO users (username, password, balance) VALUES (%s, %s, %s)",
            (username, password, balance),
        )
    connection.commit()
    print("Registration successful.")
⚠️ Проверить по документации: в реальных проектах пароли никогда не хранят в открытом виде. Используйте хеширование, например bcrypt или hashlib с солью.

🔐 Этап 3.3. Вход в аккаунт

Вход сводится к поиску пользователя с заданным логином и паролем. Если запись найдена — возвращаем user_id, иначе None. После успешного входа запускается подменю клиента.

def try_login(connection) -> int | None:
    username = input("Enter username: ")
    password = input("Enter password: ")

    with connection.cursor() as cursor:
        cursor.execute(f"USE {db_name}")
        cursor.execute(
            "SELECT id FROM users WHERE username = %s AND password = %s",
            (username, password),
        )
        result = cursor.fetchone()

    if result:
        print("Login successful.")
        return result[0]
    print("Invalid username or password.")
    return None


def user_menu(connection, user_id: int) -> None:
    while True:
        print(f"\n--- User Menu (ID {user_id}) ---")
        print("1. View available books")
        print("0. Logout")
        choice = input("Choose action: ")

        if choice == "1":
            print("Books will be shown here.")
        elif choice == "0":
            break
        else:
            print("Invalid option.")

Обратите внимание на ранний возврат: если result равен None, функция сразу сообщает об ошибке и возвращает None, не уходя в длинный if/else.

✨ Современные практики

  • f-strings: удобны для вывода сообщений, но не для формирования SQL-запросов с пользовательскими данными.
  • Guard clauses: проверяйте ошибочные условия и сразу выходите из функции — код остаётся плоским и читаемым.
  • Ранний возврат: возвращайте результат сразу, как только он известен, вместо вложенных блоков.
  • Параметризованные запросы: передавайте значения вторым аргументом в execute(). Библиотека сама экранирует опасные символы.
  • Type hints: аннотации (filename: str) -> None помогают IDE и читателю кода понимать ожидаемые типы.
  • if __name__ == "__main__": запускайте меню только при прямом запуске файла, чтобы функции можно было импортировать в тесты или другие модули.
⚠️ Проверить по документации: для промышленных приложений логин/пароль и адрес базы данных обычно хранят в переменных окружения или файле .env, а код разбивают на модули: config.py, db.py, models.py, main.py.