diff --git a/docs/advanced/constraints.md b/docs/advanced/constraints.md new file mode 100644 index 0000000000..a76244e40f --- /dev/null +++ b/docs/advanced/constraints.md @@ -0,0 +1,90 @@ +# Database Constraints + +In some cases you might want to enforce rules about your data directly at the **database level**. For example, making sure that a hero's name is unique, or that their age is never negative. πŸ¦Έβ€β™€οΈ + +These rules are called **constraints**, and because they live in the database, they work regardless of which application is inserting the data. This is particularly important for data consistency in production systems. + +/// info + +**SQLModel** uses SQLAlchemy's constraint system under the hood, so you have access to all the powerful constraint options available in SQLAlchemy. + +/// + +## Unique Constraints + +Let's say you want to make sure that no two heroes can have the same name. The simplest way to do this is with the `unique` parameter in `Field()`: + +{* ./docs_src/advanced/constraints/tutorial001_py310.py ln[4:8] hl[6] *} + +Now the `name` field must be unique across all heroes. If you try to insert a hero with a name that already exists, the database will raise an error. + +So two heroes named "Deadpond" and "Spider-Boy" would work fine, but trying to add a second "Deadpond" would fail. + +## Multi-Column Unique Constraints + +Sometimes you don't need each individual field to be unique, but you want a **combination** of fields to be unique. For example, you might allow multiple heroes named "Spider-Boy" as long as they have different ages. + +You can do this using `__table_args__` with a `UniqueConstraint`: + +{* ./docs_src/advanced/constraints/tutorial002_py310.py ln[5:11] hl[6] *} + +With this setup, "Spider-Boy" aged 16 and "Spider-Boy" aged 25 are both allowed, because the **combination** of name and age is different. But two heroes both named "Spider-Boy" and both aged 16 would be rejected. + +/// tip + +You can include as many fields as needed in a `UniqueConstraint`. For example, `UniqueConstraint("name", "age", "team")` would require the combination of all three fields to be unique. + +/// + +## Check Constraints + +Check constraints let you define custom validation rules using SQL expressions. This is handy for enforcing business rules, like making sure a hero's age is never negative: + +{* ./docs_src/advanced/constraints/tutorial003_py310.py ln[5:11] hl[6] *} + +Here we're saying that `age` must be greater than or equal to zero. The `name` parameter gives the constraint a descriptive label, which makes error messages much easier to understand. + +So heroes with age 0, 16, or 100 would all be fine, but trying to insert a hero with age -5 would fail. + +## Combining Multiple Constraints + +You can mix different types of constraints in the same model by adding multiple constraint objects to `__table_args__`: + +{* ./docs_src/advanced/constraints/tutorial004_py310.py ln[5:15] hl[6:10] *} + +This model has three constraints working together: the combination of `name` and `age` must be unique, age cannot be negative, and the name must be at least 2 characters long. All constraints must be satisfied for data to be inserted successfully. + +## What Happens When a Constraint is Violated? + +If you try to insert data that breaks a constraint, the database will raise an error. SQLAlchemy wraps this as an `IntegrityError`. Here's what that looks like in practice: + +{* ./docs_src/advanced/constraints/tutorial005_py310.py ln[25:38] hl[32:37] *} + +When you run this code, you'll see that the first hero is created successfully, but the attempt to create a duplicate fails with a clear error message. + +
+ +```console +$ python app.py + +// Some boilerplate and previous output omitted πŸ˜‰ + +βœ… Created hero: id=1 age=48 secret_name='Dive Wilson' name='Deadpond' +🚫 Constraint violation caught: + Error: (sqlite3.IntegrityError) UNIQUE constraint failed: hero.name +[SQL: INSERT INTO hero (name, age, secret_name) VALUES (?, ?, ?)] +[parameters: ('Deadpond', 25, 'Wade Wilson')] +(Background on this error at: https://sqlalche.me/e/20/gkpj) +``` + +
+ +This error handling lets you gracefully manage constraint violations in your application instead of having your program crash unexpectedly. πŸ›‘οΈ + +/// warning + +Not all databases support all types of constraints equally. In particular, **SQLite** has limitations with some complex SQL expressions in check constraints. Make sure to test your constraints with your target database. + +Most other SQL databases like **PostgreSQL** and **MySQL** have full or near-full support. πŸŽ‰ + +/// diff --git a/docs_src/advanced/constraints/__init__.py b/docs_src/advanced/constraints/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/docs_src/advanced/constraints/__init__.py @@ -0,0 +1 @@ + diff --git a/docs_src/advanced/constraints/tutorial001_py310.py b/docs_src/advanced/constraints/tutorial001_py310.py new file mode 100644 index 0000000000..9ff0c8c34c --- /dev/null +++ b/docs_src/advanced/constraints/tutorial001_py310.py @@ -0,0 +1,42 @@ +from sqlmodel import Field, Session, SQLModel, create_engine + + +class Hero(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str = Field(unique=True) + age: int + secret_name: str + + +sqlite_file_name = "database.db" +sqlite_url = f"sqlite:///{sqlite_file_name}" + +engine = create_engine(sqlite_url, echo=True) + + +def create_db_and_tables(): + SQLModel.metadata.create_all(engine) + + +def create_heroes(): + hero_1 = Hero(name="Deadpond", age=48, secret_name="Dive Wilson") + hero_2 = Hero(name="Spider-Boy", age=16, secret_name="Pedro Parqueador") + + with Session(engine) as session: + session.add(hero_1) + session.add(hero_2) + session.commit() + session.refresh(hero_1) + session.refresh(hero_2) + + print("Created hero:", hero_1) + print("Created hero:", hero_2) + + +def main(): + create_db_and_tables() + create_heroes() + + +if __name__ == "__main__": + main() diff --git a/docs_src/advanced/constraints/tutorial002_py310.py b/docs_src/advanced/constraints/tutorial002_py310.py new file mode 100644 index 0000000000..a3cd30ed20 --- /dev/null +++ b/docs_src/advanced/constraints/tutorial002_py310.py @@ -0,0 +1,45 @@ +from sqlalchemy import UniqueConstraint +from sqlmodel import Field, Session, SQLModel, create_engine + + +class Hero(SQLModel, table=True): + __table_args__ = (UniqueConstraint("name", "age"),) + + id: int | None = Field(default=None, primary_key=True) + name: str + age: int + secret_name: str + + +sqlite_file_name = "database.db" +sqlite_url = f"sqlite:///{sqlite_file_name}" + +engine = create_engine(sqlite_url, echo=True) + + +def create_db_and_tables(): + SQLModel.metadata.create_all(engine) + + +def create_heroes(): + hero_1 = Hero(name="Spider-Boy", age=16, secret_name="Pedro Parqueador") + hero_2 = Hero(name="Spider-Boy", age=25, secret_name="Different Person") + + with Session(engine) as session: + session.add(hero_1) + session.add(hero_2) + session.commit() + session.refresh(hero_1) + session.refresh(hero_2) + + print("Created hero:", hero_1) + print("Created hero:", hero_2) + + +def main(): + create_db_and_tables() + create_heroes() + + +if __name__ == "__main__": + main() diff --git a/docs_src/advanced/constraints/tutorial003_py310.py b/docs_src/advanced/constraints/tutorial003_py310.py new file mode 100644 index 0000000000..7e81a66e06 --- /dev/null +++ b/docs_src/advanced/constraints/tutorial003_py310.py @@ -0,0 +1,45 @@ +from sqlalchemy import CheckConstraint +from sqlmodel import Field, Session, SQLModel, create_engine + + +class Hero(SQLModel, table=True): + __table_args__ = (CheckConstraint("age >= 0", name="age_non_negative"),) + + id: int | None = Field(default=None, primary_key=True) + name: str + age: int + secret_name: str + + +sqlite_file_name = "database.db" +sqlite_url = f"sqlite:///{sqlite_file_name}" + +engine = create_engine(sqlite_url, echo=True) + + +def create_db_and_tables(): + SQLModel.metadata.create_all(engine) + + +def create_heroes(): + hero_1 = Hero(name="Spider-Boy", age=16, secret_name="Pedro Parqueador") + hero_2 = Hero(name="Baby Hero", age=0, secret_name="Little One") + + with Session(engine) as session: + session.add(hero_1) + session.add(hero_2) + session.commit() + session.refresh(hero_1) + session.refresh(hero_2) + + print("Created hero:", hero_1) + print("Created hero:", hero_2) + + +def main(): + create_db_and_tables() + create_heroes() + + +if __name__ == "__main__": + main() diff --git a/docs_src/advanced/constraints/tutorial004_py310.py b/docs_src/advanced/constraints/tutorial004_py310.py new file mode 100644 index 0000000000..cafb4dcc86 --- /dev/null +++ b/docs_src/advanced/constraints/tutorial004_py310.py @@ -0,0 +1,49 @@ +from sqlalchemy import CheckConstraint, UniqueConstraint +from sqlmodel import Field, Session, SQLModel, create_engine + + +class Hero(SQLModel, table=True): + __table_args__ = ( + UniqueConstraint("name", "age"), + CheckConstraint("age >= 0", name="age_non_negative"), + CheckConstraint("LENGTH(name) >= 2", name="name_min_length"), + ) + + id: int | None = Field(default=None, primary_key=True) + name: str + age: int + secret_name: str + + +sqlite_file_name = "database.db" +sqlite_url = f"sqlite:///{sqlite_file_name}" + +engine = create_engine(sqlite_url, echo=True) + + +def create_db_and_tables(): + SQLModel.metadata.create_all(engine) + + +def create_heroes(): + hero_1 = Hero(name="Spider-Boy", age=16, secret_name="Pedro Parqueador") + hero_2 = Hero(name="Captain Marvel", age=25, secret_name="Carol Danvers") + + with Session(engine) as session: + session.add(hero_1) + session.add(hero_2) + session.commit() + session.refresh(hero_1) + session.refresh(hero_2) + + print("Created hero:", hero_1) + print("Created hero:", hero_2) + + +def main(): + create_db_and_tables() + create_heroes() + + +if __name__ == "__main__": + main() diff --git a/docs_src/advanced/constraints/tutorial005_py310.py b/docs_src/advanced/constraints/tutorial005_py310.py new file mode 100644 index 0000000000..63296343c9 --- /dev/null +++ b/docs_src/advanced/constraints/tutorial005_py310.py @@ -0,0 +1,50 @@ +from sqlalchemy.exc import IntegrityError +from sqlmodel import Field, Session, SQLModel, create_engine + + +class Hero(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + name: str = Field(unique=True) + age: int + secret_name: str + + +sqlite_file_name = "database.db" +sqlite_url = f"sqlite:///{sqlite_file_name}" + +engine = create_engine(sqlite_url, echo=True) + + +def create_db_and_tables(): + SQLModel.metadata.create_all(engine) + + +def create_heroes(): + hero_1 = Hero(name="Deadpond", age=48, secret_name="Dive Wilson") + + with Session(engine) as session: + session.add(hero_1) + session.commit() + session.refresh(hero_1) + print("βœ… Created hero:", hero_1) + + # Now try to create another hero with the same name + duplicate_hero = Hero(name="Deadpond", age=25, secret_name="Wade Wilson") + session.add(duplicate_hero) + + try: + session.commit() + print("❌ This shouldn't happen - duplicate was allowed!") + except IntegrityError as e: + session.rollback() + print("🚫 Constraint violation caught:") + print(f" Error: {e}") + + +def main(): + create_db_and_tables() + create_heroes() + + +if __name__ == "__main__": + main() diff --git a/mkdocs.yml b/mkdocs.yml index b89516e024..12ed13acd1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -127,6 +127,7 @@ nav: - tutorial/fastapi/tests.md - Advanced User Guide: - advanced/index.md + - advanced/constraints.md - advanced/decimal.md - advanced/uuid.md - Resources: