Skip to content

Commit dfc30a0

Browse files
authored
fix: ensure to_model called with update operation for all data types (#575)
Fixes #555 This PR fixes a bug in the Service layer's `update()` method where dict, Pydantic, msgspec, and attrs data bypassed the `to_model()` operation map, preventing custom `to_model()` implementations from being invoked during update operations.
1 parent fa56eb0 commit dfc30a0

4 files changed

Lines changed: 493 additions & 57 deletions

File tree

advanced_alchemy/service/_async.py

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Any, ClassVar, Generic, Optional, Union, cast
1111

1212
from sqlalchemy import Select
13+
from sqlalchemy import inspect as sa_inspect
1314
from sqlalchemy.ext.asyncio import AsyncSession
1415
from sqlalchemy.ext.asyncio.scoping import async_scoped_session
1516
from sqlalchemy.orm import InstrumentedAttribute
@@ -38,7 +39,6 @@
3839
is_dto_data,
3940
is_msgspec_struct,
4041
is_pydantic_model,
41-
schema_dump,
4242
)
4343
from advanced_alchemy.utils.dataclass import Empty, EmptyType
4444

@@ -738,38 +738,42 @@ async def update(
738738
Returns:
739739
Updated representation.
740740
"""
741-
if (
742-
is_dict(data) or is_pydantic_model(data) or is_msgspec_struct(data) or is_attrs_instance(data)
743-
) and item_id is not None:
741+
# ALWAYS convert data through to_model first to ensure operation hooks are called
742+
# This ensures custom to_model() implementations receive the operation="update" parameter
743+
# and that to_model_on_update() is properly invoked via the operation_map
744+
data = await self.to_model(data, "update")
745+
746+
if item_id is not None:
747+
# When item_id is provided, update existing instance rather than replacing it
748+
# This preserves relationships and database-managed fields
744749
existing_instance = await self.repository.get(
745750
item_id, id_attribute=id_attribute, load=load, execution_options=execution_options
746751
)
747-
update_data = (
748-
await self.to_model_on_update(data) if is_dict(data) else schema_dump(data, exclude_unset=True)
749-
)
750752

751-
if is_dict(update_data):
752-
for key, value in update_data.items():
753-
if getattr(existing_instance, key, MISSING) is not MISSING:
754-
setattr(existing_instance, key, value)
753+
# Extract attributes from converted model to update existing instance
754+
# Only copy attributes that were explicitly set (present in instance state)
755+
instance_state = sa_inspect(data) # pyright: ignore[reportOptionalMemberAccess]
756+
for attr in instance_state.mapper.attrs: # type: ignore[union-attr] # pyright: ignore[reportOptionalMemberAccess]
757+
# Check if attribute was explicitly set in the instance
758+
if attr.key in instance_state.dict: # type: ignore[union-attr] # pyright: ignore[reportOptionalMemberAccess]
759+
value = getattr(data, attr.key, MISSING)
760+
if value is not MISSING and hasattr(existing_instance, attr.key):
761+
setattr(existing_instance, attr.key, value)
762+
755763
data = existing_instance
756-
else:
757-
data = await self.to_model(data, "update")
758-
if (
759-
item_id is None
760-
and self.repository.get_id_attribute_value( # pyright: ignore[reportUnknownMemberType]
761-
item=data,
762-
id_attribute=id_attribute,
763-
)
764-
is None
765-
):
766-
msg = (
767-
"Could not identify ID attribute value. One of the following is required: "
768-
f"``item_id`` or ``data.{id_attribute or self.repository.id_attribute}``"
769-
)
770-
raise RepositoryError(msg)
771-
if item_id is not None:
772-
data = self.repository.set_id_attribute_value(item_id=item_id, item=data, id_attribute=id_attribute) # pyright: ignore[reportUnknownMemberType]
764+
elif (
765+
self.repository.get_id_attribute_value( # pyright: ignore[reportUnknownMemberType]
766+
item=data,
767+
id_attribute=id_attribute,
768+
)
769+
is None
770+
):
771+
# No item_id provided and no ID on model - error
772+
msg = (
773+
"Could not identify ID attribute value. One of the following is required: "
774+
f"``item_id`` or ``data.{id_attribute or self.repository.id_attribute}``"
775+
)
776+
raise RepositoryError(msg)
773777
return cast(
774778
"ModelT",
775779
await self.repository.update(

advanced_alchemy/service/_sync.py

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from typing import Any, ClassVar, Generic, Optional, Union, cast
1313

1414
from sqlalchemy import Select
15+
from sqlalchemy import inspect as sa_inspect
1516
from sqlalchemy.orm import InstrumentedAttribute, Session
1617
from sqlalchemy.orm.scoping import scoped_session
1718
from sqlalchemy.sql import ColumnElement
@@ -37,7 +38,6 @@
3738
is_dto_data,
3839
is_msgspec_struct,
3940
is_pydantic_model,
40-
schema_dump,
4141
)
4242
from advanced_alchemy.utils.dataclass import Empty, EmptyType
4343

@@ -737,36 +737,42 @@ def update(
737737
Returns:
738738
Updated representation.
739739
"""
740-
if (
741-
is_dict(data) or is_pydantic_model(data) or is_msgspec_struct(data) or is_attrs_instance(data)
742-
) and item_id is not None:
740+
# ALWAYS convert data through to_model first to ensure operation hooks are called
741+
# This ensures custom to_model() implementations receive the operation="update" parameter
742+
# and that to_model_on_update() is properly invoked via the operation_map
743+
data = self.to_model(data, "update")
744+
745+
if item_id is not None:
746+
# When item_id is provided, update existing instance rather than replacing it
747+
# This preserves relationships and database-managed fields
743748
existing_instance = self.repository.get(
744749
item_id, id_attribute=id_attribute, load=load, execution_options=execution_options
745750
)
746-
update_data = self.to_model_on_update(data) if is_dict(data) else schema_dump(data, exclude_unset=True)
747751

748-
if is_dict(update_data):
749-
for key, value in update_data.items():
750-
if getattr(existing_instance, key, MISSING) is not MISSING:
751-
setattr(existing_instance, key, value)
752+
# Extract attributes from converted model to update existing instance
753+
# Only copy attributes that were explicitly set (present in instance state)
754+
instance_state = sa_inspect(data) # pyright: ignore[reportOptionalMemberAccess]
755+
for attr in instance_state.mapper.attrs: # type: ignore[union-attr] # pyright: ignore[reportOptionalMemberAccess]
756+
# Check if attribute was explicitly set in the instance
757+
if attr.key in instance_state.dict: # type: ignore[union-attr] # pyright: ignore[reportOptionalMemberAccess]
758+
value = getattr(data, attr.key, MISSING)
759+
if value is not MISSING and hasattr(existing_instance, attr.key):
760+
setattr(existing_instance, attr.key, value)
761+
752762
data = existing_instance
753-
else:
754-
data = self.to_model(data, "update")
755-
if (
756-
item_id is None
757-
and self.repository.get_id_attribute_value( # pyright: ignore[reportUnknownMemberType]
758-
item=data,
759-
id_attribute=id_attribute,
760-
)
761-
is None
762-
):
763-
msg = (
764-
"Could not identify ID attribute value. One of the following is required: "
765-
f"``item_id`` or ``data.{id_attribute or self.repository.id_attribute}``"
766-
)
767-
raise RepositoryError(msg)
768-
if item_id is not None:
769-
data = self.repository.set_id_attribute_value(item_id=item_id, item=data, id_attribute=id_attribute) # pyright: ignore[reportUnknownMemberType]
763+
elif (
764+
self.repository.get_id_attribute_value( # pyright: ignore[reportUnknownMemberType]
765+
item=data,
766+
id_attribute=id_attribute,
767+
)
768+
is None
769+
):
770+
# No item_id provided and no ID on model - error
771+
msg = (
772+
"Could not identify ID attribute value. One of the following is required: "
773+
f"``item_id`` or ``data.{id_attribute or self.repository.id_attribute}``"
774+
)
775+
raise RepositoryError(msg)
770776
return cast(
771777
"ModelT",
772778
self.repository.update(

docs/guides/patterns/repository-service.md

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,27 +33,81 @@ class TagService(service.SQLAlchemyAsyncRepositoryService[m.Tag]):
3333

3434
## Service Hooks
3535

36+
### Operation Flow
37+
38+
All service CRUD operations follow a consistent pattern:
39+
40+
```
41+
Operation (create/update/upsert/delete)
42+
43+
to_model(data, operation="create"|"update"|"upsert"|"delete")
44+
45+
operation_map routes to operation-specific hook:
46+
47+
to_model_on_create() # for create
48+
to_model_on_update() # for update
49+
to_model_on_upsert() # for upsert
50+
to_model_on_delete() # for delete
51+
```
52+
53+
This ensures custom `to_model()` implementations receive the operation parameter for all data types (dict, Pydantic, msgspec, attrs, model instances).
54+
3655
### Data Transformation Hooks
3756

57+
**Operation-Specific Hooks** (recommended pattern):
58+
3859
```python
39-
# Called before create
60+
# Called before create (via to_model operation_map)
4061
async def to_model_on_create(self, data: service.ModelDictT[m.User]) -> service.ModelDictT[m.User]:
4162
data = service.schema_dump(data)
4263
# Hash passwords, generate slugs, set defaults
4364
return data
4465

45-
# Called before update
66+
# Called before update (via to_model operation_map)
4667
async def to_model_on_update(self, data: service.ModelDictT[m.User]) -> service.ModelDictT[m.User]:
4768
data = service.schema_dump(data)
4869
# Update slugs, validate permissions
4970
return data
5071

51-
# Called before upsert (insert or update based on match_fields)
72+
# Called before upsert (via to_model operation_map)
5273
async def to_model_on_upsert(self, data: service.ModelDictT[m.User]) -> service.ModelDictT[m.User]:
5374
data = service.schema_dump(data)
5475
return data
5576
```
5677

78+
**Custom to_model() with Operation Parameter** (for shared logic across operations):
79+
80+
```python
81+
class UserService(service.SQLAlchemyAsyncRepositoryService[m.User]):
82+
class Repo(repository.SQLAlchemyAsyncRepository[m.User]):
83+
model_type = m.User
84+
85+
repository_type = Repo
86+
87+
async def to_model(
88+
self,
89+
data: service.ModelDictT[m.User],
90+
operation: str | None = None,
91+
) -> m.User:
92+
"""Custom to_model with operation-aware logic."""
93+
data = service.schema_dump(data)
94+
95+
# Shared preprocessing for all operations
96+
if service.is_dict(data):
97+
data["email"] = data.get("email", "").lower()
98+
99+
# Let parent call operation-specific hook via operation_map
100+
return await super().to_model(data, operation)
101+
102+
async def to_model_on_update(self, data: service.ModelDictT[m.User]) -> service.ModelDictT[m.User]:
103+
"""Update-specific logic (called after to_model preprocessing)."""
104+
data = service.schema_dump(data)
105+
# Only update slug if name changed
106+
if "name" in data and "slug" not in data:
107+
data["slug"] = await self.repository.get_available_slug(data["name"])
108+
return data
109+
```
110+
57111
## Service Patterns
58112

59113
### Password Hashing

0 commit comments

Comments
 (0)