Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions docs/tutorial/fastapi/update-extra-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
# Update with Extra Data (Hashed Passwords) with FastAPI

In the previous chapter I explained to you how to update data in the database from input data coming from a **FastAPI** *path operation*.

Now I'll explain to you how to add **extra data**, additional to the input data, when updating or creating a model object.

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**.

## Password Hashing

Let's imagine that each hero in our system also has a **password**.

We should never store the password in plain text in the database, we should only stored a **hashed version** of it.

"**Hashing**" means converting some content (a password in this case) into a sequence of bytes (just a string) that looks like gibberish.

Whenever you pass exactly the same content (exactly the same password) you get exactly the same gibberish.

But you **cannot convert** from the gibberish **back to the password**.

### Why use Password Hashing

If your database is stolen, the thief won't have your users' **plaintext passwords**, only the hashes.

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).

/// tip

You could use <a href="https://passlib.readthedocs.io/en/stable/" class="external-link" target="_blank">passlib</a> to hash passwords.

In this example we will use a fake hashing function to focus on the data changes. 🤡

///

## Update Models with Extra Data

The `Hero` table model will now store a new field `hashed_password`.

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.

```Python hl_lines="11 15 26"
# Code above omitted 👆

{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:7-30]!}

# Code below omitted 👇
```

/// details | 👀 Full file preview

```Python
{!./docs_src/tutorial/fastapi/update/tutorial002.py!}
```

///

When a client is creating a new hero, they will send the `password` in the request body.

And when they are updating a hero, they could also send the `password` in the request body to update it.

## Hash the Password

The app will receive the data from the client using the `HeroCreate` model.

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.

```Python hl_lines="11"
# Code above omitted 👆

{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:44-46]!}

# Code here omitted 👈

{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:57-59]!}

# Code below omitted 👇
```

/// details | 👀 Full file preview

```Python
{!./docs_src/tutorial/fastapi/update/tutorial002.py!}
```

///

## Create an Object with Extra Data

Now we need to create the database hero.

In previous examples, we have used something like:

```Python
db_hero = Hero.model_validate(hero)
```

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.

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.

`Hero` actually has a `hashed_password`, but we are not providing it. We need a way to provide it...

### Dictionary Update

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:

```Python hl_lines="14"
db_user_dict = {
"name": "Deadpond",
"secret_name": "Dive Wilson",
"age": None,
}

hashed_password = "fakehashedpassword"

extra_data = {
"hashed_password": hashed_password,
"age": 32,
}

db_user_dict.update(extra_data)

print(db_user_dict)

# {
# "name": "Deadpond",
# "secret_name": "Dive Wilson",
# "age": 32,
# "hashed_password": "fakehashedpassword",
# }
```

This `update` method allows us to add and override things in the original dictionary with the data from another dictionary.

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**.

### Create a Model Object with Extra Data

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:

```Python hl_lines="8"
# Code above omitted 👆

{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:57-66]!}

# Code below omitted 👇
```

/// details | 👀 Full file preview

```Python
{!./docs_src/tutorial/fastapi/update/tutorial002.py!}
```

///

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`.

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`.

If there's a field in both `hero` and the `extra_data`, **the value from the `extra_data` passed to `update` will take precedence**.

## Update with Extra Data

Now let's say we want to **update a hero** that already exists in the database.

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.

```Python hl_lines="9"
# Code above omitted 👆

{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:85-91]!}

# Code below omitted 👇
```

/// details | 👀 Full file preview

```Python
{!./docs_src/tutorial/fastapi/update/tutorial002.py!}
```

///

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`.

Then we can put that `hashed_password` in a dictionary.

And then we can update the `db_hero` object using the method `db_hero.sqlmodel_update()`.

It takes a model object or dictionary with the data to update the object and also an **additional `update` argument** with extra data.

```Python hl_lines="15"
# Code above omitted 👆

{!./docs_src/tutorial/fastapi/update/tutorial002.py[ln:85-101]!}

# Code below omitted 👇
```

/// details | 👀 Full file preview

```Python
{!./docs_src/tutorial/fastapi/update/tutorial002.py!}
```

///

/// tip

The method `db_hero.sqlmodel_update()` was added in SQLModel 0.0.16. 😎

///

## Recap

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. 🤓
21 changes: 10 additions & 11 deletions docs/tutorial/fastapi/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,13 @@ Then we use that to get the data that was actually sent by the client:

/// tip
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.
///

## Update the Hero in the Database

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()`.
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`.

```Python hl_lines="10-11"
```Python hl_lines="10"
# Code above omitted 👆

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

///

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**.
/// tip

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

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

...would be more or less equivalent to:
///

```Python
db_hero.name = "Deadpuddle"
```
The method `db_hero.sqlmodel_update()` takes an argument with another model object or a dictionary.

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.

## Remove Fields

Expand Down
3 changes: 1 addition & 2 deletions docs_src/tutorial/fastapi/update/tutorial001.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,7 @@ def update_hero(hero_id: int, hero: HeroUpdate):
if not db_hero:
raise HTTPException(status_code=404, detail="Hero not found")
hero_data = hero.model_dump(exclude_unset=True)
for key, value in hero_data.items():
setattr(db_hero, key, value)
db_hero.sqlmodel_update(hero_data)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
Expand Down
3 changes: 1 addition & 2 deletions docs_src/tutorial/fastapi/update/tutorial001_py310.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,7 @@ def update_hero(hero_id: int, hero: HeroUpdate):
if not db_hero:
raise HTTPException(status_code=404, detail="Hero not found")
hero_data = hero.model_dump(exclude_unset=True)
for key, value in hero_data.items():
setattr(db_hero, key, value)
db_hero.sqlmodel_update(hero_data)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
Expand Down
3 changes: 1 addition & 2 deletions docs_src/tutorial/fastapi/update/tutorial001_py39.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,7 @@ def update_hero(hero_id: int, hero: HeroUpdate):
if not db_hero:
raise HTTPException(status_code=404, detail="Hero not found")
hero_data = hero.model_dump(exclude_unset=True)
for key, value in hero_data.items():
setattr(db_hero, key, value)
db_hero.sqlmodel_update(hero_data)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
Expand Down
101 changes: 101 additions & 0 deletions docs_src/tutorial/fastapi/update/tutorial002.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from typing import List, Optional

from fastapi import FastAPI, HTTPException, Query
from sqlmodel import Field, Session, SQLModel, create_engine, select


class HeroBase(SQLModel):
name: str = Field(index=True)
secret_name: str
age: Optional[int] = Field(default=None, index=True)


class Hero(HeroBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
hashed_password: str = Field()


class HeroCreate(HeroBase):
password: str


class HeroRead(HeroBase):
id: int


class HeroUpdate(SQLModel):
name: Optional[str] = None
secret_name: Optional[str] = None
age: Optional[int] = None
password: Optional[str] = None


sqlite_file_name = "database.db"
sqlite_url = f"sqlite:///{sqlite_file_name}"

connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, echo=True, connect_args=connect_args)


def create_db_and_tables():
SQLModel.metadata.create_all(engine)


def hash_password(password: str) -> str:
# Use something like passlib here
return f"not really hashed {password} hehehe"


app = FastAPI()


@app.on_event("startup")
def on_startup():
create_db_and_tables()


@app.post("/heroes/", response_model=HeroRead)
def create_hero(hero: HeroCreate):
hashed_password = hash_password(hero.password)
with Session(engine) as session:
extra_data = {"hashed_password": hashed_password}
db_hero = Hero.model_validate(hero, update=extra_data)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
return db_hero


@app.get("/heroes/", response_model=List[HeroRead])
def read_heroes(offset: int = 0, limit: int = Query(default=100, le=100)):
with Session(engine) as session:
heroes = session.exec(select(Hero).offset(offset).limit(limit)).all()
return heroes


@app.get("/heroes/{hero_id}", response_model=HeroRead)
def read_hero(hero_id: int):
with Session(engine) as session:
hero = session.get(Hero, hero_id)
if not hero:
raise HTTPException(status_code=404, detail="Hero not found")
return hero


@app.patch("/heroes/{hero_id}", response_model=HeroRead)
def update_hero(hero_id: int, hero: HeroUpdate):
with Session(engine) as session:
db_hero = session.get(Hero, hero_id)
if not db_hero:
raise HTTPException(status_code=404, detail="Hero not found")
hero_data = hero.model_dump(exclude_unset=True)
extra_data = {}
if "password" in hero_data:
password = hero_data["password"]
hashed_password = hash_password(password)
extra_data["hashed_password"] = hashed_password
db_hero.sqlmodel_update(hero_data, update=extra_data)
session.add(db_hero)
session.commit()
session.refresh(db_hero)
return db_hero
Loading