Skip to content

Commit

Permalink
Add some documentation for new features
Browse files Browse the repository at this point in the history
  • Loading branch information
sambadi committed Feb 6, 2024
1 parent 33e0e6c commit 46320fe
Show file tree
Hide file tree
Showing 9 changed files with 368 additions and 8 deletions.
70 changes: 70 additions & 0 deletions docs/advanced/change-default-type-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Change default Pydantic to SQLAlchemy mappings

In most cases, you do not need to know how SQLAlchemy transforms the Python types to the type suitable for storing data
in the database, and you can use the default mapping.

But in some cases you may need to have possibility to change default mapping provided by SQLmodel. For example to use
mssql dialect with some UTF-8 data you should use NVARCHAR field (sa.Unicode)

Now changing default mapping is simple to use - see example bellow:

```python
import sqlmodel.main
import sqlalchemy as sa
from sqlmodel import Field, SQLModel
from typing import Optional

sqlmodel.main.sa_types_map[str] = lambda type_, meta, annotation: sa.Unicode(
length=getattr(meta, "max_length", None)
)


class Hero(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
name: str = Field(max_length=255)
history: Optional[str]


assert isinstance(Hero.name.type, sa.Unicode)
```

# Some details

Let's get little deeper to process of mapping. At the `sqlmodel.main` module defined the `sa_types_map` dictionary,
which uses the Python types as keys, and the sqlalchemy type or callable that takes the input of 3 parameters and
returns the sqlalchemy type as values.

Callable format present bellow:

```python
def map_python_type_to_sa_type(type_: "PythonType", meta: "PydanticMeta", annotation: "FieldAnnotatedType"):
return sqlalchemyType(length=getattr(meta, "max_length", None))
```

* `type_` - used to pass python type, provided by pydantic annotation, cleared from Union/Optional and other wrappers.
Can be passed to sa.Enum type to properly store enumerated data.
* `meta` - pydantic metadata used to store field params e.g. length of str field or precision of decimal field
* `annotation` - original annotation given by pydantic. Used to provide `type` parameter for PydanticJSONType

## Current mapping

| Python type | SqlAlchemy type |
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------|
| Enum | `lambda type_, meta, annotation: sa_Enum(type_)` |
| str | `lambda type_, meta, annotation: AutoString(length=getattr(meta, "max_length", None))` |
| float | Float |
| bool | Boolean |
| int | Integer |
| datetime | DateTime |
| date | Date |
| timedelta | Interval |
| time | Time |
| bytes | LargeBinary |
| Decimal | `lambda type_, meta, annotation: Numeric(precision=getattr(meta, "max_digits", None),scale=getattr(meta, "decimal_places", None))` |
| ipaddress.IPv4Address | AutoString |
| ipaddress.IPv4Network | AutoString |
| ipaddress.IPv6Address | AutoString |
| ipaddress.IPv6Network | AutoString |
| Path | AutoString |
| uuid.UUID | Uuid |
| BaseModel | `lambda type_, meta, annotation: PydanticJSONType(type=annotation)` |
2 changes: 1 addition & 1 deletion docs/advanced/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The **Advanced User Guide** is gradually growing, you can already read about som

At some point it will include:

* How to use `async` and `await` with the async session.
* How to use `async` and `await` with the async session.
* How to run migrations.
* How to combine **SQLModel** models with SQLAlchemy.
* ...and more. 🤓
60 changes: 60 additions & 0 deletions docs/advanced/pydantic-json-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Storing Pydantic models at database

In some cases you might need to be able to store Pydantic models as a JSON data instead of create new table for them. You can do it now with new SqlAlchemy type `PydanticJSonType` mapped to BaseModel inside SqlModel.

For example let's add some stats to our heroes and save them at database as JSON data.

At first, we need to create new class `Stats` inherited from pydantic `BaseModel` or even `SqlModel`:

```{.python .annotate }
{!./docs_src/advanced/pydantic_json_type/tutorial001.py[ln:8-14]!}
```

Then create new field `stats` to `Hero` model

```{.python .annotate hl_lines="6" }
{!./docs_src/advanced/pydantic_json_type/tutorial001.py[ln:17-22]!}
```
And... that's all of you need to do to store pydantic data as JSON at database.

/// details | 👀 Full tutorial preview
```Python
{!./docs_src/advanced/pydantic_json_type/tutorial001.py!}
```

///

Here we define new Pydantic model `Stats` contains statistics of our hero and map this model to SqlModel class `Hero`.

Then we create new instances of Hero model with random generated stats and save it at database.


# How to watch for mapped model changes at runtime

In previous example we have one *non bug but feature* - `Stats` model isn't mutable and if we try to load our Hero form database and then change some stats and call `session.commit()` there no changes will be saved.

Let's see how to avoid it.

At first, we need to inherit our Stats model from `sqlalchemy.ext.mutable.Mutable`:
```{.python .annotate hl_lines="1" }
{!./docs_src/advanced/pydantic_json_type/tutorial002.py[ln:10-19]!}
```

Then map Stats to Hero as shown bellow:
```{.python .annotate hl_lines="1-4" }
{!./docs_src/advanced/pydantic_json_type/tutorial002.py[ln:36-39]!}
```

After all of these actions we can change mutated model, and it will be saved to database after we call `session.commit()`

```{.python .annotate hl_lines="4" }
{!./docs_src/advanced/pydantic_json_type/tutorial002.py[ln:76-94]!}
```

/// details | 👀 Full tutorial preview

```Python
{!./docs_src/advanced/pydantic_json_type/tutorial002.py!}
```

///
Empty file.
85 changes: 85 additions & 0 deletions docs_src/advanced/pydantic_json_type/tutorial001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import random
from typing import Optional

from pydantic import BaseModel
from sqlmodel import Field, Session, SQLModel, create_engine, select


class Stats(BaseModel):
strength: int
dexterity: int
constitution: int
intelligence: int
wisdom: int
charisma: int


class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: Optional[int]
stats: Optional[Stats]


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 random_stat():
random.seed()

return Stats(
strength=random.randrange(1, 20, 2),
dexterity=random.randrange(1, 20, 2),
constitution=random.randrange(1, 20, 2),
intelligence=random.randrange(1, 20, 2),
wisdom=random.randrange(1, 20, 2),
charisma=random.randrange(1, 20, 2),
)


def create_heroes():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", stats=random_stat())
hero_2 = Hero(
name="Spider-Boy", secret_name="Pedro Parqueador", stats=random_stat()
)
hero_3 = Hero(
name="Rusty-Man", secret_name="Tommy Sharp", age=48, stats=random_stat()
)

with Session(engine) as session:
session.add(hero_1)
session.add(hero_2)
session.add(hero_3)

session.commit()


def select_heroes():
with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()
print("Hero 1:", hero_1)

statement = select(Hero).where(Hero.name == "Rusty-Man")
results = session.exec(statement)
hero_2 = results.one()
print("Hero 2:", hero_2)


def main():
create_db_and_tables()
create_heroes()
select_heroes()


if __name__ == "__main__":
main()
102 changes: 102 additions & 0 deletions docs_src/advanced/pydantic_json_type/tutorial002.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import random
from typing import Any, Optional

from pydantic import BaseModel
from sqlalchemy import Column
from sqlalchemy.ext.mutable import Mutable
from sqlmodel import Field, Session, SQLModel, create_engine, select
from sqlmodel.sql.sqltypes import PydanticJSONType


class Stats(BaseModel, Mutable):
strength: int
dexterity: int
constitution: int
intelligence: int
wisdom: int
charisma: int

@classmethod
def coerce(cls, key: str, value: Any) -> Optional[Any]:
return value

def __setattr__(self, key, value):
# set the attribute
object.__setattr__(self, key, value)

# alert all parents to the change
self.changed()


class Hero(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
secret_name: str
age: Optional[int]
stats: Stats = Field(
default_factory=None,
sa_column=Column(Stats.as_mutable(PydanticJSONType(type=Stats))),
)


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 random_stat():
random.seed()

return Stats(
strength=random.randrange(1, 20, 2),
dexterity=random.randrange(1, 20, 2),
constitution=random.randrange(1, 20, 2),
intelligence=random.randrange(1, 20, 2),
wisdom=random.randrange(1, 20, 2),
charisma=random.randrange(1, 20, 2),
)


def create_hero():
hero_1 = Hero(name="Deadpond", secret_name="Dive Wilson", stats=random_stat())

with Session(engine) as session:
session.add(hero_1)

session.commit()


def mutate_hero():
with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()

print("Hero 1:", hero_1.stats)

hero_1.stats.strength = 100500
session.commit()

with Session(engine) as session:
statement = select(Hero).where(Hero.name == "Deadpond")
results = session.exec(statement)
hero_1 = results.one()

print("Hero 1 strength:", hero_1.stats.strength)

print("Hero 1:", hero_1)


def main():
create_db_and_tables()
create_hero()
mutate_hero()


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ nav:
- Advanced User Guide:
- advanced/index.md
- advanced/decimal.md
- advanced/change-default-type-mapping.md
- advanced/pydantic-json-type.md
- alternatives.md
- help.md
- contributing.md
Expand Down
24 changes: 17 additions & 7 deletions sqlmodel/sql/pydantic_v2_json.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Any, Optional
from typing import Any, Dict, List, Optional, Union

from pydantic import TypeAdapter
from pydantic import BaseModel, TypeAdapter
from sqlalchemy import types
from sqlalchemy.engine.interfaces import Dialect

Expand All @@ -22,13 +22,23 @@ def __init__(self, *args: Any, **kwargs: Any):

super().__init__(*args, **kwargs)

def process_bind_param(self, value: Any, dialect: Dialect) -> Optional[str]:
def process_result_value(self, value: Any, dialect: Dialect) -> Any:
if value is None:
return None

return self._type.validate_python(value)

def serialize(
self, value: Optional[Union[Dict[Any, Any], List[BaseModel], BaseModel]]
) -> Optional[str]:
if value is None:
return None

return self._type.dump_json(
self._type.validate_python(value), by_alias=True, exclude_none=True
).decode()

def process_result_value(self, value: Any, dialect: Dialect) -> Any:
if value is None:
return None
def bind_processor(self, dialect: Dialect) -> Optional[Any]:
string_process = self._str_impl.bind_processor(dialect)

return self._type.validate_json(value)
return self._make_bind_processor(string_process, self.serialize)
Loading

0 comments on commit 46320fe

Please sign in to comment.