From a80bb85af7ce9e5a6db470d8a6008b99c03b528a Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Sun, 16 Mar 2025 17:20:40 -0700 Subject: [PATCH 01/15] =?UTF-8?q?=E2=9C=A8=20Add=20PydanticJSONB=20type=20?= =?UTF-8?q?for=20automatic=20Pydantic=20model=20serialization=20in=20Postg?= =?UTF-8?q?reSQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sqlmodel/sql/sqltypes.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index 512daacbab..9717978728 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -1,7 +1,9 @@ from typing import Any, cast +from pydantic import BaseModel from sqlalchemy import types from sqlalchemy.engine.interfaces import Dialect +from sqlalchemy.dialects.postgresql import JSONB # for Postgres JSONB class AutoString(types.TypeDecorator): # type: ignore @@ -14,3 +16,28 @@ def load_dialect_impl(self, dialect: Dialect) -> "types.TypeEngine[Any]": if impl.length is None and dialect.name == "mysql": return dialect.type_descriptor(types.String(self.mysql_default_length)) return super().load_dialect_impl(dialect) + + +class PydanticJSONB(types.TypeDecorator): # type: ignore + """Custom type to automatically handle Pydantic model serialization.""" + + impl = JSONB # use JSONB type in Postgres (fallback to JSON for others) + cache_ok = True # allow SQLAlchemy to cache results + + def __init__(self, model_class, *args, **kwargs): + super().__init__(*args, **kwargs) + self.model_class = model_class # Pydantic model class to use + + def process_bind_param(self, value, dialect): + # Called when storing to DB: convert Pydantic model to a dict (JSON-serializable) + if value is None: + return None + if isinstance(value, BaseModel): + return value.model_dump() + return value # assume it's already a dict + + def process_result_value(self, value, dialect): + # Called when loading from DB: convert dict to Pydantic model instance + if value is None: + return None + return self.model_class.parse_obj(value) # instantiate Pydantic model From 1dab2cb79f50ca6e13537e9b196d7c3b6a128b04 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 00:26:22 +0000 Subject: [PATCH 02/15] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sqlmodel/sql/sqltypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index 9717978728..952a662e97 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -2,8 +2,8 @@ from pydantic import BaseModel from sqlalchemy import types -from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.dialects.postgresql import JSONB # for Postgres JSONB +from sqlalchemy.engine.interfaces import Dialect class AutoString(types.TypeDecorator): # type: ignore From 10fd48164b3ba11b1a50e99acf153fd3dbd1a63f Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Mon, 17 Mar 2025 14:42:21 -0700 Subject: [PATCH 03/15] Support serialization of lists and improve type hints for model binding and result processing --- sqlmodel/sql/sqltypes.py | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index 952a662e97..25ae83c5a5 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -1,10 +1,12 @@ -from typing import Any, cast +from typing import Any, List, Type, TypeVar, cast, get_args from pydantic import BaseModel from sqlalchemy import types from sqlalchemy.dialects.postgresql import JSONB # for Postgres JSONB from sqlalchemy.engine.interfaces import Dialect +BaseModelType = TypeVar("BaseModelType", bound=BaseModel) + class AutoString(types.TypeDecorator): # type: ignore impl = types.String @@ -24,20 +26,35 @@ class PydanticJSONB(types.TypeDecorator): # type: ignore impl = JSONB # use JSONB type in Postgres (fallback to JSON for others) cache_ok = True # allow SQLAlchemy to cache results - def __init__(self, model_class, *args, **kwargs): + def __init__( + self, + model_class: Type[BaseModelType] | Type[list[BaseModelType]], + *args, + **kwargs, + ): super().__init__(*args, **kwargs) self.model_class = model_class # Pydantic model class to use - def process_bind_param(self, value, dialect): - # Called when storing to DB: convert Pydantic model to a dict (JSON-serializable) + def process_bind_param(self, value: Any, dialect) -> dict | list[dict] | None: # noqa: ANN401, ARG002, ANN001 if value is None: return None if isinstance(value, BaseModel): - return value.model_dump() - return value # assume it's already a dict - - def process_result_value(self, value, dialect): + return value.model_dump(mode="json") + if isinstance(value, list): + return [ + m.model_dump(mode="json") if isinstance(m, BaseModel) else m + for m in value + ] + return value + + def process_result_value( + self, value: Any, dialect + ) -> BaseModelType | List[BaseModelType] | None: # noqa: ANN401, ARG002, ANN001 # Called when loading from DB: convert dict to Pydantic model instance if value is None: return None - return self.model_class.parse_obj(value) # instantiate Pydantic model + if isinstance(value, dict): + return self.model_class.model_validate(value) # type: ignore + if isinstance(value, list): + return [get_args(self.model_class)[0].model_validate(v) for v in value] # type: ignore + return value From 5f3adaf07a07b25315f18a206eea3435794996b2 Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Mon, 17 Mar 2025 14:53:40 -0700 Subject: [PATCH 04/15] Support dict[str, BaseModel] --- sqlmodel/sql/sqltypes.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index 25ae83c5a5..f641a92fdc 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -1,4 +1,4 @@ -from typing import Any, List, Type, TypeVar, cast, get_args +from typing import Any, Dict, List, Type, TypeVar, cast, get_args from pydantic import BaseModel from sqlalchemy import types @@ -28,7 +28,9 @@ class PydanticJSONB(types.TypeDecorator): # type: ignore def __init__( self, - model_class: Type[BaseModelType] | Type[list[BaseModelType]], + model_class: Type[BaseModelType] + | Type[list[BaseModelType]] + | Type[Dict[str, BaseModelType]], *args, **kwargs, ): @@ -45,15 +47,23 @@ def process_bind_param(self, value: Any, dialect) -> dict | list[dict] | None: m.model_dump(mode="json") if isinstance(m, BaseModel) else m for m in value ] + if isinstance(value, dict): + return { + k: v.model_dump(mode="json") if isinstance(v, BaseModel) else v + for k, v in value.items() + } return value def process_result_value( self, value: Any, dialect - ) -> BaseModelType | List[BaseModelType] | None: # noqa: ANN401, ARG002, ANN001 - # Called when loading from DB: convert dict to Pydantic model instance + ) -> BaseModelType | List[BaseModelType] | Dict[str, BaseModelType] | None: # noqa: ANN401, ARG002, ANN001 if value is None: return None if isinstance(value, dict): + # If model_class is dict, handle key-value pairs + if isinstance(self.model_class, dict): + return {k: self.model_class.model_validate(v) for k, v in value.items()} + # Regular case: the whole dict represents a single model return self.model_class.model_validate(value) # type: ignore if isinstance(value, list): return [get_args(self.model_class)[0].model_validate(v) for v in value] # type: ignore From e24cb0e208fb40b7a743d305edfd22a77b356049 Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Mon, 17 Mar 2025 15:33:49 -0700 Subject: [PATCH 05/15] Enhance PydanticJSONB to support Dict and List type hints for model validation --- sqlmodel/sql/sqltypes.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index f641a92fdc..1cc74f641d 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -60,11 +60,25 @@ def process_result_value( if value is None: return None if isinstance(value, dict): - # If model_class is dict, handle key-value pairs - if isinstance(self.model_class, dict): - return {k: self.model_class.model_validate(v) for k, v in value.items()} + # If model_class is a Dict type hint, handle key-value pairs + if ( + hasattr(self.model_class, "__origin__") + and self.model_class.__origin__ is dict + ): + model_class = get_args(self.model_class)[ + 1 + ] # Get the value type (the model) + return {k: model_class.model_validate(v) for k, v in value.items()} # Regular case: the whole dict represents a single model return self.model_class.model_validate(value) # type: ignore if isinstance(value, list): - return [get_args(self.model_class)[0].model_validate(v) for v in value] # type: ignore + # If model_class is a List type hint + if ( + hasattr(self.model_class, "__origin__") + and self.model_class.__origin__ is list + ): + model_class = get_args(self.model_class)[0] + return [model_class.model_validate(v) for v in value] + # Fallback case (though this shouldn't happen given our __init__ types) + return [self.model_class.model_validate(v) for v in value] # type: ignore return value From 093ff196102117ac95f336adc7ee7eb4080e8c2d Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Fri, 21 Mar 2025 00:51:39 -0400 Subject: [PATCH 06/15] Refactor type hints in PydanticJSONB for improved clarity and support for optional types --- sqlmodel/sql/sqltypes.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index 1cc74f641d..ee9929a0b6 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Type, TypeVar, cast, get_args +from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast, get_args from pydantic import BaseModel from sqlalchemy import types @@ -13,7 +13,7 @@ class AutoString(types.TypeDecorator): # type: ignore cache_ok = True mysql_default_length = 255 - def load_dialect_impl(self, dialect: Dialect) -> "types.TypeEngine[Any]": + def load_dialect_impl(self, dialect: Dialect) -> types.TypeEngine[Any]: impl = cast(types.String, self.impl) if impl.length is None and dialect.name == "mysql": return dialect.type_descriptor(types.String(self.mysql_default_length)) @@ -28,16 +28,20 @@ class PydanticJSONB(types.TypeDecorator): # type: ignore def __init__( self, - model_class: Type[BaseModelType] - | Type[list[BaseModelType]] - | Type[Dict[str, BaseModelType]], - *args, - **kwargs, + model_class: Union[ + Type[BaseModelType], + Type[List[BaseModelType]], + Type[Dict[str, BaseModelType]], + ], + *args: Any, + **kwargs: Any, ): super().__init__(*args, **kwargs) self.model_class = model_class # Pydantic model class to use - def process_bind_param(self, value: Any, dialect) -> dict | list[dict] | None: # noqa: ANN401, ARG002, ANN001 + def process_bind_param( + self, value: Any, dialect: Any + ) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]: # noqa: ANN401, ARG002, ANN001 if value is None: return None if isinstance(value, BaseModel): @@ -55,8 +59,8 @@ def process_bind_param(self, value: Any, dialect) -> dict | list[dict] | None: return value def process_result_value( - self, value: Any, dialect - ) -> BaseModelType | List[BaseModelType] | Dict[str, BaseModelType] | None: # noqa: ANN401, ARG002, ANN001 + self, value: Any, dialect: Any + ) -> Optional[Union[BaseModelType, List[BaseModelType], Dict[str, BaseModelType]]]: # noqa: ANN401, ARG002, ANN001 if value is None: return None if isinstance(value, dict): From 5329960c95b3c7178bf56ba81801cbed2a88c844 Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Fri, 21 Mar 2025 01:01:24 -0400 Subject: [PATCH 07/15] Fix lint issues --- sqlmodel/sql/sqltypes.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index ee9929a0b6..885ccc3ff4 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -1,4 +1,15 @@ -from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast, get_args +from typing import ( + Any, + Dict, + List, + Optional, + Type, + TypeVar, + Union, + cast, + get_args, + get_origin, +) from pydantic import BaseModel from sqlalchemy import types @@ -56,7 +67,10 @@ def process_bind_param( k: v.model_dump(mode="json") if isinstance(v, BaseModel) else v for k, v in value.items() } - return value + + raise TypeError( + f"Unsupported type for PydanticJSONB: {type(value)}. Expected a Pydantic model, a list of Pydantic models, or a dictionary of Pydantic models." + ) def process_result_value( self, value: Any, dialect: Any @@ -65,10 +79,8 @@ def process_result_value( return None if isinstance(value, dict): # If model_class is a Dict type hint, handle key-value pairs - if ( - hasattr(self.model_class, "__origin__") - and self.model_class.__origin__ is dict - ): + origin = get_origin(self.model_class) + if origin is dict: model_class = get_args(self.model_class)[ 1 ] # Get the value type (the model) @@ -77,12 +89,13 @@ def process_result_value( return self.model_class.model_validate(value) # type: ignore if isinstance(value, list): # If model_class is a List type hint - if ( - hasattr(self.model_class, "__origin__") - and self.model_class.__origin__ is list - ): + origin = get_origin(self.model_class) + if origin is list: model_class = get_args(self.model_class)[0] return [model_class.model_validate(v) for v in value] # Fallback case (though this shouldn't happen given our __init__ types) return [self.model_class.model_validate(v) for v in value] # type: ignore - return value + + raise TypeError( + f"Unsupported type for PydanticJSONB from database: {type(value)}. Expected a dictionary or list." + ) From 7525eb9d4fc90782607959c7edc8bd1bc502e5ed Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Sat, 22 Mar 2025 14:21:09 -0400 Subject: [PATCH 08/15] Enhance PydanticJSONB serialization by integrating to_jsonable_python for non-BaseModel types in lists and dictionaries --- sqlmodel/sql/sqltypes.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index 885ccc3ff4..77350b5c06 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -12,6 +12,7 @@ ) from pydantic import BaseModel +from pydantic_core import to_jsonable_python from sqlalchemy import types from sqlalchemy.dialects.postgresql import JSONB # for Postgres JSONB from sqlalchemy.engine.interfaces import Dialect @@ -59,18 +60,20 @@ def process_bind_param( return value.model_dump(mode="json") if isinstance(value, list): return [ - m.model_dump(mode="json") if isinstance(m, BaseModel) else m + m.model_dump(mode="json") + if isinstance(m, BaseModel) + else to_jsonable_python(m) for m in value ] if isinstance(value, dict): return { - k: v.model_dump(mode="json") if isinstance(v, BaseModel) else v + k: v.model_dump(mode="json") + if isinstance(v, BaseModel) + else to_jsonable_python(v) for k, v in value.items() } - raise TypeError( - f"Unsupported type for PydanticJSONB: {type(value)}. Expected a Pydantic model, a list of Pydantic models, or a dictionary of Pydantic models." - ) + return to_jsonable_python(value) def process_result_value( self, value: Any, dialect: Any From 06b520078f2b1befce98378ad095aa382f30b1de Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Sat, 22 Mar 2025 14:26:23 -0400 Subject: [PATCH 09/15] Fix [no-any-return] --- sqlmodel/sql/sqltypes.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index 77350b5c06..c64a343492 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -19,6 +19,9 @@ BaseModelType = TypeVar("BaseModelType", bound=BaseModel) +# Define a type alias for JSON-serializable values +JSONValue = Union[Dict[str, Any], List[Any], str, int, float, bool, None] + class AutoString(types.TypeDecorator): # type: ignore impl = types.String @@ -51,9 +54,7 @@ def __init__( super().__init__(*args, **kwargs) self.model_class = model_class # Pydantic model class to use - def process_bind_param( - self, value: Any, dialect: Any - ) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]: # noqa: ANN401, ARG002, ANN001 + def process_bind_param(self, value: Any, dialect: Any) -> JSONValue: # noqa: ANN401, ARG002, ANN001 if value is None: return None if isinstance(value, BaseModel): From 1a7c7fb2084a25d8c1ac3a5576011a05762d937a Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Sat, 22 Mar 2025 14:27:45 -0400 Subject: [PATCH 10/15] Ignore [no-any-return] --- sqlmodel/sql/sqltypes.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index c64a343492..1bd18023cc 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -74,7 +74,8 @@ def process_bind_param(self, value: Any, dialect: Any) -> JSONValue: # noqa: AN for k, v in value.items() } - return to_jsonable_python(value) + # We know to_jsonable_python returns a JSON-serializable value, but mypy sees it as Any + return to_jsonable_python(value) # type: ignore[no-any-return] def process_result_value( self, value: Any, dialect: Any From 376b41ec980fc39c9f9ad2a1e65b1d0c695d0dbb Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Thu, 24 Apr 2025 13:29:23 -0700 Subject: [PATCH 11/15] add commit to trigger github actions --- sqlmodel/sql/sqltypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index 1bd18023cc..01d828b919 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -74,7 +74,7 @@ def process_bind_param(self, value: Any, dialect: Any) -> JSONValue: # noqa: AN for k, v in value.items() } - # We know to_jsonable_python returns a JSON-serializable value, but mypy sees it as Any + # We know to_jsonable_python returns a JSON-serializable value, but mypy sees it as an Any type return to_jsonable_python(value) # type: ignore[no-any-return] def process_result_value( From 14c2fc62e96646a6146d3f269be7a18bb9bbb40b Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Sun, 8 Jun 2025 00:39:34 -0400 Subject: [PATCH 12/15] Added docs --- docs/advanced/index.md | 16 ++++--- docs/advanced/pydantic-jsonb.md | 74 +++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 docs/advanced/pydantic-jsonb.md diff --git a/docs/advanced/index.md b/docs/advanced/index.md index f6178249ce..6b009b44c2 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -1,10 +1,16 @@ # Advanced User Guide -The **Advanced User Guide** is gradually growing, you can already read about some advanced topics. +The **Advanced User Guide** covers advanced topics and features of SQLModel. -At some point it will include: +Current topics include: -* How to use `async` and `await` with the async session. -* How to run migrations. -* How to combine **SQLModel** models with SQLAlchemy. +* [Working with Decimal Fields](decimal.md) - How to handle decimal numbers in SQLModel +* [Working with UUID Fields](uuid.md) - How to use UUID fields in your models +* [Storing Pydantic Models in JSONB Columns](pydantic-jsonb.md) - How to store and work with Pydantic models in JSONB columns + +Coming soon: + +* How to use `async` and `await` with the async session +* How to run migrations +* How to combine **SQLModel** models with SQLAlchemy * ...and more. 🤓 diff --git a/docs/advanced/pydantic-jsonb.md b/docs/advanced/pydantic-jsonb.md new file mode 100644 index 0000000000..55a1fcfdc3 --- /dev/null +++ b/docs/advanced/pydantic-jsonb.md @@ -0,0 +1,74 @@ +# Storing Pydantic Models in JSONB Columns + +You can store Pydantic models (and lists or dicts of them) in JSON or JSONB database columns using the `PydanticJSONB` utility. + +This is especially useful when: + +- You want to persist flexible, nested data structures in your models. +- You prefer to avoid separate relational tables for structured fields like metadata, config, or address. +- You want automatic serialization and deserialization using Pydantic. + +## Usage + +You can use it with SQLModel like this: + +```python +from typing import Optional +from pydantic import BaseModel +from sqlmodel import SQLModel, Field, Column +from sqlmodel.sql.sqltypes import PydanticJSONB + +class Address(BaseModel): + street: str + city: str + +class User(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + address: Address = Field(sa_column=Column(PydanticJSONB(Address))) +``` + +This will store the `address` field as a `JSONB` column in PostgreSQL and automatically serialize/deserialize to and from the `Address` Pydantic model. + +If you're using a list or dict of models, `PydanticJSONB` supports that too: + +```python +Field(sa_column=Column(PydanticJSONB(List[SomeModel]))) +Field(sa_column=Column(PydanticJSONB(Dict[str, SomeModel]))) +``` + +## Requirements + +* PostgreSQL (for full `JSONB` support). +* Pydantic v2. +* SQLAlchemy 2.x. + +## Limitations + +### Nested Model Updates + +Currently, updating attributes inside a nested Pydantic model doesn't automatically trigger a database update. This is similar to how plain dictionaries work in SQLAlchemy. For example: + +```python +# This won't trigger a database update +row = select(...) # some MyTable row +row.data.x = 1 +db.add(row) # no effect, change isn't detected +``` + +To update nested model attributes, you need to reassign the entire model: + +```python +# Workaround: Create a new instance and reassign +updated = ExtraData(**row.data.model_dump()) +updated.x = 1 +row.data = updated +db.add(row) +``` + +This limitation will be addressed in a future update using `MutableDict` to enable change tracking for nested fields. The `MutableDict` implementation will emit change events when the contents of the dictionary are altered, including when values are added or removed. + +## Notes + +* Falls back to `JSON` if `JSONB` is not available. +* Only tested with PostgreSQL at the moment. diff --git a/mkdocs.yml b/mkdocs.yml index c59ccd245a..8e036fbfaf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -128,6 +128,7 @@ nav: - advanced/index.md - advanced/decimal.md - advanced/uuid.md + - advanced/pydantic-jsonb.md - Resources: - resources/index.md - help.md From bcd8689f078dea32d43c409fc24c278cd17f0b65 Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Sun, 8 Jun 2025 11:35:13 -0400 Subject: [PATCH 13/15] =Added examples for creating, storing, and retrieving data --- docs/advanced/pydantic-jsonb.md | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/advanced/pydantic-jsonb.md b/docs/advanced/pydantic-jsonb.md index 55a1fcfdc3..9b89db23df 100644 --- a/docs/advanced/pydantic-jsonb.md +++ b/docs/advanced/pydantic-jsonb.md @@ -37,6 +37,43 @@ Field(sa_column=Column(PydanticJSONB(List[SomeModel]))) Field(sa_column=Column(PydanticJSONB(Dict[str, SomeModel]))) ``` +## Create & Store Data + +Here's how to create and store data with Pydantic models in JSONB columns: + +```python +from sqlmodel import Session, create_engine + +engine = create_engine("postgresql://user:password@localhost/db") + +# Insert a User with an Address +with Session(engine) as session: + user = User( + name="John Doe", + address=Address(street="123 Main St", city="New York") + ) + session.add(user) + session.commit() +``` + +## Retrieve & Use Data + +When you retrieve the data, it's automatically converted back to a Pydantic model: + +```python +with Session(engine) as session: + user = session.query(User).first() + print(user.address.street) # "123 Main St" + print(user.address.city) # "New York" + print(type(user.address)) # +``` + +Result: +✅ No need for `Address(**user.address)` – it's already an `Address` instance! +✅ Automatic conversion between JSONB and Pydantic models. + +This simplifies handling structured data in SQLModel, making JSONB storage seamless and ergonomic. 🚀 + ## Requirements * PostgreSQL (for full `JSONB` support). From 483cea7d00fb6da365da5288231d1645df59cbbd Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Sun, 15 Jun 2025 15:35:43 -0700 Subject: [PATCH 14/15] Re-add 'gradually growing' statement --- docs/advanced/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced/index.md b/docs/advanced/index.md index 6b009b44c2..81007d4726 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -1,6 +1,6 @@ # Advanced User Guide -The **Advanced User Guide** covers advanced topics and features of SQLModel. +The **Advanced User Guide** is gradually growing, you can already read about some advanced topics Current topics include: From 1580ecc1bf672881459be89ec4b62554221d8a81 Mon Sep 17 00:00:00 2001 From: Aman Ibrahim Date: Sat, 28 Jun 2025 09:54:15 +0100 Subject: [PATCH 15/15] Enhance PydanticJSONB to use JSON variant for better compatibility across databases --- sqlmodel/sql/sqltypes.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sqlmodel/sql/sqltypes.py b/sqlmodel/sql/sqltypes.py index 01d828b919..486bdb769a 100644 --- a/sqlmodel/sql/sqltypes.py +++ b/sqlmodel/sql/sqltypes.py @@ -13,7 +13,7 @@ from pydantic import BaseModel from pydantic_core import to_jsonable_python -from sqlalchemy import types +from sqlalchemy import JSON, types from sqlalchemy.dialects.postgresql import JSONB # for Postgres JSONB from sqlalchemy.engine.interfaces import Dialect @@ -21,6 +21,7 @@ # Define a type alias for JSON-serializable values JSONValue = Union[Dict[str, Any], List[Any], str, int, float, bool, None] +JSON_VARIANT = JSON().with_variant(JSONB, "postgresql") class AutoString(types.TypeDecorator): # type: ignore @@ -38,7 +39,7 @@ def load_dialect_impl(self, dialect: Dialect) -> types.TypeEngine[Any]: class PydanticJSONB(types.TypeDecorator): # type: ignore """Custom type to automatically handle Pydantic model serialization.""" - impl = JSONB # use JSONB type in Postgres (fallback to JSON for others) + impl = JSON_VARIANT cache_ok = True # allow SQLAlchemy to cache results def __init__(