Databases
Problem
A catalog in a file
The store's catalog is a CSV file, as in the files module. While there are a hundred products, it works. Then there are a hundred thousand, and problems show up:
- to find one product, you read the whole file;
- "everything under €5, sorted by price" is a loop, an
ifand a sort, written by hand; - two cash registers write to the file at the same time, and one record gets lost or the file gets damaged;
- the power goes out in the middle of a write, and half the file is gone.
That's why the data of almost every real program lives in a database: a program whose job is to keep data, find it fast and never lose it.
The idea
Tables and SQL
A relational database keeps data in tables. A table has columns (name, type) and rows (one for each product). The id column is the primary key: its value is different for each row, so it finds exactly one row.
You talk to the database in SQL. The five most common commands:
CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2));
INSERT INTO products VALUES (1, 'Coffee', 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 TABLEmakes a table,INSERTadds a row,UPDATEchanges rows andDELETEremoves them.SELECTreads: which columns, from which table, under what condition (WHERE) and in what order (ORDER BY).
You say what you want, and the database decides how to find it fast.
More precisely, if you are curious
- Well-known relational databases are PostgreSQL, MySQL, Oracle and SQL Server. They run as separate servers. Here we use SQLite: a small database that lives in a file or entirely in the program's memory. The
sqlite3module is in Python's standard library. The address":memory:"is a database in memory: every run starts with an empty one. - Text in SQL goes in single quotes:
'Coffee'. In PostgreSQL and MySQL,DECIMAL(8, 2)is an exact number with 2 digits after the decimal point, which suits money. SQLite doesn't keep exact decimal numbers: there,7.50is an ordinary floating-point number.
See it
sqlite3: connection, query, result
sqlite3 module follows Python's standard for databases (DB-API, PEP 249): PostgreSQL and MySQL have the same connect, execute, commit and close. The connection is a Connection, and the result of a query is a cursor. The setup function makes the table and adds three products: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, 'Coffee', 7.50), (2, 'Tea', 3.20), (3, 'Chocolate', 2.40)") conn.commit()Product dataclass, with the fields id, name and 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='Chocolate', price=2.4)Product(id=2, name='Tea', price=3.2)Product(id=1, name='Coffee', price=7.5)sqlite3.connect(address)opens the connection. The address says which database: here":memory:", or a file name like"shop.db".conn.execute(sql)sends any command and returns a cursor. Aforover the cursor gives you one row at a time, each as a tuple of the columns in the order of theSELECT. Here the tuple is unpacked into three variables.- Before
INSERT,UPDATEandDELETE, sqlite3 starts a transaction by itself, andconn.commit()saves the changes. Without it, changes to a database in a file are lost when it closes. conn.close()closes the connection. (with conn:doesn't close it: it makes a transaction, as you'll see below.)- Errors are exceptions from
sqlite3.Error, for exampleOperationalErrorfor wrong SQL andIntegrityErrorfor a broken table rule. Python has no checked exceptions.
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()Step by step, in words
- A connection to the database in memory. setup makes the products table and adds 3 rows.
- An empty list that the objects will go into.
- SELECT with ORDER BY price: conn.execute returns a cursor over the rows, cheapest first.
- The loop takes row 1 as a tuple and unpacks it into three variables, and Product(...) makes an object from them: Chocolate.
- The loop takes row 2 as a tuple and unpacks it into three variables, and Product(...) makes an object from them: Tea.
- The loop takes row 3 as a tuple and unpacks it into three variables, and Product(...) makes an object from them: Coffee.
- The cursor has no more rows and the loop stops.
- The list holds ordinary objects. Printing shows the dataclass with its fields.
More precisely, if you are curious
- With a real database on a server, the address is different and there's a user and a password. You also need the database's driver as a dependency in the project (for MySQL: the
mysql-connector-pythonpackage):
import mysql.connector
conn = mysql.connector.connect(host="localhost", port=3306, database="shop", user="shop_app", password="secret")print("Connected to the database!")conn.close()- The password isn't written in the code. It's read from the settings or from an environment variable (
os.environ).
- The prices here are
floatto keep it short. In a real store, money is kept exactly: asDECIMALin PostgreSQL or MySQL (in Python it arrives asdecimal.Decimal), and in SQLite as whole cents. - With
conn.row_factory = sqlite3.Row, you can also read each row by column name:row["name"].
Try it
What will it print?
The products are Coffee €7.50, Tea €3.20 and Chocolate €2.40. What will the program print?
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 keeps Tea and Chocolate. ORDER BY price DESC sorts them by price from the top down (DESC is descending, and ASC is the default): first Tea for €3.20, then Chocolate for €2.40.
Problem
The search that shows everything
The website searches for a product by the name the user types. The query is put together with an 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("Searching: Tea")search(conn, "Tea")print("Searching: x' OR '1'='1")search(conn, "x' OR '1'='1")conn.close()Searching: Tea Tea: €3.2Searching: x' OR '1'='1 Coffee: €7.5 Tea: €3.2 Chocolate: €2.4With "Tea" it works. With x' OR '1'='1 the query becomes ... WHERE name = 'x' OR '1'='1'. The condition is always true, and the whole table comes out. The user's input has become part of the SQL. This is SQL injection, one of the most common attacks on websites. With the same trick, someone can log in without a password or delete a table.
The idea
Parameters with ?
? in place of the values, and you pass them as a tuple, the second argument of execute. (name,) is a tuple with one value: the comma is required.def search(conn, name): sql = "SELECT name, price FROM products WHERE name = ?" for found, price in conn.execute(sql, (name,)): print(f" {found}: €{price}")Searching: Tea Tea: €3.2Searching: x' OR '1'='1Now x' OR '1'='1 is just text that is searched for as a name. There's no such product, and nothing comes out. Whatever the user types, it can't become SQL.
The rule is simple: a value from the user, from a file or from the network is never glued into SQL. Always use ?.
Try it
Coffee with an apostrophe
Coffee 'Arabica' the program stops with sqlite3.OperationalError: near "Arabica": syntax error. Which line has the bug?Pick the line with the bug.
'Arabica' closes the text too early, and the rest is meaningless SQL (near "Arabica" points at the spot). An attacker would put their own query there. The fix is to use parameters: conn.execute("UPDATE products SET name = ? WHERE id = ?", (new_name, product_id)). Then the name is saved exactly as it is, with the apostrophes.The idea
Transaction: all or nothing
Ana transfers €150 to Boris. That's two queries: add to Boris, subtract from Ana. The table has the condition CHECK (balance >= 0), so the second query fails: Ana has only €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, 'Ana', 100), (2, 'Boris', 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("The transfer of €150 was refused.")print_balances(conn)conn.close()The transfer of €150 was refused. Ana: €100 Boris: €200commit(). The two queries must be a transaction: either both go through, or neither does. In Python, with conn: does this.def transfer(conn, source, target, amount): with conn: move(conn, target, amount) move(conn, source, -amount)The transfer of €150 was refused. Ana: €100 Boris: €50- The first
UPDATEstarts a transaction: the changes aren't final yet. - If the
with conn:block ends without an error, it callsconn.commit()and saves everything at once. - If there's an exception inside, it calls
conn.rollback()and undoes everything. The exception goes on up, so the caller finds out too. open_accountscallscommit()at the end. Without it, creating the accounts would be in the same transaction, androllback()would undo that too.
More precisely, if you are curious
- The exception for the failed query is an
IntegrityErrorwith the messageCHECK constraint failed: no_debt.no_debtis the name of the condition: in the table it'sbalance INT CONSTRAINT no_debt CHECK (balance >= 0).
- Databases promise four things about transactions, known as ACID: all or nothing (atomicity), the data stays correct according to the table rules (consistency), transactions running at the same time don't get in each other's way (isolation), and what's saved isn't lost even if the power goes out (durability).
The idea
ORM: objects instead of rows
sqlalchemy package), and web projects with Django use Django's built-in ORM.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]- A subclass of
Base(fromDeclarativeBase) is a table, and__tablename__gives its name. - Every field with the type
Mapped[...]is a column.primary_key=Trueis the primary key: SQLAlchemy lets the database give it a value. mapped_columnsets details for the column: here,String(50). A field with onlyMapped[float]becomes a column too.- The constructor is made for you:
Product(name="Coffee", price=7.50).
Saving and reading go through the Session:
engine = create_engine("sqlite://")Base.metadata.create_all(engine)
with Session(engine) as session: session.add(Product(name="Coffee", price=7.50)) session.add(Product(name="Tea", 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}")Tea: €3.2create_engine("sqlite://") says which database: here, SQLite in memory. create_all makes the tables. session.add adds the object, and commit() saves it in a transaction, as in sqlite3. The query is built with code: select(Product).where(Product.price < 5) talks about the class and its price field, not about the table. session.scalars returns ready objects.
SQLAlchemy is a package, and the site's checker doesn't have it, so this code doesn't run here. The output comes from a run with SQLAlchemy 2.1 installed (pip install sqlalchemy).
More precisely, if you are curious
- A query with
selectis code, so the conditions are easy to put together from the user's choices. A function that searches by name:
def named(session, name): query = select(Product).where(Product.name == name) return session.scalars(query).all()- A transaction that is undone on an error:
session.begin()in awithsaves everything at the end, and on an exception undoes everything:
with Session(engine) as session, session.begin(): session.add(Product(name="Chocolate", price=2.40)) # more changes: on an exception everything is undone, otherwise everything is saved- With
create_engine(address, echo=True), SQLAlchemy prints the SQL it sends. That way you can see it's still SQL underneath. - In older code you'll see
session.query(Product).filter(...). That's the SQLAlchemy 1.x style, andselect(...)is from 2.0 on.
Exercise
A store with a database
sqlite3 and the database ":memory:".1. Catalog. Make a products table and the functions add, set_price, remove and show (list is the name of a built-in type), all with ? except show. Add Coffee (€7.50), Tea (€3.20) and Chocolate (€2.40), print them, change the price of the coffee to €6.90, delete the chocolate and print again:1. Coffee: €7.52. Tea: €3.23. Chocolate: €2.4---1. Coffee: €6.92. Tea: €3.2find(conn, text) finds all products whose name contains the text, ignoring upper and lower case. LOWER in SQLite changes only the letters A to Z, so É stays as it is. Give the database a function of your own: conn.create_function("lower_text", 1, str.lower), then WHERE lower_text(name) LIKE ? with the value f"%{text.lower()}%". Try "CO", "éclair" and an injection attempt:Searching for "CO": Cocoa: €4.8 Coffee: €7.5Searching for "éclair": Éclair: €2.4Searching for "' OR '1'='1": no products foundtransfer(conn, source, target, amount) first subtracts from source, then adds to target. If cursor.rowcount is 0 (no such account), raise ValueError(f"No account {account_id}"). Everything is in with conn:. Transfer €30 from 1 to 2, then €40 from 1 to the account 99, which doesn't exist:Transferred €30 from 1 to 2Refused: No account 99 Ana: €70 Boris: €80The second transfer took €40 from Ana before it failed, but with conn (with rollback()) gave it back.
Test