diff --git a/docs/tutorial/fastapi/update-extra-data.md b/docs/tutorial/fastapi/update-extra-data.md new file mode 100644 index 0000000..71d9b9c --- /dev/null +++ b/docs/tutorial/fastapi/update-extra-data.md @@ -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 passlib 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. 🤓 diff --git a/docs/tutorial/fastapi/update.md b/docs/tutorial/fastapi/update.md index cfcf8a9..be4d90d 100644 --- a/docs/tutorial/fastapi/update.md +++ b/docs/tutorial/fastapi/update.md @@ -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 diff --git a/docs_src/tutorial/fastapi/update/tutorial001.py b/docs_src/tutorial/fastapi/update/tutorial001.py index 5639638..feab25c 100644 --- a/docs_src/tutorial/fastapi/update/tutorial001.py +++ b/docs_src/tutorial/fastapi/update/tutorial001.py @@ -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/tutorial001_py310.py b/docs_src/tutorial/fastapi/update/tutorial001_py310.py index 4faf266..02bec2e 100644 --- a/docs_src/tutorial/fastapi/update/tutorial001_py310.py +++ b/docs_src/tutorial/fastapi/update/tutorial001_py310.py @@ -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) diff --git a/docs_src/tutorial/fastapi/update/tutorial001_py39.py b/docs_src/tutorial/fastapi/update/tutorial001_py39.py index b0daa87..241d205 100644 --- a/docs_src/tutorial/fastapi/update/tutorial001_py39.py +++ b/docs_src/tutorial/fastapi/update/tutorial001_py39.py @@ -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 index 0000000..1333654 --- /dev/null +++ b/docs_src/tutorial/fastapi/update/tutorial002.py @@ -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 index 0000000..84efb3d --- /dev/null +++ b/docs_src/tutorial/fastapi/update/tutorial002_py310.py @@ -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 index 0000000..72751da --- /dev/null +++ b/docs_src/tutorial/fastapi/update/tutorial002_py39.py @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index ce98f15..fa85062 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/sqlmodel/_compat.py b/sqlmodel/_compat.py index 76771ce..072d2b0 100644 --- a/sqlmodel/_compat.py +++ b/sqlmodel/_compat.py @@ -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()) diff --git a/sqlmodel/main.py b/sqlmodel/main.py index fec3bc7..9e8330d 100644 --- a/sqlmodel/main.py +++ b/sqlmodel/main.py @@ -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 diff --git a/tests/test_fields_set.py b/tests/test_fields_set.py index 56f4ad0..e0bd8cb 100644 --- a/tests/test_fields_set.py +++ b/tests/test_fields_set.py @@ -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 index 0000000..21ca74e --- /dev/null +++ b/tests/test_tutorial/test_fastapi/test_update/test_tutorial002.py @@ -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 index 0000000..6feb1ec --- /dev/null +++ b/tests/test_tutorial/test_fastapi/test_update/test_tutorial002_py310.py @@ -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 index 0000000..13d70dd --- /dev/null +++ b/tests/test_tutorial/test_fastapi/test_update/test_tutorial002_py39.py @@ -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"}, + }, + }, + } + }, + }