Skip to content

Commit 81c937e

Browse files
authored
feat(service): add configurable schema dump settings (#750)
## Problem Service model conversion currently hardcodes partial-update dump behavior for schema-like inputs. Pydantic models are dumped with `exclude_unset=True`, msgspec structs drop `UNSET`, and attrs instances drop `attrs.NOTHING`. That preserves partial-update semantics, but it gives callers no supported way to intentionally include unset/default values for update-like schemas. A common case is a schema where an unset field should still be applied with its schema default: ```python class InviteUpdate(BaseModel): name: str | None = None is_admin: bool = False await invite_service.update(data, item_id=invite_id) ``` With the previous hardcoded behavior, `is_admin=False` is excluded unless the caller explicitly sets it on the Pydantic model. ## Changes ### `feat(service): add configurable schema dump behavior` Adds `SchemaDumpConfig` and exposes it as a class-level service default plus a per-call override on write operations that convert schema-like input through `to_model()`. ```python class InviteService(SQLAlchemyAsyncRepositoryService[Invite]): schema_dump_config = SchemaDumpConfig(exclude_unset=False) await invite_service.update( data, item_id=invite_id, schema_dump_config=SchemaDumpConfig(exclude_unset=False), ) ``` The per-call override is scoped to that operation only and does not mutate the service default or affect later calls. ### `feat(serialization): centralize schema dumping` Routes Pydantic, msgspec, attrs, and dataclass inputs through `schema_dump(..., config=...)` instead of duplicating hardcoded conversion logic inside sync and async services. `SchemaDumpConfig` supports: - `exclude_unset`, for Pydantic field-set behavior - `exclude_none`, for dropping `None` values - `exclude_defaults`, for dropping values equal to schema defaults where supported - `exclude_sentinels`, for dropping sentinel values such as Pydantic `MISSING`, msgspec `UNSET`, attrs `NOTHING`, and Advanced Alchemy `Empty` ### `fix(fixtures): align service overrides with the new conversion contract` Updates test fixture services that override `to_model()` so they accept and forward `schema_dump_config`. This keeps custom service conversion hooks compatible with the new base method signature and preserves the selected dump behavior in the inline slug-generation path. ## Compatibility Notes - Existing defaults preserve current partial-update behavior: `SchemaDumpConfig()` keeps `exclude_unset=True` and `exclude_sentinels=True`. - The existing `schema_dump(..., exclude_unset=...)` argument remains supported for callers using the standalone utility. - Optional schema libraries remain guarded. Advanced Alchemy should not fail to import when Pydantic, msgspec, attrs, or cattrs are absent. - `to_schema()` is not changed by this PR; the configurable behavior applies to service input conversion through `to_model()`.
1 parent e6c2563 commit 81c937e

10 files changed

Lines changed: 828 additions & 139 deletions

File tree

advanced_alchemy/service/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
ModelDictListT,
2929
ModelDictT,
3030
ModelDTOT,
31+
SchemaDumpConfig,
3132
SupportedSchemaModel,
3233
fields,
3334
is_attrs_instance,
@@ -77,6 +78,7 @@
7778
"SQLAlchemySyncQueryService",
7879
"SQLAlchemySyncRepositoryReadService",
7980
"SQLAlchemySyncRepositoryService",
81+
"SchemaDumpConfig",
8082
"SupportedSchemaModel",
8183
"fields",
8284
"find_filter",

advanced_alchemy/service/_async.py

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,18 @@
3131
from advanced_alchemy.utils.dataclass import Empty, EmptyType
3232
from advanced_alchemy.utils.deprecation import warn_deprecation
3333
from advanced_alchemy.utils.serialization import (
34-
UNSET,
3534
BulkModelDictT,
3635
ModelDictListT,
3736
ModelDictT,
38-
asdict,
39-
attrs_nothing,
37+
SchemaDumpConfig,
4038
is_attrs_instance,
39+
is_dataclass,
4140
is_dict,
4241
is_dto_data,
4342
is_msgspec_struct,
4443
is_pydantic_model,
4544
is_sqlmodel_table_model,
45+
schema_dump,
4646
)
4747

4848

@@ -107,6 +107,8 @@ class SQLAlchemyAsyncRepositoryReadService(ResultConverter, Generic[ModelT, SQLA
107107
"""Optionally apply the ``unique()`` method to results before returning."""
108108
count_with_window_function: ClassVar[bool] = True
109109
"""Use an analytical window function to count results. This allows the count to be performed in a single query."""
110+
schema_dump_config: ClassVar[SchemaDumpConfig] = SchemaDumpConfig()
111+
"""Default dump behavior when schema-like input is converted into a model."""
110112
_repository_instance: SQLAlchemyAsyncRepositoryT
111113

112114
def __init__(
@@ -462,16 +464,19 @@ async def to_model(
462464
self,
463465
data: "ModelDictT[ModelT]",
464466
operation: Optional[str] = None,
467+
schema_dump_config: Optional[SchemaDumpConfig] = None,
465468
) -> ModelT:
466469
"""Parse and Convert input into a model.
467470
468471
Args:
469472
data: Representations to be created.
470473
operation: Optional operation flag so that you can provide behavior based on CRUD operation
474+
schema_dump_config: Optional schema dump behavior for this conversion.
471475
472476
Returns:
473477
Representation of created instances.
474478
"""
479+
dump_config = schema_dump_config if schema_dump_config is not None else self.schema_dump_config
475480
operation_map = {
476481
"create": self.to_model_on_create,
477482
"update": self.to_model_on_update,
@@ -486,35 +491,15 @@ async def to_model(
486491
return model_from_dict(self.model_type, **data)
487492
if is_sqlmodel_table_model(data):
488493
return model_from_dict(self.model_type, **model_to_dict(cast("ModelProtocol", data)))
489-
if is_pydantic_model(data):
494+
if is_pydantic_model(data) or is_msgspec_struct(data) or is_attrs_instance(data) or is_dataclass(data):
490495
return model_from_dict(
491496
self.model_type,
492-
**data.model_dump(exclude_unset=True),
493-
)
494-
495-
if is_msgspec_struct(data):
496-
return model_from_dict(
497-
self.model_type,
498-
**{
499-
f: getattr(data, f)
500-
for f in data.__struct_fields__
501-
if hasattr(data, f) and getattr(data, f) is not UNSET
502-
},
497+
**schema_dump(data, config=dump_config),
503498
)
504499

505500
if is_dto_data(data):
506501
return cast("ModelT", data.create_instance())
507502

508-
if is_attrs_instance(data):
509-
# Filter out attrs.NOTHING values for partial updates
510-
def filter_unset(attr: Any, value: Any) -> bool: # noqa: ARG001
511-
return value is not attrs_nothing
512-
513-
return model_from_dict(
514-
self.model_type,
515-
**asdict(data, filter=filter_unset),
516-
)
517-
518503
# Fallback for objects with __dict__ (e.g., regular classes)
519504
if hasattr(data, "__dict__") and not isinstance(data, self.model_type):
520505
return model_from_dict(
@@ -773,6 +758,7 @@ async def create(
773758
auto_refresh: Optional[bool] = None,
774759
error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty,
775760
bind_group: Optional[str] = None,
761+
schema_dump_config: Optional[SchemaDumpConfig] = None,
776762
) -> "ModelT":
777763
"""Wrap repository instance creation.
778764
@@ -784,11 +770,12 @@ async def create(
784770
error_messages: An optional dictionary of templates to use
785771
for friendlier error messages to clients
786772
bind_group: Optional routing group to use for the operation.
773+
schema_dump_config: Optional schema dump behavior for this operation.
787774
788775
Returns:
789776
Representation of created instance.
790777
"""
791-
data = await self.to_model(data, "create")
778+
data = await self.to_model(data, "create", schema_dump_config=schema_dump_config)
792779
return cast(
793780
"ModelT",
794781
await self.repository.add(
@@ -809,6 +796,7 @@ async def create_many(
809796
auto_expunge: Optional[bool] = None,
810797
error_messages: Optional[Union[ErrorMessages, EmptyType]] = Empty,
811798
bind_group: Optional[str] = None,
799+
schema_dump_config: Optional[SchemaDumpConfig] = None,
812800
) -> Sequence[ModelT]:
813801
"""Wrap repository bulk instance creation.
814802
@@ -819,13 +807,17 @@ async def create_many(
819807
error_messages: An optional dictionary of templates to use
820808
for friendlier error messages to clients
821809
bind_group: Optional routing group to use for the operation.
810+
schema_dump_config: Optional schema dump behavior for this operation.
822811
823812
Returns:
824813
Representation of created instances.
825814
"""
826815
if is_dto_data(data):
827816
data = data.create_instance()
828-
data = [(await self.to_model(datum, "create")) for datum in cast("ModelDictListT[ModelT]", data)]
817+
data = [
818+
(await self.to_model(datum, "create", schema_dump_config=schema_dump_config))
819+
for datum in cast("ModelDictListT[ModelT]", data)
820+
]
829821
return cast(
830822
"Sequence[ModelT]",
831823
await self.repository.add_many(
@@ -853,6 +845,7 @@ async def update(
853845
execution_options: Optional[dict[str, Any]] = None,
854846
uniquify: Optional[bool] = None,
855847
bind_group: Optional[str] = None,
848+
schema_dump_config: Optional[SchemaDumpConfig] = None,
856849
) -> "ModelT":
857850
"""Wrap repository update operation.
858851
@@ -875,6 +868,7 @@ async def update(
875868
execution_options: Set default execution options
876869
uniquify: Optionally apply the ``unique()`` method to results before returning.
877870
bind_group: Optional routing group to use for the operation.
871+
schema_dump_config: Optional schema dump behavior for this operation.
878872
879873
Raises:
880874
RepositoryError: If no configuration or session is provided.
@@ -885,7 +879,7 @@ async def update(
885879
# ALWAYS convert data through to_model first to ensure operation hooks are called
886880
# This ensures custom to_model() implementations receive the operation="update" parameter
887881
# and that to_model_on_update() is properly invoked via the operation_map
888-
data = await self.to_model(data, "update")
882+
data = await self.to_model(data, "update", schema_dump_config=schema_dump_config)
889883

890884
if item_id is not None:
891885
# When item_id is provided, update existing instance rather than replacing it
@@ -958,6 +952,7 @@ async def update_many(
958952
execution_options: Optional[dict[str, Any]] = None,
959953
uniquify: Optional[bool] = None,
960954
bind_group: Optional[str] = None,
955+
schema_dump_config: Optional[SchemaDumpConfig] = None,
961956
) -> Sequence[ModelT]:
962957
"""Wrap repository bulk instance update.
963958
@@ -971,13 +966,17 @@ async def update_many(
971966
execution_options: Set default execution options
972967
uniquify: Optionally apply the ``unique()`` method to results before returning.
973968
bind_group: Optional routing group to use for the operation.
969+
schema_dump_config: Optional schema dump behavior for this operation.
974970
975971
Returns:
976972
Representation of updated instances.
977973
"""
978974
if is_dto_data(data):
979975
data = data.create_instance()
980-
data = [(await self.to_model(datum, "update")) for datum in cast("ModelDictListT[ModelT]", data)]
976+
data = [
977+
(await self.to_model(datum, "update", schema_dump_config=schema_dump_config))
978+
for datum in cast("ModelDictListT[ModelT]", data)
979+
]
981980
return cast(
982981
"Sequence[ModelT]",
983982
await self.repository.update_many(
@@ -1008,6 +1007,7 @@ async def upsert(
10081007
execution_options: Optional[dict[str, Any]] = None,
10091008
uniquify: Optional[bool] = None,
10101009
bind_group: Optional[str] = None,
1010+
schema_dump_config: Optional[SchemaDumpConfig] = None,
10111011
) -> ModelT:
10121012
"""Wrap repository upsert operation.
10131013
@@ -1031,11 +1031,12 @@ async def upsert(
10311031
execution_options: Set default execution options
10321032
uniquify: Optionally apply the ``unique()`` method to results before returning.
10331033
bind_group: Optional routing group to use for the operation.
1034+
schema_dump_config: Optional schema dump behavior for this operation.
10341035
10351036
Returns:
10361037
Updated or created representation.
10371038
"""
1038-
data = await self.to_model(data, "upsert")
1039+
data = await self.to_model(data, "upsert", schema_dump_config=schema_dump_config)
10391040
# Handle ID extraction and setting for single-column PKs
10401041
# For composite PKs, the repository's upsert() handles this directly
10411042
if item_id is None:
@@ -1079,6 +1080,7 @@ async def upsert_many(
10791080
execution_options: Optional[dict[str, Any]] = None,
10801081
uniquify: Optional[bool] = None,
10811082
bind_group: Optional[str] = None,
1083+
schema_dump_config: Optional[SchemaDumpConfig] = None,
10821084
) -> Sequence[ModelT]:
10831085
"""Wrap repository upsert operation.
10841086
@@ -1095,13 +1097,17 @@ async def upsert_many(
10951097
execution_options: Set default execution options
10961098
uniquify: Optionally apply the ``unique()`` method to results before returning.
10971099
bind_group: Optional routing group to use for the operation.
1100+
schema_dump_config: Optional schema dump behavior for this operation.
10981101
10991102
Returns:
11001103
Updated or created representation.
11011104
"""
11021105
if is_dto_data(data):
11031106
data = data.create_instance()
1104-
data = [(await self.to_model(datum, "upsert")) for datum in cast("ModelDictListT[ModelT]", data)]
1107+
data = [
1108+
(await self.to_model(datum, "upsert", schema_dump_config=schema_dump_config))
1109+
for datum in cast("ModelDictListT[ModelT]", data)
1110+
]
11051111
return cast(
11061112
"Sequence[ModelT]",
11071113
await self.repository.upsert_many(
@@ -1133,6 +1139,7 @@ async def get_or_upsert(
11331139
execution_options: Optional[dict[str, Any]] = None,
11341140
uniquify: Optional[bool] = None,
11351141
bind_group: Optional[str] = None,
1142+
schema_dump_config: Optional[SchemaDumpConfig] = None,
11361143
**kwargs: Any,
11371144
) -> tuple[ModelT, bool]:
11381145
"""Wrap repository instance creation.
@@ -1158,13 +1165,14 @@ async def get_or_upsert(
11581165
execution_options: Set default execution options
11591166
uniquify: Optionally apply the ``unique()`` method to results before returning.
11601167
bind_group: Optional routing group to use for the operation.
1168+
schema_dump_config: Optional schema dump behavior for this operation.
11611169
**kwargs: Identifier of the instance to be retrieved.
11621170
11631171
Returns:
11641172
Representation of created instance.
11651173
"""
11661174
match_fields = match_fields or self.match_fields
1167-
validated_model = await self.to_model(kwargs, "create")
1175+
validated_model = await self.to_model(kwargs, "create", schema_dump_config=schema_dump_config)
11681176
return cast(
11691177
"tuple[ModelT, bool]",
11701178
await self.repository.get_or_upsert(
@@ -1199,6 +1207,7 @@ async def get_and_update(
11991207
execution_options: Optional[dict[str, Any]] = None,
12001208
uniquify: Optional[bool] = None,
12011209
bind_group: Optional[str] = None,
1210+
schema_dump_config: Optional[SchemaDumpConfig] = None,
12021211
**kwargs: Any,
12031212
) -> tuple[ModelT, bool]:
12041213
"""Wrap repository instance creation.
@@ -1221,13 +1230,14 @@ async def get_and_update(
12211230
execution_options: Set default execution options
12221231
uniquify: Optionally apply the ``unique()`` method to results before returning.
12231232
bind_group: Optional routing group to use for the operation.
1233+
schema_dump_config: Optional schema dump behavior for this operation.
12241234
**kwargs: Identifier of the instance to be retrieved.
12251235
12261236
Returns:
12271237
Representation of updated instance.
12281238
"""
12291239
match_fields = match_fields or self.match_fields
1230-
validated_model = await self.to_model(kwargs, "update")
1240+
validated_model = await self.to_model(kwargs, "update", schema_dump_config=schema_dump_config)
12311241
return cast(
12321242
"tuple[ModelT, bool]",
12331243
await self.repository.get_and_update(

0 commit comments

Comments
 (0)