-
-
Notifications
You must be signed in to change notification settings - Fork 736
📝 Add example code for sa_column onupdate timestamps #372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
RobertRosca
wants to merge
5
commits into
fastapi:main
Choose a base branch
from
RobertRosca:docs/created_updated_at_cols
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
893bcf3
Add example code for sa_column onupdate timestamps
RobertRosca 1e5caaa
Fix import order
RobertRosca 01ca385
Fix typo
RobertRosca d78fc71
Add notes on Pydantic implementation of updated_at
RobertRosca b160fc8
Merge branch 'main' into docs/created_updated_at_cols
tiangolo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
# SQLAlchemy Columns | ||
|
||
In some cases you may need more control over the columns generated by SQLModel, this can be done by using the `sa_column`, `sa_column_args`, and `sa_column_kwargs` arguments when creating the `Field` object. | ||
|
||
There are many use cases for this, but ones where this is particularity useful is when you want more advanced defaults for values than what is easy to implement with Pydantic, such `created_at` or `update_at` timestamps for rows. | ||
|
||
## Columns for Timestamps | ||
|
||
Two ways of implementing `created_at` timestamps with Pydantic are [default factories](https://pydantic-docs.helpmanual.io/usage/models/#field-with-dynamic-default-value) and [validators](https://pydantic-docs.helpmanual.io/usage/validators/#validate-always), however there's no straightforward way to have an `update_at` timestamp. | ||
|
||
The SQLAlchemy docs describe how `created_at` timestamps can be automatically set with either [default](https://docs.sqlalchemy.org/en/14/core/defaults.html#python-executed-functions) or [server-default](https://docs.sqlalchemy.org/en/14/core/defaults.html#server-invoked-ddl-explicit-default-expressions) functions, by using `sa_column=Column(...)` as described in the SQLAlchemy documentation we can achieve the same behaviour: | ||
|
||
```{.python .annotate hl_lines="8 12"} | ||
{!./docs_src/advanced/sa_column/tutorial001.py[ln:9-21]!} | ||
``` | ||
|
||
Above we are saying that the `registered_at` column should have a `server_default` value of `func.now()` (see full code for imports), which means that if there is no provided value then the current time will be the recorded value for that row. | ||
|
||
As there is a value there now, then it will not be changed automatically in the future. | ||
|
||
The `updated_at` column has an `onupdate` value of `func.now()`, this means that each time an `UPDATE` is performed, the function will be executed, meaning that the timestamp changes whenever a change is made to the row. | ||
|
||
!!! warning | ||
The difference between client-side python functions, server-side ddl expressions, and server-side implicit defaults is important in some situations but too in-depth to go into here. Check the SQL and SQLAlchemy docs for more information. | ||
|
||
<details> | ||
<summary>👀 Full file preview</summary> | ||
|
||
```Python | ||
{!./docs_src/advanced/sa_column/tutorial001.py!} | ||
``` | ||
|
||
</details> | ||
|
||
### Pydantic Implementation | ||
|
||
Implementing these timestamps on the DB side with SQLAlchemy works very well as the database itself is what will create and update the fields whenever a relevant database interaction occurs. | ||
|
||
It's possible to achieve similar behaviour with Pydantic, for the `created_at` timestamp by using a Pydantic `Field` with a `default_factory`: | ||
|
||
```python | ||
from datetime import datetime | ||
|
||
from pydantic import BaseModel, Field | ||
|
||
|
||
class Model(BaseModel): | ||
created_at: datetime = Field(default_factory=datetime.utcnow) | ||
|
||
|
||
m1 = Model() | ||
m2 = Model() | ||
print(f'{m1.created_at} != {m2.created_at}') | ||
#> 2022-05-19 10:49:22.053624 != 2022-05-19 10:49:22.053641 | ||
``` | ||
|
||
Another approach is to use a Pydantic `validator`: | ||
|
||
```python | ||
from datetime import datetime | ||
|
||
from pydantic import BaseModel, validator | ||
|
||
class Model(BaseModel): | ||
created_at: datetime = None | ||
|
||
@validator('ts', pre=True, always=True) | ||
def set_created_at_now(cls, v): | ||
return v or datetime.now() | ||
``` | ||
|
||
Both of these approaches come with the major caveat that default fields are set during the **Pydantic model instantiation**, as opposed to during **interactions with the database**, instead of the SQLModel approach which sets it with `server_default` which means that the timestamp will be exactly when the row is created in the database. | ||
|
||
The real issue starts when looking at the `updated_at` timestamp - SQLAlchemy has the `onupdate` default which runs a function when the row is updated in the database, but there is no easy way to do this in Pydantic as it has no concept of 'about to be saved'. | ||
|
||
So the pure Pydantic approach would require some additional logic to always change the `updated_at` timestamp before doing a write to the database, which adds some more complexity to the code and does not have benefits over the SQLAlchemy approach. |
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
from datetime import datetime | ||
from time import sleep | ||
from typing import Optional | ||
|
||
from sqlalchemy import Column, DateTime, func | ||
from sqlmodel import Field, Session, SQLModel, create_engine, select | ||
|
||
|
||
class Hero(SQLModel, table=True): | ||
id: Optional[int] = Field(default=None, primary_key=True) | ||
name: str | ||
secret_name: str | ||
age: Optional[int] = None | ||
|
||
registered_at: datetime = Field( | ||
sa_column=Column(DateTime(timezone=False), server_default=func.now()) | ||
) | ||
|
||
updated_at: Optional[datetime] = Field( | ||
sa_column=Column(DateTime(timezone=False), onupdate=func.now()) | ||
) | ||
|
||
|
||
sqlite_file_name = "database.db" | ||
sqlite_url = f"sqlite:///{sqlite_file_name}" | ||
|
||
engine = create_engine(sqlite_url, echo=True) | ||
|
||
|
||
def create_db_and_tables(): | ||
SQLModel.metadata.create_all(engine) | ||
|
||
|
||
def create_heroes(): | ||
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson") | ||
hero_2 = Hero(name="Spider-Boy", secret_name="Pedro Parqueador") | ||
hero_3 = Hero(name="Rusty-Man", secret_name="Tommy Sharp", age=48) | ||
|
||
session = Session(engine) | ||
|
||
session.add(hero_1) | ||
session.add(hero_2) | ||
session.add(hero_3) | ||
|
||
session.commit() | ||
|
||
session.close() | ||
|
||
|
||
def update_hero_age(new_secret_name): | ||
with Session(engine) as session: | ||
statement = select(Hero).where(Hero.name == "Spider-Boy") | ||
results = session.exec(statement) | ||
hero = results.one() | ||
print("Hero:", hero) | ||
|
||
hero.secret_name = new_secret_name | ||
session.add(hero) | ||
session.commit() | ||
session.refresh(hero) | ||
print("Updated hero:", hero) | ||
|
||
|
||
def main(): | ||
create_db_and_tables() | ||
create_heroes() | ||
sleep(1) | ||
update_hero_age("Arachnid-Lad") | ||
sleep(1) | ||
update_hero_age("The Wallclimber") | ||
|
||
|
||
if __name__ == "__main__": | ||
main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.