Skip to content

Commit fa12c5d

Browse files
authored
✨ Add new method sqlmodel_update() to update models in place, including an update parameter for extra data (#804)
1 parent 7fec884 commit fa12c5d

File tree

15 files changed

+1871
-26
lines changed

15 files changed

+1871
-26
lines changed
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# Update with Extra Data (Hashed Passwords) with FastAPI
2+
3+
In the previous chapter I explained to you how to update data in the database from input data coming from a **FastAPI** *path operation*.
4+
5+
Now I'll explain to you how to add **extra data**, additional to the input data, when updating or creating a model object.
6+
7+
This is particularly useful when you need to **generate some data** in your code that is **not coming from the client**, but you need to store it in the database. For example, to store a **hashed password**.
8+
9+
## Password Hashing
10+
11+
Let's imagine that each hero in our system also has a **password**.
12+
13+
We should never store the password in plain text in the database, we should only stored a **hashed version** of it.
14+
15+
"**Hashing**" means converting some content (a password in this case) into a sequence of bytes (just a string) that looks like gibberish.
16+
17+
Whenever you pass exactly the same content (exactly the same password) you get exactly the same gibberish.
18+
19+
But you **cannot convert** from the gibberish **back to the password**.
20+
21+
### Why use Password Hashing
22+
23+
If your database is stolen, the thief won't have your users' **plaintext passwords**, only the hashes.
24+
25+
So, the thief won't be able to try to use that password in another system (as many users use the same password everywhere, this would be dangerous).
26+
27+
/// tip
28+
29+
You could use <a href="https://passlib.readthedocs.io/en/stable/" class="external-link" target="_blank">passlib</a> to hash passwords.
30+
31+
In this example we will use a fake hashing function to focus on the data changes. 🤡
32+
33+
///
34+
35+
## Update Models with Extra Data
36+
37+
The `Hero` table model will now store a new field `hashed_password`.
38+
39+
And the data models for `HeroCreate` and `HeroUpdate` will also have a new field `password` that will contain the plain text password sent by clients.
40+
41+
```Python hl_lines="11 15 26"
42+
# Code above omitted 👆
43+
44+
{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:7-30]!}
45+
46+
# Code below omitted 👇
47+
```
48+
49+
/// details | 👀 Full file preview
50+
51+
```Python
52+
{!./docs_src/tutorial/fastapi/update/tutorial002.py!}
53+
```
54+
55+
///
56+
57+
When a client is creating a new hero, they will send the `password` in the request body.
58+
59+
And when they are updating a hero, they could also send the `password` in the request body to update it.
60+
61+
## Hash the Password
62+
63+
The app will receive the data from the client using the `HeroCreate` model.
64+
65+
This contains the `password` field with the plain text password, and we cannot use that one. So we need to generate a hash from it.
66+
67+
```Python hl_lines="11"
68+
# Code above omitted 👆
69+
70+
{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:44-46]!}
71+
72+
# Code here omitted 👈
73+
74+
{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:57-59]!}
75+
76+
# Code below omitted 👇
77+
```
78+
79+
/// details | 👀 Full file preview
80+
81+
```Python
82+
{!./docs_src/tutorial/fastapi/update/tutorial002.py!}
83+
```
84+
85+
///
86+
87+
## Create an Object with Extra Data
88+
89+
Now we need to create the database hero.
90+
91+
In previous examples, we have used something like:
92+
93+
```Python
94+
db_hero = Hero.model_validate(hero)
95+
```
96+
97+
This creates a `Hero` (which is a *table model*) object from the `HeroCreate` (which is a *data model*) object that we received in the request.
98+
99+
And this is all good... but as `Hero` doesn't have a field `password`, it won't be extracted from the object `HeroCreate` that has it.
100+
101+
`Hero` actually has a `hashed_password`, but we are not providing it. We need a way to provide it...
102+
103+
### Dictionary Update
104+
105+
Let's pause for a second to check this, when working with dictionaries, there's a way to `update` a dictionary with extra data from another dictionary, something like this:
106+
107+
```Python hl_lines="14"
108+
db_user_dict = {
109+
"name": "Deadpond",
110+
"secret_name": "Dive Wilson",
111+
"age": None,
112+
}
113+
114+
hashed_password = "fakehashedpassword"
115+
116+
extra_data = {
117+
"hashed_password": hashed_password,
118+
"age": 32,
119+
}
120+
121+
db_user_dict.update(extra_data)
122+
123+
print(db_user_dict)
124+
125+
# {
126+
# "name": "Deadpond",
127+
# "secret_name": "Dive Wilson",
128+
# "age": 32,
129+
# "hashed_password": "fakehashedpassword",
130+
# }
131+
```
132+
133+
This `update` method allows us to add and override things in the original dictionary with the data from another dictionary.
134+
135+
So now, `db_user_dict` has the updated `age` field with `32` instead of `None` and more importantly, **it has the new `hashed_password` field**.
136+
137+
### Create a Model Object with Extra Data
138+
139+
Similar to how dictionaries have an `update` method, **SQLModel** models have a parameter `update` in `Hero.model_validate()` that takes a dictionary with extra data, or data that should take precedence:
140+
141+
```Python hl_lines="8"
142+
# Code above omitted 👆
143+
144+
{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:57-66]!}
145+
146+
# Code below omitted 👇
147+
```
148+
149+
/// details | 👀 Full file preview
150+
151+
```Python
152+
{!./docs_src/tutorial/fastapi/update/tutorial002.py!}
153+
```
154+
155+
///
156+
157+
Now, `db_hero` (which is a *table model* `Hero`) will extract its values from `hero` (which is a *data model* `HeroCreate`), and then it will **`update`** its values with the extra data from the dictionary `extra_data`.
158+
159+
It will only take the fields defined in `Hero`, so **it will not take the `password`** from `HeroCreate`. And it will also **take its values** from the **dictionary passed to the `update`** parameter, in this case, the `hashed_password`.
160+
161+
If there's a field in both `hero` and the `extra_data`, **the value from the `extra_data` passed to `update` will take precedence**.
162+
163+
## Update with Extra Data
164+
165+
Now let's say we want to **update a hero** that already exists in the database.
166+
167+
The same way as before, to avoid removing existing data, we will use `exclude_unset=True` when calling `hero.model_dump()`, to get a dictionary with only the data sent by the client.
168+
169+
```Python hl_lines="9"
170+
# Code above omitted 👆
171+
172+
{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:85-91]!}
173+
174+
# Code below omitted 👇
175+
```
176+
177+
/// details | 👀 Full file preview
178+
179+
```Python
180+
{!./docs_src/tutorial/fastapi/update/tutorial002.py!}
181+
```
182+
183+
///
184+
185+
Now, this `hero_data` dictionary could contain a `password`. We need to check it, and if it's there, we need to generate the `hashed_password`.
186+
187+
Then we can put that `hashed_password` in a dictionary.
188+
189+
And then we can update the `db_hero` object using the method `db_hero.sqlmodel_update()`.
190+
191+
It takes a model object or dictionary with the data to update the object and also an **additional `update` argument** with extra data.
192+
193+
```Python hl_lines="15"
194+
# Code above omitted 👆
195+
196+
{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:85-101]!}
197+
198+
# Code below omitted 👇
199+
```
200+
201+
/// details | 👀 Full file preview
202+
203+
```Python
204+
{!./docs_src/tutorial/fastapi/update/tutorial002.py!}
205+
```
206+
207+
///
208+
209+
/// tip
210+
211+
The method `db_hero.sqlmodel_update()` was added in SQLModel 0.0.16. 😎
212+
213+
///
214+
215+
## Recap
216+
217+
You can use the `update` parameter in `Hero.model_validate()` to provide extra data when creating a new object and `Hero.sqlmodel_update()` to provide extra data when updating an existing object. 🤓

docs/tutorial/fastapi/update.md

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,12 +154,13 @@ Then we use that to get the data that was actually sent by the client:
154154

155155
/// tip
156156
Before SQLModel 0.0.14, the method was called `hero.dict(exclude_unset=True)`, but it was renamed to `hero.model_dump(exclude_unset=True)` to be consistent with Pydantic v2.
157+
///
157158

158159
## Update the Hero in the Database
159160

160-
Now that we have a **dictionary with the data sent by the client**, we can iterate for each one of the keys and the values, and then we set them in the database hero model `db_hero` using `setattr()`.
161+
Now that we have a **dictionary with the data sent by the client**, we can use the method `db_hero.sqlmodel_update()` to update the object `db_hero`.
161162

162-
```Python hl_lines="10-11"
163+
```Python hl_lines="10"
163164
# Code above omitted 👆
164165

165166
{!./docs_src/tutorial/fastapi/update/tutorial001.py[ln:76-91]!}
@@ -175,19 +176,17 @@ Now that we have a **dictionary with the data sent by the client**, we can itera
175176

176177
///
177178

178-
If you are not familiar with that `setattr()`, it takes an object, like the `db_hero`, then an attribute name (`key`), that in our case could be `"name"`, and a value (`value`). And then it **sets the attribute with that name to the value**.
179+
/// tip
179180

180-
So, if `key` was `"name"` and `value` was `"Deadpuddle"`, then this code:
181+
The method `db_hero.sqlmodel_update()` was added in SQLModel 0.0.16. 🤓
181182

182-
```Python
183-
setattr(db_hero, key, value)
184-
```
183+
Before that, you would need to manually get the values and set them using `setattr()`.
185184

186-
...would be more or less equivalent to:
185+
///
187186

188-
```Python
189-
db_hero.name = "Deadpuddle"
190-
```
187+
The method `db_hero.sqlmodel_update()` takes an argument with another model object or a dictionary.
188+
189+
For each of the fields in the **original** model object (`db_hero` in this example), it checks if the field is available in the **argument** (`hero_data` in this example) and then updates it with the provided value.
191190

192191
## Remove Fields
193192

docs_src/tutorial/fastapi/update/tutorial001.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,7 @@ def update_hero(hero_id: int, hero: HeroUpdate):
8080
if not db_hero:
8181
raise HTTPException(status_code=404, detail="Hero not found")
8282
hero_data = hero.model_dump(exclude_unset=True)
83-
for key, value in hero_data.items():
84-
setattr(db_hero, key, value)
83+
db_hero.sqlmodel_update(hero_data)
8584
session.add(db_hero)
8685
session.commit()
8786
session.refresh(db_hero)

docs_src/tutorial/fastapi/update/tutorial001_py310.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,7 @@ def update_hero(hero_id: int, hero: HeroUpdate):
7878
if not db_hero:
7979
raise HTTPException(status_code=404, detail="Hero not found")
8080
hero_data = hero.model_dump(exclude_unset=True)
81-
for key, value in hero_data.items():
82-
setattr(db_hero, key, value)
81+
db_hero.sqlmodel_update(hero_data)
8382
session.add(db_hero)
8483
session.commit()
8584
session.refresh(db_hero)

docs_src/tutorial/fastapi/update/tutorial001_py39.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,7 @@ def update_hero(hero_id: int, hero: HeroUpdate):
8080
if not db_hero:
8181
raise HTTPException(status_code=404, detail="Hero not found")
8282
hero_data = hero.model_dump(exclude_unset=True)
83-
for key, value in hero_data.items():
84-
setattr(db_hero, key, value)
83+
db_hero.sqlmodel_update(hero_data)
8584
session.add(db_hero)
8685
session.commit()
8786
session.refresh(db_hero)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from typing import List, Optional
2+
3+
from fastapi import FastAPI, HTTPException, Query
4+
from sqlmodel import Field, Session, SQLModel, create_engine, select
5+
6+
7+
class HeroBase(SQLModel):
8+
name: str = Field(index=True)
9+
secret_name: str
10+
age: Optional[int] = Field(default=None, index=True)
11+
12+
13+
class Hero(HeroBase, table=True):
14+
id: Optional[int] = Field(default=None, primary_key=True)
15+
hashed_password: str = Field()
16+
17+
18+
class HeroCreate(HeroBase):
19+
password: str
20+
21+
22+
class HeroRead(HeroBase):
23+
id: int
24+
25+
26+
class HeroUpdate(SQLModel):
27+
name: Optional[str] = None
28+
secret_name: Optional[str] = None
29+
age: Optional[int] = None
30+
password: Optional[str] = None
31+
32+
33+
sqlite_file_name = "database.db"
34+
sqlite_url = f"sqlite:///{sqlite_file_name}"
35+
36+
connect_args = {"check_same_thread": False}
37+
engine = create_engine(sqlite_url, echo=True, connect_args=connect_args)
38+
39+
40+
def create_db_and_tables():
41+
SQLModel.metadata.create_all(engine)
42+
43+
44+
def hash_password(password: str) -> str:
45+
# Use something like passlib here
46+
return f"not really hashed {password} hehehe"
47+
48+
49+
app = FastAPI()
50+
51+
52+
@app.on_event("startup")
53+
def on_startup():
54+
create_db_and_tables()
55+
56+
57+
@app.post("/heroes/", response_model=HeroRead)
58+
def create_hero(hero: HeroCreate):
59+
hashed_password = hash_password(hero.password)
60+
with Session(engine) as session:
61+
extra_data = {"hashed_password": hashed_password}
62+
db_hero = Hero.model_validate(hero, update=extra_data)
63+
session.add(db_hero)
64+
session.commit()
65+
session.refresh(db_hero)
66+
return db_hero
67+
68+
69+
@app.get("/heroes/", response_model=List[HeroRead])
70+
def read_heroes(offset: int = 0, limit: int = Query(default=100, le=100)):
71+
with Session(engine) as session:
72+
heroes = session.exec(select(Hero).offset(offset).limit(limit)).all()
73+
return heroes
74+
75+
76+
@app.get("/heroes/{hero_id}", response_model=HeroRead)
77+
def read_hero(hero_id: int):
78+
with Session(engine) as session:
79+
hero = session.get(Hero, hero_id)
80+
if not hero:
81+
raise HTTPException(status_code=404, detail="Hero not found")
82+
return hero
83+
84+
85+
@app.patch("/heroes/{hero_id}", response_model=HeroRead)
86+
def update_hero(hero_id: int, hero: HeroUpdate):
87+
with Session(engine) as session:
88+
db_hero = session.get(Hero, hero_id)
89+
if not db_hero:
90+
raise HTTPException(status_code=404, detail="Hero not found")
91+
hero_data = hero.model_dump(exclude_unset=True)
92+
extra_data = {}
93+
if "password" in hero_data:
94+
password = hero_data["password"]
95+
hashed_password = hash_password(password)
96+
extra_data["hashed_password"] = hashed_password
97+
db_hero.sqlmodel_update(hero_data, update=extra_data)
98+
session.add(db_hero)
99+
session.commit()
100+
session.refresh(db_hero)
101+
return db_hero

0 commit comments

Comments
 (0)