Skip to content

Commit 00b594f

Browse files
committed
docs(sa_column): consolidate into Advanced page; add encrypted + uniqueness tutorials; remove deprecated encrypted-type page; update nav and run notes
1 parent a85de91 commit 00b594f

File tree

4 files changed

+242
-0
lines changed

4 files changed

+242
-0
lines changed

docs/advanced/sa-column.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Use SQLAlchemy columns with `sa_column`
2+
3+
Sometimes you need full control over how a database column is defined — beyond what `Field()` options provide.
4+
5+
SQLModel lets you pass a fully configured SQLAlchemy `Column(...)` using the `sa_column` parameter.
6+
7+
This allows you to use advanced SQLAlchemy features and third‑party column types directly while keeping the simplicity of SQLModel models.
8+
9+
/// info
10+
`sa_column` provides a low-level hook to supply a complete SQLAlchemy `Column(...)` object for a field. SQLModel will use the column's type, options, and constraints as-is.
11+
///
12+
13+
## What `sa_column` enables
14+
15+
- Fine‑grained control over column definitions (e.g. `ForeignKey`, `CheckConstraint`, `UniqueConstraint`, `Index`, `server_default`, `server_onupdate`).
16+
- Custom/third‑party SQLAlchemy types (for example, encrypted strings, PostgreSQL `JSONB`, etc.).
17+
- Easier migration from or integration with existing SQLAlchemy models.
18+
19+
## Use case: encrypted field with a custom type
20+
21+
Use a third‑party SQLAlchemy type from `sqlalchemy-utils` to encrypt a string field. The key idea is that the field uses a full SQLAlchemy `Column(...)` via `sa_column`.
22+
23+
/// warning | Deprecation
24+
25+
`EncryptedType` is deprecated in SQLAlchemy‑Utils since version `0.36.6`. Use `StringEncryptedType` instead.
26+
27+
<a href="https://sqlalchemy-utils.readthedocs.io/en/latest/data_types.html#encryptedtype" class="external-link" target="_blank">See the upstream deprecation note</a>.
28+
29+
///
30+
31+
Note: `StringEncryptedType` provides explicit string type handling and better compatibility with SQLAlchemy 2.x.
32+
33+
{* ./docs_src/advanced/sa_column/tutorial001.py *}
34+
35+
### Key points
36+
37+
- The field uses `sa_column=Column(StringEncryptedType(...))`, which gives full control over the SQLAlchemy column while keeping a SQLModel model.
38+
- `EncryptedType` is deprecated; the example uses `StringEncryptedType` instead.
39+
- The type is initialized with keyword args (`key=...`, `engine=...`, `padding=...`) to match the installed package signature and avoid runtime errors.
40+
- The key is read from an environment variable. Don’t hard‑code secrets; use a secrets manager or environment variables, and ensure the same key is available for decryption.
41+
- In the DB, the value is stored encrypted (you’ll see ciphertext in the SQL echo and database); in Python it’s transparently decrypted when you access the field.
42+
- Indexing or filtering on encrypted ciphertext is typically not useful; design queries accordingly.
43+
44+
### Run it
45+
46+
To try the encrypted type example locally:
47+
48+
```bash
49+
python -m venv .venv
50+
source .venv/bin/activate
51+
pip install sqlmodel sqlalchemy-utils cryptography
52+
export SQLMODEL_ENCRYPTION_KEY="change-me"
53+
54+
# Copy the code from docs_src/advanced/sa_column/tutorial001.py into app.py
55+
python app.py
56+
```
57+
58+
After running, you should see "Database and tables created." and a `database_encrypted.db` SQLite file created in your working directory.
59+
60+
/// tip
61+
If you change the encryption key between runs, delete `database_encrypted.db` first so existing ciphertext doesn’t fail to decrypt with the new key.
62+
///
63+
64+
## Use case: enforcing uniqueness
65+
66+
- Single‑column unique: You can express this using `Field(unique=True)` in SQLModel or directly on the SQLAlchemy `Column(...)` when using `sa_column` for full control (e.g., to set a specific SQL type or name).
67+
- Composite unique (multiple columns): Prefer the idiomatic SQLAlchemy approach with `__table_args__` and `UniqueConstraint`.
68+
69+
{* ./docs_src/advanced/sa_column/tutorial002.py *}
70+
71+
### Key points
72+
73+
- Single‑column unique can be declared with `Field(unique=True)` (simple case) or on the SQLAlchemy `Column(..., unique=True)` via `sa_column` when you need full control over type/nullable/name. `Field(unique=True)` is shorthand for setting `unique=True` on the underlying SQLAlchemy column.
74+
- Composite unique constraints across multiple columns use `__table_args__ = (UniqueConstraint(...),)`. Naming the constraint helps during migrations and debugging.
75+
- Nullability matters: a unique, nullable column can usually store multiple NULLs (DB‑specific). Set `nullable=False` for strict uniqueness.
76+
- The example uses a separate DB file (`database_unique.db`) to avoid colliding with other tutorials.
77+
- Attempting to insert a duplicate `email` or the same `(name, secret_name)` pair will raise an integrity error.
78+
79+
### Run it
80+
81+
To try the unique constraints example locally on macOS with bash:
82+
83+
```bash
84+
python -m venv .venv
85+
source .venv/bin/activate
86+
pip install sqlmodel
87+
88+
# Copy the code from docs_src/advanced/sa_column/tutorial002.py into app.py
89+
python app.py
90+
```
91+
92+
After running, you should see the selected rows printed, with a database created at `database_unique.db`. Attempting to insert a duplicate `email` (single‑column unique) or a duplicate pair of `(name, secret_name)` (composite unique) would raise an integrity error.
93+
94+
## Important considerations
95+
96+
- **Prefer** built‑in `Field()` parameters (like `unique=True`, `index=True`, `default=...`) when they are sufficient.
97+
- **Use** `sa_column` only when you need full SQLAlchemy control over the column.
98+
- **Avoid conflicts** between `sa_column` and other `Field()` arguments that also affect the underlying column.
99+
- **Match your backend**: ensure the SQLAlchemy `Column(...)` you pass is compatible with your target database.
100+
- **PostgreSQL**: import and use types like `JSONB`, `ARRAY`, or `UUID` from `sqlalchemy.dialects.postgresql` when appropriate.
101+
102+
## See also
103+
104+
- SQLAlchemy Column docs: <a href="https://docs.sqlalchemy.org/en/20/core/metadata.html#sqlalchemy.schema.Column" class="external-link" target="_blank">`Column`</a>
105+
- Advanced SQLModel topics: <a href="./index.md" class="internal-link">Advanced User Guide</a>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import os
2+
from typing import Optional
3+
4+
from sqlalchemy import Column
5+
from sqlalchemy_utils.types.encrypted.encrypted_type import (
6+
AesEngine,
7+
StringEncryptedType,
8+
)
9+
from sqlmodel import Field, Session, SQLModel, create_engine, select
10+
11+
# In a real application, load this from a secure source (e.g., environment variable or secrets manager)
12+
ENCRYPTION_KEY = os.getenv("SQLMODEL_ENCRYPTION_KEY", "a-super-secret-key")
13+
14+
15+
class Hero(SQLModel, table=True):
16+
id: Optional[int] = Field(default=None, primary_key=True)
17+
name: str
18+
# Because the secret name should stay a secret
19+
secret_name: str = Field(
20+
sa_column=Column(
21+
StringEncryptedType(
22+
key=ENCRYPTION_KEY,
23+
engine=AesEngine,
24+
padding="pkcs5",
25+
)
26+
)
27+
)
28+
age: Optional[int] = None
29+
30+
31+
sqlite_file_name = "database_encrypted.db"
32+
sqlite_url = f"sqlite:///{sqlite_file_name}"
33+
engine = create_engine(sqlite_url, echo=True)
34+
35+
36+
def create_db_and_tables() -> None:
37+
SQLModel.metadata.create_all(engine)
38+
39+
40+
def create_heroes() -> None:
41+
hero_1 = Hero(name="Ted Lasso", secret_name="Coach")
42+
hero_2 = Hero(name="Roy Kent", secret_name="Roy")
43+
hero_3 = Hero(name="Keeley Jones", secret_name="Keeley", age=29)
44+
45+
with Session(engine) as session:
46+
session.add(hero_1)
47+
session.add(hero_2)
48+
session.add(hero_3)
49+
session.commit()
50+
51+
52+
def select_heroes() -> None:
53+
with Session(engine) as session:
54+
statement = select(Hero).where(Hero.name == "Ted Lasso")
55+
hero_1 = session.exec(statement).one()
56+
print("Hero 1:", hero_1)
57+
print("Hero 1 secret_name (decrypted in Python):", hero_1.secret_name)
58+
59+
statement = select(Hero).where(Hero.name == "Roy Kent")
60+
hero_2 = session.exec(statement).one()
61+
print("Hero 2:", hero_2)
62+
print("Hero 2 secret_name (decrypted in Python):", hero_2.secret_name)
63+
64+
65+
def main() -> None:
66+
create_db_and_tables()
67+
create_heroes()
68+
select_heroes()
69+
70+
71+
if __name__ == "__main__":
72+
main()
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from typing import Optional
2+
3+
from sqlalchemy import Column, String, UniqueConstraint
4+
from sqlmodel import Field, Session, SQLModel, create_engine, select
5+
6+
7+
class Hero(SQLModel, table=True):
8+
id: Optional[int] = Field(default=None, primary_key=True)
9+
# Single-column unique using sa_column for full control (e.g., explicit SQL type and nullability)
10+
email: str = Field(sa_column=Column(String(255), unique=True, nullable=False))
11+
name: str
12+
secret_name: str
13+
age: Optional[int] = None
14+
15+
# Composite (multi-column) unique constraint using the idiomatic SQLAlchemy approach
16+
__table_args__ = (
17+
UniqueConstraint("name", "secret_name", name="uq_hero_name_secret"),
18+
)
19+
20+
21+
sqlite_file_name = "database_unique.db"
22+
sqlite_url = f"sqlite:///{sqlite_file_name}"
23+
engine = create_engine(sqlite_url, echo=True)
24+
25+
26+
def create_db_and_tables() -> None:
27+
SQLModel.metadata.create_all(engine)
28+
29+
30+
def create_heroes() -> None:
31+
hero_1 = Hero(email="[email protected]", name="Ted Lasso", secret_name="Coach")
32+
hero_2 = Hero(email="[email protected]", name="Roy Kent", secret_name="Roy")
33+
hero_3 = Hero(
34+
email="[email protected]", name="Keeley Jones", secret_name="Keeley"
35+
)
36+
37+
with Session(engine) as session:
38+
session.add(hero_1)
39+
session.add(hero_2)
40+
session.add(hero_3)
41+
session.commit()
42+
43+
44+
def select_heroes() -> None:
45+
with Session(engine) as session:
46+
statement = select(Hero).where(Hero.email == "[email protected]")
47+
hero_1 = session.exec(statement).one()
48+
print("Hero 1:", hero_1)
49+
50+
statement = select(Hero).where(
51+
(Hero.name == "Roy Kent") & (Hero.secret_name == "Roy")
52+
)
53+
hero_2 = session.exec(statement).one()
54+
print("Hero 2:", hero_2)
55+
56+
57+
def main() -> None:
58+
create_db_and_tables()
59+
create_heroes()
60+
select_heroes()
61+
62+
63+
if __name__ == "__main__":
64+
main()

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ nav:
128128
- advanced/index.md
129129
- advanced/decimal.md
130130
- advanced/uuid.md
131+
- advanced/sa-column.md
131132
- Resources:
132133
- resources/index.md
133134
- help.md

0 commit comments

Comments
 (0)