Бази данни
Проблем
Каталог във файл
Каталогът на магазина е CSV файл, както в модула за файловете. Докато продуктите са сто, става. После стават сто хиляди и се появяват проблеми:
- за да намериш един продукт, четеш целия файл;
- „всички под 5 €, подредени по цена“ е цикъл,
ifи сортиране, писани на ръка; - две каси пишат във файла едновременно и единият запис се губи или файлът се поврежда;
- токът спира по средата на записа и половината файл го няма.
Затова данните на почти всяка истинска програма са в база данни: програма, чиято работа е да пази данни, да ги търси бързо и да не ги губи.
Идеята
Таблици и SQL
Релационната база пази данните в таблици. Таблицата има колони (име, тип) и редове (по един за всеки продукт). Колоната id е първичен ключ: стойността ѝ е различна за всеки ред, затова по нея се намира точно един.
С базата се говори на езика SQL. Петте най-чести команди:
CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2));
INSERT INTO products VALUES (1, 'Кафе', 7.50);SELECT name, price FROM products WHERE price < 5 ORDER BY price;UPDATE products SET price = 6.90 WHERE id = 1;DELETE FROM products WHERE id = 3;CREATE TABLEправи таблица,INSERTдобавя ред,UPDATEпроменя,DELETEтрие.SELECTчете: кои колони, от коя таблица, при какво условие (WHERE) и в какъв ред (ORDER BY).
Ти казваш какво искаш, а базата решава как да го намери бързо.
По-точно, за любопитните
- Известни релационни бази са PostgreSQL, MySQL, Oracle и SQL Server. Те работят като отделни сървъри. Тук ползваме SQLite: малка база, която живее във файл или изцяло в паметта на програмата. Модулът
sqlite3е в стандартната библиотека на Python. Адресът":memory:"е база в паметта: всяко пускане започва с празна. - Текстът в SQL е в единични кавички:
'Кафе'. В PostgreSQL и MySQLDECIMAL(8, 2)е точно число с 2 знака след запетаята, подходящо за пари. SQLite не пази точни десетични числа: там7.50е обикновено число с плаваща запетая.
Виж го
sqlite3: връзка, заявка, резултат
sqlite3 следва стандарта на Python за бази данни (DB-API, PEP 249): същите connect, execute, commit и close има и при PostgreSQL и MySQL. Връзката е Connection, а резултатът от заявка е курсор. Функцията setup прави таблицата и добавя три продукта:def setup(conn): conn.execute("CREATE TABLE products (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2))") conn.execute("INSERT INTO products VALUES (1, 'Кафе', 7.50), (2, 'Чай', 3.20), (3, 'Шоколад', 2.40)") conn.commit()Product с полета id, name и price:@dataclassclass Product: id: int name: str price: floatconn = sqlite3.connect(":memory:")setup(conn)products = []for product_id, name, price in conn.execute("SELECT id, name, price FROM products ORDER BY price"): products.append(Product(product_id, name, price))for p in products: print(p)conn.close()Product(id=3, name='Шоколад', price=2.4)Product(id=2, name='Чай', price=3.2)Product(id=1, name='Кафе', price=7.5)sqlite3.connect(адрес)отваря връзката. Адресът казва коя база: тук":memory:", или име на файл като"shop.db".conn.execute(sql)праща всяка команда и връща курсор.forпо курсора дава ред по ред, всеки като кортеж от колоните в реда наSELECT. Тук кортежът се разпакетира в три променливи.- Преди
INSERT,UPDATEиDELETEsqlite3 сам започва транзакция, аconn.commit()записва промените. Без него промените в база във файл се губят при затваряне. conn.close()затваря връзката. (with conn:не я затваря: той прави транзакция, ще го видим по-долу.)- Грешките са изключения от
sqlite3.Error, напримерOperationalErrorза грешен SQL иIntegrityErrorза нарушено правило на таблицата. Проверени изключения в Python няма.
conn = sqlite3.connect(":memory:")setup(conn)products = []for product_id, name, price in conn.execute("SELECT id, name, price FROM products ORDER BY price"): products.append(Product(product_id, name, price))for p in products: print(p)conn.close()Стъпка по стъпка, с думи
- Връзка с базата в паметта. setup прави таблицата products и добавя 3 реда.
- Празен списък, в който ще влязат обектите.
- SELECT с ORDER BY price: conn.execute връща курсор към редовете, от най-евтиния.
- Цикълът взима ред 1 като кортеж и го разпакетира в три променливи, а Product(...) прави от тях обект: Шоколад.
- Цикълът взима ред 2 като кортеж и го разпакетира в три променливи, а Product(...) прави от тях обект: Чай.
- Цикълът взима ред 3 като кортеж и го разпакетира в три променливи, а Product(...) прави от тях обект: Кафе.
- Курсорът няма повече редове и цикълът спира.
- Списъкът е от обикновени обекти. Печатът показва dataclass-а с полетата му.
По-точно, за любопитните
- С истинска база на сървър адресът е друг и има потребител и парола. Също трябва и драйверът на базата като зависимост в проекта (за MySQL: пакетът
mysql-connector-python):
import mysql.connector
conn = mysql.connector.connect(host="localhost", port=3306, database="shop", user="shop_app", password="парола")print("Връзката с базата е успешна!")conn.close()- Паролата не се пише в кода, а се чете от настройките или от променлива на средата (
os.environ).
- Цените тук са
float, за да е кратко. В истински магазин парите се пазят точно: катоDECIMALв PostgreSQL или MySQL (в Python те идват катоdecimal.Decimal), а в SQLite като цели стотинки. - С
conn.row_factory = sqlite3.Rowвсеки ред може да се чете и по име на колона:row["name"].
Пробвай
Кое ще излезе?
Продуктите са Кафе 7,50 €, Чай 3,20 € и Шоколад 2,40 €. Какво ще отпечата програмата?
conn = sqlite3.connect(":memory:")setup(conn)for row in conn.execute("SELECT name FROM products WHERE price < 5 ORDER BY price DESC"): print(row[0])conn.close()WHERE price < 5 оставя Чай и Шоколад. ORDER BY price DESC ги подрежда по цена отгоре надолу (DESC е намаляващо, ASC е по подразбиране): първо Чай за 3,20 €, после Шоколад за 2,40 €.
Проблем
Търсачката, която показва всичко
Сайтът търси продукт по име, което потребителят пише. Заявката се сглобява с f-string:
def search(conn, name): sql = f"SELECT name, price FROM products WHERE name = '{name}'" for found, price in conn.execute(sql): print(f" {found}: {price} €")conn = sqlite3.connect(":memory:")setup(conn)print("Търся: Чай")search(conn, "Чай")print("Търся: x' OR '1'='1")search(conn, "x' OR '1'='1")conn.close()Търся: Чай Чай: 3.2 €Търся: x' OR '1'='1 Кафе: 7.5 € Чай: 3.2 € Шоколад: 2.4 €С „Чай“ работи. С x' OR '1'='1 заявката става ... WHERE name = 'x' OR '1'='1'. Условието е винаги вярно и излиза цялата таблица. Входът на потребителя е станал част от SQL. Това е SQL injection, една от най-честите атаки срещу сайтове. Със същия трик може да се влезе без парола или да се изтрие таблица.
Идеята
Параметри с ?
? на местата на стойностите, а те се подават като кортеж, вторият аргумент на execute. (name,) е кортеж с една стойност: запетаята е задължителна.def search(conn, name): sql = "SELECT name, price FROM products WHERE name = ?" for found, price in conn.execute(sql, (name,)): print(f" {found}: {price} €")Търся: Чай Чай: 3.2 €Търся: x' OR '1'='1Сега x' OR '1'='1 е просто текст, който се търси като име. Такъв продукт няма и не излиза нищо. Каквото и да напише потребителят, то не може да стане SQL.
Правилото е просто: стойност от потребителя, от файл или от мрежата никога не се долепя в SQL. Винаги ?.
Пробвай
Кафе с апостроф
Кафе 'Арабика' програмата спира с sqlite3.OperationalError: near "Арабика": syntax error. На кой ред е грешката?Избери реда с грешката.
'Арабика' затваря текста по-рано и останалото е безсмислен SQL (near "Арабика" сочи мястото). Нападател би сложил там своя заявка. Поправката е с параметри: conn.execute("UPDATE products SET name = ? WHERE id = ?", (new_name, product_id)). Тогава името се записва точно както е, с апострофите.Идеята
Транзакция: всичко или нищо
Ана превежда 150 € на Борис. Това са две заявки: добави на Борис, извади от Ана. В таблицата има условие CHECK (balance >= 0), затова втората заявка се проваля: Ана има само 100 €.
def open_accounts(conn): conn.execute("CREATE TABLE accounts (id INT PRIMARY KEY, owner VARCHAR(50), balance INT CONSTRAINT no_debt CHECK (balance >= 0))") conn.execute("INSERT INTO accounts VALUES (1, 'Ана', 100), (2, 'Борис', 50)") conn.commit()
def move(conn, account_id, amount): conn.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, account_id))
def print_balances(conn): for owner, balance in conn.execute("SELECT owner, balance FROM accounts ORDER BY id"): print(f" {owner}: {balance} €")def transfer(conn, source, target, amount): move(conn, target, amount) conn.commit() move(conn, source, -amount) conn.commit()
conn = sqlite3.connect(":memory:")open_accounts(conn)try: transfer(conn, 1, 2, 150)except sqlite3.Error: print("Преводът на 150 € е отказан.")print_balances(conn)conn.close()Преводът на 150 € е отказан. Ана: 100 € Борис: 200 €commit(). Двете заявки трябва да са транзакция: или минават и двете, или нито една. В Python това прави with conn:.def transfer(conn, source, target, amount): with conn: move(conn, target, amount) move(conn, source, -amount)Преводът на 150 € е отказан. Ана: 100 € Борис: 50 €- Първият
UPDATEзапочва транзакция: промените още не са окончателни. - Ако блокът
with conn:свърши без грешка, той викаconn.commit()и записва всичко наведнъж. - Ако вътре има изключение, той вика
conn.rollback()и отменя всичко. Изключението продължава нагоре, за да разбере и викащият. open_accountsвикаcommit()накрая. Без него и създаването на сметките щеше да е в същата транзакция иrollback()щеше да отмени и него.
По-точно, за любопитните
- Изключението при провалената заявка е
IntegrityErrorсъс съобщениеCHECK constraint failed: no_debt.no_debtе името на условието: в таблицата то еbalance INT CONSTRAINT no_debt CHECK (balance >= 0).
- Базите обещават четири неща за транзакциите, известни като ACID: всичко или нищо (atomicity), данните остават верни според правилата на таблиците (consistency), едновременните транзакции не си пречат (isolation) и записаното не се губи дори при спрял ток (durability).
Идеята
ORM: обекти вместо редове
sqlalchemy), а в уеб проекти с Django се ползва вграденият ORM на Django.from sqlalchemy import String, create_engine, selectfrom sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
class Base(DeclarativeBase): pass
class Product(Base): __tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(50)) price: Mapped[float]- Наследник на
Base(отDeclarativeBase) е таблица, а__tablename__дава името ѝ. - Всяко поле с тип
Mapped[...]е колона.primary_key=Trueе първичният ключ: SQLAlchemy оставя базата да му дава стойност. mapped_columnзадава подробности за колоната: тукString(50). Поле само сMapped[float]също става колона.- Конструкторът се прави сам:
Product(name="Кафе", price=7.50).
Записът и четенето минават през Session:
engine = create_engine("sqlite://")Base.metadata.create_all(engine)
with Session(engine) as session: session.add(Product(name="Кафе", price=7.50)) session.add(Product(name="Чай", price=3.20)) session.commit()
cheap = session.scalars(select(Product).where(Product.price < 5).order_by(Product.price)) for p in cheap: print(f"{p.name}: {p.price} €")Чай: 3.2 €create_engine("sqlite://") казва коя база: тук SQLite в паметта. create_all прави таблиците. session.add добавя обекта, а commit() го записва в транзакция, както в sqlite3. Заявката се строи с код: select(Product).where(Product.price < 5) говори за класа и полето му price, не за таблицата. session.scalars връща готови обекти.
SQLAlchemy е пакет и в проверката на сайта го няма, затова този код тук не се пуска. Изходът е от пускане с инсталиран SQLAlchemy 2.1 (pip install sqlalchemy).
По-точно, за любопитните
- Заявката с
selectе код, затова условията лесно се сглобяват според избора на потребителя. Функция, която търси по име:
def named(session, name): query = select(Product).where(Product.name == name) return session.scalars(query).all()- Транзакция с отмяна при грешка:
session.begin()вwithзаписва всичко накрая, а при изключение отменя всичко:
with Session(engine) as session, session.begin(): session.add(Product(name="Шоколад", price=2.40)) # още промени: при изключение всичко се отменя, иначе се записва- С
create_engine(адрес, echo=True)SQLAlchemy печата SQL-а, който праща. Така се вижда, че отдолу пак е SQL. - В стария код ще срещнеш
session.query(Product).filter(...). Това е стилът от SQLAlchemy 1.x, аselect(...)е от 2.0 нататък.
Упражнение
Магазин с база
sqlite3 и база ":memory:".1. Каталог. Направи таблица products и функции add, set_price, remove и show (list е име на вграден тип), всички с ? освен show. Добави Кафе (7,50 €), Чай (3,20 €) и Шоколад (2,40 €), отпечатай, смени цената на кафето на 6,90 €, изтрий шоколада и отпечатай пак:1. Кафе: 7.5 €2. Чай: 3.2 €3. Шоколад: 2.4 €---1. Кафе: 6.9 €2. Чай: 3.2 €find(conn, text) намира всички, чието име съдържа текста, без значение от малки и главни букви. LOWER в SQLite сменя само латинските букви, затова дай на базата своя функция: conn.create_function("lower_text", 1, str.lower), после WHERE lower_text(name) LIKE ? със стойност f"%{text.lower()}%". Пробвай „КА“, „чай“ и опит за injection:Търся „КА“: Какао: 4.8 € Кафе: 7.5 €Търся „чай“: Чай: 3.2 €Търся „' OR '1'='1“: няма такива продуктиtransfer(conn, source, target, amount) първо вади от source, после добавя на target. Ако cursor.rowcount е 0 (няма такава сметка), хвърли ValueError(f"Няма сметка {account_id}"). Всичко е в with conn:. Преведи 30 € от 1 към 2, после 40 € от 1 към несъществуващата 99:Преведени 30 € от 1 към 2Отказан: Няма сметка 99 Ана: 70 € Борис: 80 €Вторият превод е извадил 40 € от Ана, преди да се провали, но with conn (с rollback()) ги е върнал.
Тест