Skip to content

Commit e888e39

Browse files
committed
feat: configurable JSON serialization with Protocol-based architecture
Refactor the serialization layer to use a JSONSerializer Protocol with backend support (msgspec, orjson, stdlib), enable custom type_encoders at the application and function level, and integrate with Litestar's type_encoders system. - Replace _serialization.py with utils/serialization.py containing the Protocol-based serializer hierarchy (MsgspecSerializer, OrjsonSerializer, StdlibSerializer) and DEFAULT_TYPE_ENCODERS for asyncpg/uuid_utils types - Move schema_dump and type guards into utils/serializers.py - Wire SQLAlchemyInitPlugin to honor Litestar's app-level type_encoders - Delete the unreleased advanced_alchemy/_serialization.py and advanced_alchemy/service/typing.py modules - Update all internal callers to import from the new locations - Add comprehensive function-based unit tests covering encoder/decoder helpers, type markers, serializer protocol, and orjson integration - Add Litestar integration test for type_encoders flow Fixes: #710
1 parent 009edb1 commit e888e39

41 files changed

Lines changed: 1685 additions & 542 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

advanced_alchemy/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
types,
1414
utils,
1515
)
16+
from advanced_alchemy.utils.serialization import decode_json, encode_json
1617

1718
__all__ = (
1819
"alembic",
1920
"base",
2021
"cli",
2122
"config",
23+
"decode_json",
24+
"encode_json",
2225
"exceptions",
2326
"extensions",
2427
"filters",

advanced_alchemy/_serialization.py

Lines changed: 0 additions & 194 deletions
This file was deleted.

advanced_alchemy/alembic/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ async def dump_tables(
8484
) -> None:
8585
from types import new_class
8686

87-
from advanced_alchemy._serialization import encode_json
87+
from advanced_alchemy.utils.serialization import encode_json
8888

8989
try:
9090
from rich import get_console

advanced_alchemy/cache/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from sqlalchemy import inspect as sa_inspect
66

7-
from advanced_alchemy._serialization import (
7+
from advanced_alchemy.utils.serialization import (
88
decode_complex_type,
99
decode_json,
1010
encode_complex_type,

advanced_alchemy/config/engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from dataclasses import dataclass
22
from typing import TYPE_CHECKING, Callable, Literal, Optional, Union
33

4-
from advanced_alchemy._serialization import decode_json, encode_json
54
from advanced_alchemy.utils.dataclass import Empty
5+
from advanced_alchemy.utils.serialization import decode_json, encode_json
66

77
if TYPE_CHECKING:
88
from collections.abc import Mapping

advanced_alchemy/extensions/flask/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212
from sqlalchemy.exc import OperationalError
1313
from typing_extensions import Literal
1414

15-
from advanced_alchemy._serialization import decode_json, encode_json
1615
from advanced_alchemy.base import metadata_registry
1716
from advanced_alchemy.config import EngineConfig as _EngineConfig
1817
from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig
1918
from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig
2019
from advanced_alchemy.exceptions import ImproperConfigurationError
21-
from advanced_alchemy.service import schema_dump
20+
from advanced_alchemy.utils.serialization import decode_json, encode_json
21+
from advanced_alchemy.utils.serializers import schema_dump
2222

2323
if TYPE_CHECKING:
2424
from flask import Flask, Response

advanced_alchemy/extensions/litestar/plugins/init/plugin.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@
3434
StatementTypeT,
3535
)
3636
from advanced_alchemy.service import ModelDictListT, ModelDictT, ModelDTOT, ModelOrRowMappingT, ModelT, OffsetPagination
37+
from advanced_alchemy.utils.serialization import DEFAULT_TYPE_ENCODERS
3738

3839
if TYPE_CHECKING:
40+
from collections.abc import Callable
41+
3942
from click import Group
4043
from litestar.config.app import AppConfig
4144
from litestar.types import BeforeMessageSendHookHandler
@@ -44,6 +47,56 @@
4447

4548
__all__ = ("SQLAlchemyInitPlugin",)
4649

50+
51+
def _get_aa_type_encoders() -> dict[type, "Callable[[Any], Any]"]:
52+
"""Get Advanced Alchemy's built-in type encoders.
53+
54+
These encoders handle database-specific types that need special
55+
serialization. They are added to Litestar's type_encoders with
56+
lower precedence than user-defined encoders.
57+
58+
Returns:
59+
Dictionary of type to encoder function mappings.
60+
"""
61+
encoders: dict[type, Callable[[Any], Any]] = {**DEFAULT_TYPE_ENCODERS}
62+
63+
# asyncpg UUID type (PostgreSQL asyncpg driver)
64+
with contextlib.suppress(ImportError):
65+
from asyncpg.pgproto import pgproto # pyright: ignore[reportMissingImports]
66+
67+
encoders[pgproto.UUID] = str
68+
69+
# uuid_utils UUID type (fast UUID implementation)
70+
with contextlib.suppress(ImportError):
71+
import uuid_utils # pyright: ignore[reportMissingImports]
72+
73+
encoders[uuid_utils.UUID] = str # pyright: ignore[reportUnknownMemberType]
74+
75+
return encoders
76+
77+
78+
def _get_aa_type_decoders() -> list[tuple["Callable[[Any], bool]", "Callable[[type, Any], Any]"]]:
79+
"""Get Advanced Alchemy's built-in type decoders.
80+
81+
These decoders handle database-specific types that need special
82+
deserialization during request parsing.
83+
84+
Returns:
85+
List of (predicate, decoder) tuples for Litestar's type_decoders.
86+
"""
87+
decoders: list[tuple[Callable[[Any], bool], Callable[[type, Any], Any]]] = []
88+
89+
# uuid_utils UUID type decoder
90+
with contextlib.suppress(ImportError):
91+
import uuid_utils # pyright: ignore[reportMissingImports]
92+
93+
decoders.append(
94+
(lambda x: x is uuid_utils.UUID, lambda t, v: t(str(v))) # pyright: ignore[reportUnknownMemberType]
95+
)
96+
97+
return decoders
98+
99+
47100
signature_namespace_values: dict[str, Any] = {
48101
"BeforeAfter": BeforeAfter,
49102
"OnBeforeAfter": OnBeforeAfter,
@@ -125,20 +178,24 @@ def on_app_init(self, app_config: "AppConfig") -> "AppConfig":
125178
app_config: The :class:`AppConfig <.config.app.AppConfig>` instance.
126179
"""
127180
self._validate_config()
181+
182+
# Add AA built-in type encoders/decoders
183+
# These are added BEFORE user encoders so user config takes precedence
184+
aa_encoders = _get_aa_type_encoders()
185+
aa_decoders = _get_aa_type_decoders()
186+
187+
# Merge: AA built-ins first, then user encoders override
188+
app_config.type_encoders = {**aa_encoders, **(app_config.type_encoders or {})}
189+
app_config.type_decoders = [*aa_decoders, *(app_config.type_decoders or [])]
190+
128191
with contextlib.suppress(ImportError):
129192
from asyncpg.pgproto import pgproto # pyright: ignore[reportMissingImports]
130193

131194
signature_namespace_values.update({"pgproto.UUID": pgproto.UUID})
132-
app_config.type_encoders = {pgproto.UUID: str, **(app_config.type_encoders or {})}
133195
with contextlib.suppress(ImportError):
134196
import uuid_utils # pyright: ignore[reportMissingImports]
135197

136198
signature_namespace_values.update({"uuid_utils.UUID": uuid_utils.UUID}) # pyright: ignore[reportUnknownMemberType]
137-
app_config.type_encoders = {uuid_utils.UUID: str, **(app_config.type_encoders or {})} # pyright: ignore[reportUnknownMemberType]
138-
app_config.type_decoders = [
139-
(lambda x: x is uuid_utils.UUID, lambda t, v: t(str(v))), # pyright: ignore[reportUnknownMemberType]
140-
*(app_config.type_decoders or []),
141-
]
142199
configure_exception_handler = False
143200
for config in self.config:
144201
if config.set_default_exception_handler:

advanced_alchemy/extensions/sanic/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@
2929
from typing_extensions import Literal
3030

3131
from advanced_alchemy._listeners import set_async_context
32-
from advanced_alchemy._serialization import decode_json, encode_json
3332
from advanced_alchemy.base import metadata_registry
3433
from advanced_alchemy.config import EngineConfig as _EngineConfig
3534
from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig
3635
from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig
37-
from advanced_alchemy.service import schema_dump
36+
from advanced_alchemy.utils.serialization import decode_json, encode_json
37+
from advanced_alchemy.utils.serializers import schema_dump
3838

3939
logger = logging.getLogger("advanced_alchemy.extensions.sanic")
4040

advanced_alchemy/extensions/starlette/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
from starlette.requests import Request
1616
from typing_extensions import Literal
1717

18-
from advanced_alchemy._serialization import decode_json, encode_json
1918
from advanced_alchemy.base import metadata_registry
2019
from advanced_alchemy.config import EngineConfig as _EngineConfig
2120
from advanced_alchemy.config.asyncio import SQLAlchemyAsyncConfig as _SQLAlchemyAsyncConfig
2221
from advanced_alchemy.config.sync import SQLAlchemySyncConfig as _SQLAlchemySyncConfig
2322
from advanced_alchemy.routing.context import reset_routing_context
24-
from advanced_alchemy.service import schema_dump
23+
from advanced_alchemy.utils.serialization import decode_json, encode_json
24+
from advanced_alchemy.utils.serializers import schema_dump
2525

2626
if TYPE_CHECKING:
2727
from sqlalchemy.ext.asyncio import AsyncSession

advanced_alchemy/repository/_async.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@
6767
was_attribute_set,
6868
)
6969
from advanced_alchemy.repository.typing import MISSING, ModelT, OrderingPair, PrimaryKeyType, T
70-
from advanced_alchemy.service.typing import schema_dump
7170
from advanced_alchemy.utils.dataclass import Empty, EmptyType
7271
from advanced_alchemy.utils.deprecation import warn_deprecation
72+
from advanced_alchemy.utils.serializers import schema_dump
7373
from advanced_alchemy.utils.text import slugify
7474

7575
if TYPE_CHECKING:

0 commit comments

Comments
 (0)