]> git.ipfire.org Git - thirdparty/fastapi/sqlmodel.git/commitdiff
✨ Add new method `sqlmodel_update()` to update models in place, including an `update...
authorSebastián Ramírez <tiangolo@gmail.com>
Sat, 17 Feb 2024 13:49:39 +0000 (14:49 +0100)
committerGitHub <noreply@github.com>
Sat, 17 Feb 2024 13:49:39 +0000 (14:49 +0100)
15 files changed:
docs/tutorial/fastapi/update-extra-data.md [new file with mode: 0644]
docs/tutorial/fastapi/update.md
docs_src/tutorial/fastapi/update/tutorial001.py
docs_src/tutorial/fastapi/update/tutorial001_py310.py
docs_src/tutorial/fastapi/update/tutorial001_py39.py
docs_src/tutorial/fastapi/update/tutorial002.py [new file with mode: 0644]
docs_src/tutorial/fastapi/update/tutorial002_py310.py [new file with mode: 0644]
docs_src/tutorial/fastapi/update/tutorial002_py39.py [new file with mode: 0644]
mkdocs.yml
sqlmodel/_compat.py
sqlmodel/main.py
tests/test_fields_set.py
tests/test_tutorial/test_fastapi/test_update/test_tutorial002.py [new file with mode: 0644]
tests/test_tutorial/test_fastapi/test_update/test_tutorial002_py310.py [new file with mode: 0644]
tests/test_tutorial/test_fastapi/test_update/test_tutorial002_py39.py [new file with mode: 0644]

diff --git a/docs/tutorial/fastapi/update-extra-data.md b/docs/tutorial/fastapi/update-extra-data.md
new file mode 100644 (file)
index 0000000..71d9b9c
--- /dev/null
@@ -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. 🤓
index cfcf8a98e79dc1c8f809699270729289bd159db3..be4d90df16b2a4905f0736ed550083b6f163987e 100644 (file)
@@ -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]!}
@@ -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
 
index 5639638d5c45466b6ca7f09a77f0077830044a4c..feab25cc1310c9acf720c6163199fb9f1b62642e 100644 (file)
@@ -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)
index 4faf266f84e197fe0083807a4f3fa6552b32dc15..02bec2e0dbd7901c712ab4db32c2652fd8d95c2b 100644 (file)
@@ -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)
index b0daa87880d012f51e2ac35a7616419ba9253050..241d205c05fe288379fdc539010dc516e54b6b93 100644 (file)
@@ -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)
diff --git a/docs_src/tutorial/fastapi/update/tutorial002.py b/docs_src/tutorial/fastapi/update/tutorial002.py
new file mode 100644 (file)
index 0000000..1333654
--- /dev/null
@@ -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
diff --git a/docs_src/tutorial/fastapi/update/tutorial002_py310.py b/docs_src/tutorial/fastapi/update/tutorial002_py310.py
new file mode 100644 (file)
index 0000000..84efb3d
--- /dev/null
@@ -0,0 +1,99 @@
+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: int | None = Field(default=None, index=True)
+
+
+class Hero(HeroBase, table=True):
+    id: int | None = Field(default=None, primary_key=True)
+    hashed_password: str = Field()
+
+
+class HeroCreate(HeroBase):
+    password: str
+
+
+class HeroRead(HeroBase):
+    id: int
+
+
+class HeroUpdate(SQLModel):
+    name: str | None = None
+    secret_name: str | None = None
+    age: int | None = None
+    password: str | None = 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)
+        update_data = {}
+        if "password" in hero_data:
+            password = hero_data["password"]
+            hashed_password = hash_password(password)
+            update_data["hashed_password"] = hashed_password
+        db_hero.sqlmodel_update(hero_data, update=update_data)
+        session.add(db_hero)
+        session.commit()
+        session.refresh(db_hero)
+        return db_hero
diff --git a/docs_src/tutorial/fastapi/update/tutorial002_py39.py b/docs_src/tutorial/fastapi/update/tutorial002_py39.py
new file mode 100644 (file)
index 0000000..72751da
--- /dev/null
@@ -0,0 +1,101 @@
+from typing import 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)
+        update_data = {}
+        if "password" in hero_data:
+            password = hero_data["password"]
+            hashed_password = hash_password(password)
+            update_data["hashed_password"] = hashed_password
+        db_hero.sqlmodel_update(hero_data, update=update_data)
+        session.add(db_hero)
+        session.commit()
+        session.refresh(db_hero)
+        return db_hero
index ce98f1524e952430085d06945228149ab1e4deb8..fa85062a8be6dafd310a100dedcd7c5153659260 100644 (file)
@@ -90,6 +90,7 @@ nav:
       - tutorial/fastapi/read-one.md
       - tutorial/fastapi/limit-and-offset.md
       - tutorial/fastapi/update.md
+      - tutorial/fastapi/update-extra-data.md
       - tutorial/fastapi/delete.md
       - tutorial/fastapi/session-with-dependency.md
       - tutorial/fastapi/teams.md
index 76771ce7ff4210a0a3925872ffd4da48e666ca3c..072d2b0f58e357519134c7e333919e99e880f416 100644 (file)
@@ -6,6 +6,7 @@ from typing import (
     TYPE_CHECKING,
     AbstractSet,
     Any,
+    Callable,
     Dict,
     ForwardRef,
     Generator,
@@ -18,6 +19,7 @@ from typing import (
 )
 
 from pydantic import VERSION as PYDANTIC_VERSION
+from pydantic import BaseModel
 from pydantic.fields import FieldInfo
 from typing_extensions import get_args, get_origin
 
@@ -46,9 +48,11 @@ class ObjectWithUpdateWrapper:
     update: Dict[str, Any]
 
     def __getattribute__(self, __name: str) -> Any:
-        if __name in self.update:
-            return self.update[__name]
-        return getattr(self.obj, __name)
+        update = super().__getattribute__("update")
+        obj = super().__getattribute__("obj")
+        if __name in update:
+            return update[__name]
+        return getattr(obj, __name)
 
 
 def _is_union_type(t: Any) -> bool:
@@ -94,9 +98,14 @@ if IS_PYDANTIC_V2:
     ) -> None:
         model.model_config[parameter] = value  # type: ignore[literal-required]
 
-    def get_model_fields(model: InstanceOrType["SQLModel"]) -> Dict[str, "FieldInfo"]:
+    def get_model_fields(model: InstanceOrType[BaseModel]) -> Dict[str, "FieldInfo"]:
         return model.model_fields
 
+    def get_fields_set(
+        object: InstanceOrType["SQLModel"],
+    ) -> Union[Set[str], Callable[[BaseModel], Set[str]]]:
+        return object.model_fields_set
+
     def init_pydantic_private_attrs(new_object: InstanceOrType["SQLModel"]) -> None:
         object.__setattr__(new_object, "__pydantic_fields_set__", set())
         object.__setattr__(new_object, "__pydantic_extra__", None)
@@ -384,9 +393,14 @@ else:
     ) -> None:
         setattr(model.__config__, parameter, value)  # type: ignore
 
-    def get_model_fields(model: InstanceOrType["SQLModel"]) -> Dict[str, "FieldInfo"]:
+    def get_model_fields(model: InstanceOrType[BaseModel]) -> Dict[str, "FieldInfo"]:
         return model.__fields__  # type: ignore
 
+    def get_fields_set(
+        object: InstanceOrType["SQLModel"],
+    ) -> Union[Set[str], Callable[[BaseModel], Set[str]]]:
+        return object.__fields_set__
+
     def init_pydantic_private_attrs(new_object: InstanceOrType["SQLModel"]) -> None:
         object.__setattr__(new_object, "__fields_set__", set())
 
index fec3bc79066535930a30cbd6143d23132f9b2e01..9e8330d69d5db793f2360e2424d832c701e2a646 100644 (file)
@@ -758,7 +758,6 @@ class SQLModel(BaseModel, metaclass=SQLModelMetaclass, registry=default_registry
             update=update,
         )
 
-    # TODO: remove when deprecating Pydantic v1, only for compatibility
     def model_dump(
         self,
         *,
@@ -869,3 +868,32 @@ class SQLModel(BaseModel, metaclass=SQLModelMetaclass, registry=default_registry
             exclude_unset=exclude_unset,
             update=update,
         )
+
+    def sqlmodel_update(
+        self: _TSQLModel,
+        obj: Union[Dict[str, Any], BaseModel],
+        *,
+        update: Union[Dict[str, Any], None] = None,
+    ) -> _TSQLModel:
+        use_update = (update or {}).copy()
+        if isinstance(obj, dict):
+            for key, value in {**obj, **use_update}.items():
+                if key in get_model_fields(self):
+                    setattr(self, key, value)
+        elif isinstance(obj, BaseModel):
+            for key in get_model_fields(obj):
+                if key in use_update:
+                    value = use_update.pop(key)
+                else:
+                    value = getattr(obj, key)
+                setattr(self, key, value)
+            for remaining_key in use_update:
+                if remaining_key in get_model_fields(self):
+                    value = use_update.pop(remaining_key)
+                    setattr(self, remaining_key, value)
+        else:
+            raise ValueError(
+                "Can't use sqlmodel_update() with something that "
+                f"is not a dict or SQLModel or Pydantic model: {obj}"
+            )
+        return self
index 56f4ad014419030c1f695e457ec3c5e99c25bafa..e0bd8cba76115480822934284530c51cec9d0ac9 100644 (file)
@@ -1,6 +1,7 @@
 from datetime import datetime, timedelta
 
 from sqlmodel import Field, SQLModel
+from sqlmodel._compat import get_fields_set
 
 
 def test_fields_set():
@@ -10,12 +11,12 @@ def test_fields_set():
         last_updated: datetime = Field(default_factory=datetime.now)
 
     user = User(username="bob")
-    assert user.__fields_set__ == {"username"}
+    assert get_fields_set(user) == {"username"}
     user = User(username="bob", email="bob@test.com")
-    assert user.__fields_set__ == {"username", "email"}
+    assert get_fields_set(user) == {"username", "email"}
     user = User(
         username="bob",
         email="bob@test.com",
         last_updated=datetime.now() - timedelta(days=1),
     )
-    assert user.__fields_set__ == {"username", "email", "last_updated"}
+    assert get_fields_set(user) == {"username", "email", "last_updated"}
diff --git a/tests/test_tutorial/test_fastapi/test_update/test_tutorial002.py b/tests/test_tutorial/test_fastapi/test_update/test_tutorial002.py
new file mode 100644 (file)
index 0000000..21ca74e
--- /dev/null
@@ -0,0 +1,427 @@
+from dirty_equals import IsDict
+from fastapi.testclient import TestClient
+from sqlmodel import Session, create_engine
+from sqlmodel.pool import StaticPool
+
+
+def test_tutorial(clear_sqlmodel):
+    from docs_src.tutorial.fastapi.update import tutorial002 as mod
+
+    mod.sqlite_url = "sqlite://"
+    mod.engine = create_engine(
+        mod.sqlite_url, connect_args=mod.connect_args, poolclass=StaticPool
+    )
+
+    with TestClient(mod.app) as client:
+        hero1_data = {
+            "name": "Deadpond",
+            "secret_name": "Dive Wilson",
+            "password": "chimichanga",
+        }
+        hero2_data = {
+            "name": "Spider-Boy",
+            "secret_name": "Pedro Parqueador",
+            "id": 9000,
+            "password": "auntmay",
+        }
+        hero3_data = {
+            "name": "Rusty-Man",
+            "secret_name": "Tommy Sharp",
+            "age": 48,
+            "password": "bestpreventer",
+        }
+        response = client.post("/heroes/", json=hero1_data)
+        assert response.status_code == 200, response.text
+        hero1 = response.json()
+        assert "password" not in hero1
+        assert "hashed_password" not in hero1
+        hero1_id = hero1["id"]
+        response = client.post("/heroes/", json=hero2_data)
+        assert response.status_code == 200, response.text
+        hero2 = response.json()
+        hero2_id = hero2["id"]
+        response = client.post("/heroes/", json=hero3_data)
+        assert response.status_code == 200, response.text
+        hero3 = response.json()
+        hero3_id = hero3["id"]
+        response = client.get(f"/heroes/{hero2_id}")
+        assert response.status_code == 200, response.text
+        fetched_hero2 = response.json()
+        assert "password" not in fetched_hero2
+        assert "hashed_password" not in fetched_hero2
+        response = client.get("/heroes/9000")
+        assert response.status_code == 404, response.text
+        response = client.get("/heroes/")
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert len(data) == 3
+        for response_hero in data:
+            assert "password" not in response_hero
+            assert "hashed_password" not in response_hero
+
+        # Test hashed passwords
+        with Session(mod.engine) as session:
+            hero1_db = session.get(mod.Hero, hero1_id)
+            assert hero1_db
+            assert not hasattr(hero1_db, "password")
+            assert hero1_db.hashed_password == "not really hashed chimichanga hehehe"
+            hero2_db = session.get(mod.Hero, hero2_id)
+            assert hero2_db
+            assert not hasattr(hero2_db, "password")
+            assert hero2_db.hashed_password == "not really hashed auntmay hehehe"
+            hero3_db = session.get(mod.Hero, hero3_id)
+            assert hero3_db
+            assert not hasattr(hero3_db, "password")
+            assert hero3_db.hashed_password == "not really hashed bestpreventer hehehe"
+
+        response = client.patch(
+            f"/heroes/{hero2_id}", json={"secret_name": "Spider-Youngster"}
+        )
+        data = response.json()
+        assert response.status_code == 200, response.text
+        assert data["name"] == hero2_data["name"], "The name should not be set to none"
+        assert (
+            data["secret_name"] == "Spider-Youngster"
+        ), "The secret name should be updated"
+        assert "password" not in data
+        assert "hashed_password" not in data
+        with Session(mod.engine) as session:
+            hero2b_db = session.get(mod.Hero, hero2_id)
+            assert hero2b_db
+            assert not hasattr(hero2b_db, "password")
+            assert hero2b_db.hashed_password == "not really hashed auntmay hehehe"
+
+        response = client.patch(f"/heroes/{hero3_id}", json={"age": None})
+        data = response.json()
+        assert response.status_code == 200, response.text
+        assert data["name"] == hero3_data["name"]
+        assert (
+            data["age"] is None
+        ), "A field should be updatable to None, even if that's the default"
+        assert "password" not in data
+        assert "hashed_password" not in data
+        with Session(mod.engine) as session:
+            hero3b_db = session.get(mod.Hero, hero3_id)
+            assert hero3b_db
+            assert not hasattr(hero3b_db, "password")
+            assert hero3b_db.hashed_password == "not really hashed bestpreventer hehehe"
+
+        # Test update dict, hashed_password
+        response = client.patch(
+            f"/heroes/{hero3_id}", json={"password": "philantroplayboy"}
+        )
+        data = response.json()
+        assert response.status_code == 200, response.text
+        assert data["name"] == hero3_data["name"]
+        assert data["age"] is None
+        assert "password" not in data
+        assert "hashed_password" not in data
+        with Session(mod.engine) as session:
+            hero3b_db = session.get(mod.Hero, hero3_id)
+            assert hero3b_db
+            assert not hasattr(hero3b_db, "password")
+            assert (
+                hero3b_db.hashed_password == "not really hashed philantroplayboy hehehe"
+            )
+
+        response = client.patch("/heroes/9001", json={"name": "Dragon Cube X"})
+        assert response.status_code == 404, response.text
+
+        response = client.get("/openapi.json")
+        assert response.status_code == 200, response.text
+        assert response.json() == {
+            "openapi": "3.1.0",
+            "info": {"title": "FastAPI", "version": "0.1.0"},
+            "paths": {
+                "/heroes/": {
+                    "get": {
+                        "summary": "Read Heroes",
+                        "operationId": "read_heroes_heroes__get",
+                        "parameters": [
+                            {
+                                "required": False,
+                                "schema": {
+                                    "title": "Offset",
+                                    "type": "integer",
+                                    "default": 0,
+                                },
+                                "name": "offset",
+                                "in": "query",
+                            },
+                            {
+                                "required": False,
+                                "schema": {
+                                    "title": "Limit",
+                                    "maximum": 100,
+                                    "type": "integer",
+                                    "default": 100,
+                                },
+                                "name": "limit",
+                                "in": "query",
+                            },
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "title": "Response Read Heroes Heroes  Get",
+                                            "type": "array",
+                                            "items": {
+                                                "$ref": "#/components/schemas/HeroRead"
+                                            },
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                    "post": {
+                        "summary": "Create Hero",
+                        "operationId": "create_hero_heroes__post",
+                        "requestBody": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HeroCreate"
+                                    }
+                                }
+                            },
+                            "required": True,
+                        },
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HeroRead"
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                },
+                "/heroes/{hero_id}": {
+                    "get": {
+                        "summary": "Read Hero",
+                        "operationId": "read_hero_heroes__hero_id__get",
+                        "parameters": [
+                            {
+                                "required": True,
+                                "schema": {"title": "Hero Id", "type": "integer"},
+                                "name": "hero_id",
+                                "in": "path",
+                            }
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HeroRead"
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                    "patch": {
+                        "summary": "Update Hero",
+                        "operationId": "update_hero_heroes__hero_id__patch",
+                        "parameters": [
+                            {
+                                "required": True,
+                                "schema": {"title": "Hero Id", "type": "integer"},
+                                "name": "hero_id",
+                                "in": "path",
+                            }
+                        ],
+                        "requestBody": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HeroUpdate"
+                                    }
+                                }
+                            },
+                            "required": True,
+                        },
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HeroRead"
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                },
+            },
+            "components": {
+                "schemas": {
+                    "HTTPValidationError": {
+                        "title": "HTTPValidationError",
+                        "type": "object",
+                        "properties": {
+                            "detail": {
+                                "title": "Detail",
+                                "type": "array",
+                                "items": {
+                                    "$ref": "#/components/schemas/ValidationError"
+                                },
+                            }
+                        },
+                    },
+                    "HeroCreate": {
+                        "title": "HeroCreate",
+                        "required": ["name", "secret_name", "password"],
+                        "type": "object",
+                        "properties": {
+                            "name": {"title": "Name", "type": "string"},
+                            "secret_name": {"title": "Secret Name", "type": "string"},
+                            "age": IsDict(
+                                {
+                                    "anyOf": [{"type": "integer"}, {"type": "null"}],
+                                    "title": "Age",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Age", "type": "integer"}
+                            ),
+                            "password": {"type": "string", "title": "Password"},
+                        },
+                    },
+                    "HeroRead": {
+                        "title": "HeroRead",
+                        "required": ["name", "secret_name", "id"],
+                        "type": "object",
+                        "properties": {
+                            "name": {"title": "Name", "type": "string"},
+                            "secret_name": {"title": "Secret Name", "type": "string"},
+                            "age": IsDict(
+                                {
+                                    "anyOf": [{"type": "integer"}, {"type": "null"}],
+                                    "title": "Age",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Age", "type": "integer"}
+                            ),
+                            "id": {"title": "Id", "type": "integer"},
+                        },
+                    },
+                    "HeroUpdate": {
+                        "title": "HeroUpdate",
+                        "type": "object",
+                        "properties": {
+                            "name": IsDict(
+                                {
+                                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                                    "title": "Name",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Name", "type": "string"}
+                            ),
+                            "secret_name": IsDict(
+                                {
+                                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                                    "title": "Secret Name",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Secret Name", "type": "string"}
+                            ),
+                            "age": IsDict(
+                                {
+                                    "anyOf": [{"type": "integer"}, {"type": "null"}],
+                                    "title": "Age",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Age", "type": "integer"}
+                            ),
+                            "password": IsDict(
+                                {
+                                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                                    "title": "Password",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Password", "type": "string"}
+                            ),
+                        },
+                    },
+                    "ValidationError": {
+                        "title": "ValidationError",
+                        "required": ["loc", "msg", "type"],
+                        "type": "object",
+                        "properties": {
+                            "loc": {
+                                "title": "Location",
+                                "type": "array",
+                                "items": {
+                                    "anyOf": [{"type": "string"}, {"type": "integer"}]
+                                },
+                            },
+                            "msg": {"title": "Message", "type": "string"},
+                            "type": {"title": "Error Type", "type": "string"},
+                        },
+                    },
+                }
+            },
+        }
diff --git a/tests/test_tutorial/test_fastapi/test_update/test_tutorial002_py310.py b/tests/test_tutorial/test_fastapi/test_update/test_tutorial002_py310.py
new file mode 100644 (file)
index 0000000..6feb1ec
--- /dev/null
@@ -0,0 +1,430 @@
+from dirty_equals import IsDict
+from fastapi.testclient import TestClient
+from sqlmodel import Session, create_engine
+from sqlmodel.pool import StaticPool
+
+from ....conftest import needs_py310
+
+
+@needs_py310
+def test_tutorial(clear_sqlmodel):
+    from docs_src.tutorial.fastapi.update import tutorial002_py310 as mod
+
+    mod.sqlite_url = "sqlite://"
+    mod.engine = create_engine(
+        mod.sqlite_url, connect_args=mod.connect_args, poolclass=StaticPool
+    )
+
+    with TestClient(mod.app) as client:
+        hero1_data = {
+            "name": "Deadpond",
+            "secret_name": "Dive Wilson",
+            "password": "chimichanga",
+        }
+        hero2_data = {
+            "name": "Spider-Boy",
+            "secret_name": "Pedro Parqueador",
+            "id": 9000,
+            "password": "auntmay",
+        }
+        hero3_data = {
+            "name": "Rusty-Man",
+            "secret_name": "Tommy Sharp",
+            "age": 48,
+            "password": "bestpreventer",
+        }
+        response = client.post("/heroes/", json=hero1_data)
+        assert response.status_code == 200, response.text
+        hero1 = response.json()
+        assert "password" not in hero1
+        assert "hashed_password" not in hero1
+        hero1_id = hero1["id"]
+        response = client.post("/heroes/", json=hero2_data)
+        assert response.status_code == 200, response.text
+        hero2 = response.json()
+        hero2_id = hero2["id"]
+        response = client.post("/heroes/", json=hero3_data)
+        assert response.status_code == 200, response.text
+        hero3 = response.json()
+        hero3_id = hero3["id"]
+        response = client.get(f"/heroes/{hero2_id}")
+        assert response.status_code == 200, response.text
+        fetched_hero2 = response.json()
+        assert "password" not in fetched_hero2
+        assert "hashed_password" not in fetched_hero2
+        response = client.get("/heroes/9000")
+        assert response.status_code == 404, response.text
+        response = client.get("/heroes/")
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert len(data) == 3
+        for response_hero in data:
+            assert "password" not in response_hero
+            assert "hashed_password" not in response_hero
+
+        # Test hashed passwords
+        with Session(mod.engine) as session:
+            hero1_db = session.get(mod.Hero, hero1_id)
+            assert hero1_db
+            assert not hasattr(hero1_db, "password")
+            assert hero1_db.hashed_password == "not really hashed chimichanga hehehe"
+            hero2_db = session.get(mod.Hero, hero2_id)
+            assert hero2_db
+            assert not hasattr(hero2_db, "password")
+            assert hero2_db.hashed_password == "not really hashed auntmay hehehe"
+            hero3_db = session.get(mod.Hero, hero3_id)
+            assert hero3_db
+            assert not hasattr(hero3_db, "password")
+            assert hero3_db.hashed_password == "not really hashed bestpreventer hehehe"
+
+        response = client.patch(
+            f"/heroes/{hero2_id}", json={"secret_name": "Spider-Youngster"}
+        )
+        data = response.json()
+        assert response.status_code == 200, response.text
+        assert data["name"] == hero2_data["name"], "The name should not be set to none"
+        assert (
+            data["secret_name"] == "Spider-Youngster"
+        ), "The secret name should be updated"
+        assert "password" not in data
+        assert "hashed_password" not in data
+        with Session(mod.engine) as session:
+            hero2b_db = session.get(mod.Hero, hero2_id)
+            assert hero2b_db
+            assert not hasattr(hero2b_db, "password")
+            assert hero2b_db.hashed_password == "not really hashed auntmay hehehe"
+
+        response = client.patch(f"/heroes/{hero3_id}", json={"age": None})
+        data = response.json()
+        assert response.status_code == 200, response.text
+        assert data["name"] == hero3_data["name"]
+        assert (
+            data["age"] is None
+        ), "A field should be updatable to None, even if that's the default"
+        assert "password" not in data
+        assert "hashed_password" not in data
+        with Session(mod.engine) as session:
+            hero3b_db = session.get(mod.Hero, hero3_id)
+            assert hero3b_db
+            assert not hasattr(hero3b_db, "password")
+            assert hero3b_db.hashed_password == "not really hashed bestpreventer hehehe"
+
+        # Test update dict, hashed_password
+        response = client.patch(
+            f"/heroes/{hero3_id}", json={"password": "philantroplayboy"}
+        )
+        data = response.json()
+        assert response.status_code == 200, response.text
+        assert data["name"] == hero3_data["name"]
+        assert data["age"] is None
+        assert "password" not in data
+        assert "hashed_password" not in data
+        with Session(mod.engine) as session:
+            hero3b_db = session.get(mod.Hero, hero3_id)
+            assert hero3b_db
+            assert not hasattr(hero3b_db, "password")
+            assert (
+                hero3b_db.hashed_password == "not really hashed philantroplayboy hehehe"
+            )
+
+        response = client.patch("/heroes/9001", json={"name": "Dragon Cube X"})
+        assert response.status_code == 404, response.text
+
+        response = client.get("/openapi.json")
+        assert response.status_code == 200, response.text
+        assert response.json() == {
+            "openapi": "3.1.0",
+            "info": {"title": "FastAPI", "version": "0.1.0"},
+            "paths": {
+                "/heroes/": {
+                    "get": {
+                        "summary": "Read Heroes",
+                        "operationId": "read_heroes_heroes__get",
+                        "parameters": [
+                            {
+                                "required": False,
+                                "schema": {
+                                    "title": "Offset",
+                                    "type": "integer",
+                                    "default": 0,
+                                },
+                                "name": "offset",
+                                "in": "query",
+                            },
+                            {
+                                "required": False,
+                                "schema": {
+                                    "title": "Limit",
+                                    "maximum": 100,
+                                    "type": "integer",
+                                    "default": 100,
+                                },
+                                "name": "limit",
+                                "in": "query",
+                            },
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "title": "Response Read Heroes Heroes  Get",
+                                            "type": "array",
+                                            "items": {
+                                                "$ref": "#/components/schemas/HeroRead"
+                                            },
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                    "post": {
+                        "summary": "Create Hero",
+                        "operationId": "create_hero_heroes__post",
+                        "requestBody": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HeroCreate"
+                                    }
+                                }
+                            },
+                            "required": True,
+                        },
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HeroRead"
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                },
+                "/heroes/{hero_id}": {
+                    "get": {
+                        "summary": "Read Hero",
+                        "operationId": "read_hero_heroes__hero_id__get",
+                        "parameters": [
+                            {
+                                "required": True,
+                                "schema": {"title": "Hero Id", "type": "integer"},
+                                "name": "hero_id",
+                                "in": "path",
+                            }
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HeroRead"
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                    "patch": {
+                        "summary": "Update Hero",
+                        "operationId": "update_hero_heroes__hero_id__patch",
+                        "parameters": [
+                            {
+                                "required": True,
+                                "schema": {"title": "Hero Id", "type": "integer"},
+                                "name": "hero_id",
+                                "in": "path",
+                            }
+                        ],
+                        "requestBody": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HeroUpdate"
+                                    }
+                                }
+                            },
+                            "required": True,
+                        },
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HeroRead"
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                },
+            },
+            "components": {
+                "schemas": {
+                    "HTTPValidationError": {
+                        "title": "HTTPValidationError",
+                        "type": "object",
+                        "properties": {
+                            "detail": {
+                                "title": "Detail",
+                                "type": "array",
+                                "items": {
+                                    "$ref": "#/components/schemas/ValidationError"
+                                },
+                            }
+                        },
+                    },
+                    "HeroCreate": {
+                        "title": "HeroCreate",
+                        "required": ["name", "secret_name", "password"],
+                        "type": "object",
+                        "properties": {
+                            "name": {"title": "Name", "type": "string"},
+                            "secret_name": {"title": "Secret Name", "type": "string"},
+                            "age": IsDict(
+                                {
+                                    "anyOf": [{"type": "integer"}, {"type": "null"}],
+                                    "title": "Age",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Age", "type": "integer"}
+                            ),
+                            "password": {"type": "string", "title": "Password"},
+                        },
+                    },
+                    "HeroRead": {
+                        "title": "HeroRead",
+                        "required": ["name", "secret_name", "id"],
+                        "type": "object",
+                        "properties": {
+                            "name": {"title": "Name", "type": "string"},
+                            "secret_name": {"title": "Secret Name", "type": "string"},
+                            "age": IsDict(
+                                {
+                                    "anyOf": [{"type": "integer"}, {"type": "null"}],
+                                    "title": "Age",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Age", "type": "integer"}
+                            ),
+                            "id": {"title": "Id", "type": "integer"},
+                        },
+                    },
+                    "HeroUpdate": {
+                        "title": "HeroUpdate",
+                        "type": "object",
+                        "properties": {
+                            "name": IsDict(
+                                {
+                                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                                    "title": "Name",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Name", "type": "string"}
+                            ),
+                            "secret_name": IsDict(
+                                {
+                                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                                    "title": "Secret Name",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Secret Name", "type": "string"}
+                            ),
+                            "age": IsDict(
+                                {
+                                    "anyOf": [{"type": "integer"}, {"type": "null"}],
+                                    "title": "Age",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Age", "type": "integer"}
+                            ),
+                            "password": IsDict(
+                                {
+                                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                                    "title": "Password",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Password", "type": "string"}
+                            ),
+                        },
+                    },
+                    "ValidationError": {
+                        "title": "ValidationError",
+                        "required": ["loc", "msg", "type"],
+                        "type": "object",
+                        "properties": {
+                            "loc": {
+                                "title": "Location",
+                                "type": "array",
+                                "items": {
+                                    "anyOf": [{"type": "string"}, {"type": "integer"}]
+                                },
+                            },
+                            "msg": {"title": "Message", "type": "string"},
+                            "type": {"title": "Error Type", "type": "string"},
+                        },
+                    },
+                }
+            },
+        }
diff --git a/tests/test_tutorial/test_fastapi/test_update/test_tutorial002_py39.py b/tests/test_tutorial/test_fastapi/test_update/test_tutorial002_py39.py
new file mode 100644 (file)
index 0000000..13d70dd
--- /dev/null
@@ -0,0 +1,430 @@
+from dirty_equals import IsDict
+from fastapi.testclient import TestClient
+from sqlmodel import Session, create_engine
+from sqlmodel.pool import StaticPool
+
+from ....conftest import needs_py39
+
+
+@needs_py39
+def test_tutorial(clear_sqlmodel):
+    from docs_src.tutorial.fastapi.update import tutorial002_py39 as mod
+
+    mod.sqlite_url = "sqlite://"
+    mod.engine = create_engine(
+        mod.sqlite_url, connect_args=mod.connect_args, poolclass=StaticPool
+    )
+
+    with TestClient(mod.app) as client:
+        hero1_data = {
+            "name": "Deadpond",
+            "secret_name": "Dive Wilson",
+            "password": "chimichanga",
+        }
+        hero2_data = {
+            "name": "Spider-Boy",
+            "secret_name": "Pedro Parqueador",
+            "id": 9000,
+            "password": "auntmay",
+        }
+        hero3_data = {
+            "name": "Rusty-Man",
+            "secret_name": "Tommy Sharp",
+            "age": 48,
+            "password": "bestpreventer",
+        }
+        response = client.post("/heroes/", json=hero1_data)
+        assert response.status_code == 200, response.text
+        hero1 = response.json()
+        assert "password" not in hero1
+        assert "hashed_password" not in hero1
+        hero1_id = hero1["id"]
+        response = client.post("/heroes/", json=hero2_data)
+        assert response.status_code == 200, response.text
+        hero2 = response.json()
+        hero2_id = hero2["id"]
+        response = client.post("/heroes/", json=hero3_data)
+        assert response.status_code == 200, response.text
+        hero3 = response.json()
+        hero3_id = hero3["id"]
+        response = client.get(f"/heroes/{hero2_id}")
+        assert response.status_code == 200, response.text
+        fetched_hero2 = response.json()
+        assert "password" not in fetched_hero2
+        assert "hashed_password" not in fetched_hero2
+        response = client.get("/heroes/9000")
+        assert response.status_code == 404, response.text
+        response = client.get("/heroes/")
+        assert response.status_code == 200, response.text
+        data = response.json()
+        assert len(data) == 3
+        for response_hero in data:
+            assert "password" not in response_hero
+            assert "hashed_password" not in response_hero
+
+        # Test hashed passwords
+        with Session(mod.engine) as session:
+            hero1_db = session.get(mod.Hero, hero1_id)
+            assert hero1_db
+            assert not hasattr(hero1_db, "password")
+            assert hero1_db.hashed_password == "not really hashed chimichanga hehehe"
+            hero2_db = session.get(mod.Hero, hero2_id)
+            assert hero2_db
+            assert not hasattr(hero2_db, "password")
+            assert hero2_db.hashed_password == "not really hashed auntmay hehehe"
+            hero3_db = session.get(mod.Hero, hero3_id)
+            assert hero3_db
+            assert not hasattr(hero3_db, "password")
+            assert hero3_db.hashed_password == "not really hashed bestpreventer hehehe"
+
+        response = client.patch(
+            f"/heroes/{hero2_id}", json={"secret_name": "Spider-Youngster"}
+        )
+        data = response.json()
+        assert response.status_code == 200, response.text
+        assert data["name"] == hero2_data["name"], "The name should not be set to none"
+        assert (
+            data["secret_name"] == "Spider-Youngster"
+        ), "The secret name should be updated"
+        assert "password" not in data
+        assert "hashed_password" not in data
+        with Session(mod.engine) as session:
+            hero2b_db = session.get(mod.Hero, hero2_id)
+            assert hero2b_db
+            assert not hasattr(hero2b_db, "password")
+            assert hero2b_db.hashed_password == "not really hashed auntmay hehehe"
+
+        response = client.patch(f"/heroes/{hero3_id}", json={"age": None})
+        data = response.json()
+        assert response.status_code == 200, response.text
+        assert data["name"] == hero3_data["name"]
+        assert (
+            data["age"] is None
+        ), "A field should be updatable to None, even if that's the default"
+        assert "password" not in data
+        assert "hashed_password" not in data
+        with Session(mod.engine) as session:
+            hero3b_db = session.get(mod.Hero, hero3_id)
+            assert hero3b_db
+            assert not hasattr(hero3b_db, "password")
+            assert hero3b_db.hashed_password == "not really hashed bestpreventer hehehe"
+
+        # Test update dict, hashed_password
+        response = client.patch(
+            f"/heroes/{hero3_id}", json={"password": "philantroplayboy"}
+        )
+        data = response.json()
+        assert response.status_code == 200, response.text
+        assert data["name"] == hero3_data["name"]
+        assert data["age"] is None
+        assert "password" not in data
+        assert "hashed_password" not in data
+        with Session(mod.engine) as session:
+            hero3b_db = session.get(mod.Hero, hero3_id)
+            assert hero3b_db
+            assert not hasattr(hero3b_db, "password")
+            assert (
+                hero3b_db.hashed_password == "not really hashed philantroplayboy hehehe"
+            )
+
+        response = client.patch("/heroes/9001", json={"name": "Dragon Cube X"})
+        assert response.status_code == 404, response.text
+
+        response = client.get("/openapi.json")
+        assert response.status_code == 200, response.text
+        assert response.json() == {
+            "openapi": "3.1.0",
+            "info": {"title": "FastAPI", "version": "0.1.0"},
+            "paths": {
+                "/heroes/": {
+                    "get": {
+                        "summary": "Read Heroes",
+                        "operationId": "read_heroes_heroes__get",
+                        "parameters": [
+                            {
+                                "required": False,
+                                "schema": {
+                                    "title": "Offset",
+                                    "type": "integer",
+                                    "default": 0,
+                                },
+                                "name": "offset",
+                                "in": "query",
+                            },
+                            {
+                                "required": False,
+                                "schema": {
+                                    "title": "Limit",
+                                    "maximum": 100,
+                                    "type": "integer",
+                                    "default": 100,
+                                },
+                                "name": "limit",
+                                "in": "query",
+                            },
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "title": "Response Read Heroes Heroes  Get",
+                                            "type": "array",
+                                            "items": {
+                                                "$ref": "#/components/schemas/HeroRead"
+                                            },
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                    "post": {
+                        "summary": "Create Hero",
+                        "operationId": "create_hero_heroes__post",
+                        "requestBody": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HeroCreate"
+                                    }
+                                }
+                            },
+                            "required": True,
+                        },
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HeroRead"
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                },
+                "/heroes/{hero_id}": {
+                    "get": {
+                        "summary": "Read Hero",
+                        "operationId": "read_hero_heroes__hero_id__get",
+                        "parameters": [
+                            {
+                                "required": True,
+                                "schema": {"title": "Hero Id", "type": "integer"},
+                                "name": "hero_id",
+                                "in": "path",
+                            }
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HeroRead"
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                    "patch": {
+                        "summary": "Update Hero",
+                        "operationId": "update_hero_heroes__hero_id__patch",
+                        "parameters": [
+                            {
+                                "required": True,
+                                "schema": {"title": "Hero Id", "type": "integer"},
+                                "name": "hero_id",
+                                "in": "path",
+                            }
+                        ],
+                        "requestBody": {
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HeroUpdate"
+                                    }
+                                }
+                            },
+                            "required": True,
+                        },
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HeroRead"
+                                        }
+                                    }
+                                },
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    },
+                },
+            },
+            "components": {
+                "schemas": {
+                    "HTTPValidationError": {
+                        "title": "HTTPValidationError",
+                        "type": "object",
+                        "properties": {
+                            "detail": {
+                                "title": "Detail",
+                                "type": "array",
+                                "items": {
+                                    "$ref": "#/components/schemas/ValidationError"
+                                },
+                            }
+                        },
+                    },
+                    "HeroCreate": {
+                        "title": "HeroCreate",
+                        "required": ["name", "secret_name", "password"],
+                        "type": "object",
+                        "properties": {
+                            "name": {"title": "Name", "type": "string"},
+                            "secret_name": {"title": "Secret Name", "type": "string"},
+                            "age": IsDict(
+                                {
+                                    "anyOf": [{"type": "integer"}, {"type": "null"}],
+                                    "title": "Age",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Age", "type": "integer"}
+                            ),
+                            "password": {"type": "string", "title": "Password"},
+                        },
+                    },
+                    "HeroRead": {
+                        "title": "HeroRead",
+                        "required": ["name", "secret_name", "id"],
+                        "type": "object",
+                        "properties": {
+                            "name": {"title": "Name", "type": "string"},
+                            "secret_name": {"title": "Secret Name", "type": "string"},
+                            "age": IsDict(
+                                {
+                                    "anyOf": [{"type": "integer"}, {"type": "null"}],
+                                    "title": "Age",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Age", "type": "integer"}
+                            ),
+                            "id": {"title": "Id", "type": "integer"},
+                        },
+                    },
+                    "HeroUpdate": {
+                        "title": "HeroUpdate",
+                        "type": "object",
+                        "properties": {
+                            "name": IsDict(
+                                {
+                                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                                    "title": "Name",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Name", "type": "string"}
+                            ),
+                            "secret_name": IsDict(
+                                {
+                                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                                    "title": "Secret Name",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Secret Name", "type": "string"}
+                            ),
+                            "age": IsDict(
+                                {
+                                    "anyOf": [{"type": "integer"}, {"type": "null"}],
+                                    "title": "Age",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Age", "type": "integer"}
+                            ),
+                            "password": IsDict(
+                                {
+                                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                                    "title": "Password",
+                                }
+                            )
+                            | IsDict(
+                                # TODO: Remove when deprecating Pydantic v1
+                                {"title": "Password", "type": "string"}
+                            ),
+                        },
+                    },
+                    "ValidationError": {
+                        "title": "ValidationError",
+                        "required": ["loc", "msg", "type"],
+                        "type": "object",
+                        "properties": {
+                            "loc": {
+                                "title": "Location",
+                                "type": "array",
+                                "items": {
+                                    "anyOf": [{"type": "string"}, {"type": "integer"}]
+                                },
+                            },
+                            "msg": {"title": "Message", "type": "string"},
+                            "type": {"title": "Error Type", "type": "string"},
+                        },
+                    },
+                }
+            },
+        }