Skip to content

Commit 298b4ce

Browse files
cofinJacobCoffee
andauthored
fix: recursively convert nested dicts in model_from_dict (#637)
Fixed regression where service.create() failed when passing nested dictionaries for relationship data. The model_from_dict() function now uses SQLAlchemy's class_mapper to detect relationships and recursively converts nested dicts to model instances. --------- Signed-off-by: Cody Fincher <204685+cofin@users.noreply.github.com> Co-authored-by: Jacob Coffee <jacob@z7x.org>
1 parent e74d5fe commit 298b4ce

File tree

9 files changed

+1713
-927
lines changed

9 files changed

+1713
-927
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ jobs:
9696
strategy:
9797
fail-fast: true
9898
matrix:
99-
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
99+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
100100
uses: ./.github/workflows/test.yml
101101
with:
102102
coverage: ${{ matrix.python-version == '3.13' }}

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ repos:
2222
- id: unasyncd
2323
additional_dependencies: ["ruff"]
2424
- repo: https://github.com/charliermarsh/ruff-pre-commit
25-
rev: "v0.14.9"
25+
rev: "v0.14.13"
2626
hooks:
2727
# Run the linter.
2828
- id: ruff

advanced_alchemy/extensions/sanic/config.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,14 @@ async def on_shutdown(_: Any) -> None: # pyright: ignore[reportUnusedFunction]
189189
if hasattr(self.app.ctx, self.session_maker_key): # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess]
190190
delattr(self.app.ctx, self.session_maker_key) # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportOptionalMemberAccess]
191191

192-
@self.app.middleware("request") # pyright: ignore[reportUnknownMemberType]
192+
@self.app.middleware("request") # type: ignore[misc,untyped-decorator] # pyright: ignore[reportUnknownMemberType]
193193
async def on_request(request: Request) -> None: # pyright: ignore[reportUnusedFunction]
194194
session = cast("Optional[AsyncSession]", getattr(request.ctx, self.session_key, None))
195195
if session is None:
196196
setattr(request.ctx, self.session_key, self.get_session())
197197
set_async_context(True)
198198

199-
@self.app.middleware("response") # type: ignore[arg-type]
199+
@self.app.middleware("response") # type: ignore[misc,untyped-decorator]
200200
async def on_response(request: Request, response: HTTPResponse) -> None: # pyright: ignore[reportUnusedFunction]
201201
session = cast("Optional[AsyncSession]", getattr(request.ctx, self.session_key, None))
202202
if session is not None:
@@ -397,14 +397,14 @@ async def on_startup(_: Any) -> None: # pyright: ignore[reportUnusedFunction]
397397
async def on_shutdown(_: Any) -> None: # pyright: ignore[reportUnusedFunction]
398398
await self.on_shutdown()
399399

400-
@self.app.middleware("request") # pyright: ignore[reportUnknownMemberType]
400+
@self.app.middleware("request") # type: ignore[misc,untyped-decorator] # pyright: ignore[reportUnknownMemberType]
401401
async def on_request(request: Request) -> None: # pyright: ignore[reportUnusedFunction]
402402
session = cast("Optional[Session]", getattr(request.ctx, self.session_key, None))
403403
if session is None:
404404
setattr(request.ctx, self.session_key, self.get_session())
405405
set_async_context(False)
406406

407-
@self.app.middleware("response") # type: ignore[arg-type]
407+
@self.app.middleware("response") # type: ignore[misc,untyped-decorator]
408408
async def on_response(request: Request, response: HTTPResponse) -> None: # pyright: ignore[reportUnusedFunction]
409409
session = cast("Optional[Session]", getattr(request.ctx, self.session_key, None))
410410
if session is not None:

advanced_alchemy/repository/_util.py

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
InstrumentedAttribute,
1313
MapperProperty,
1414
RelationshipProperty,
15+
class_mapper,
1516
joinedload,
1617
lazyload,
1718
selectinload,
@@ -88,22 +89,98 @@ def get_instrumented_attr(
8889
return key
8990

9091

92+
def _convert_relationship_value(
93+
value: Any,
94+
related_model: type[ModelT],
95+
is_collection: bool,
96+
) -> Any:
97+
"""Convert a relationship value, handling dicts, lists, and instances.
98+
99+
Args:
100+
value: The value to convert (dict, list, model instance, or None).
101+
related_model: The SQLAlchemy model class for the relationship.
102+
is_collection: Whether this is a collection relationship (uselist=True).
103+
104+
Returns:
105+
Converted value appropriate for the relationship type.
106+
"""
107+
if value is None:
108+
return None
109+
110+
if is_collection:
111+
# One-to-many or many-to-many: expect a list
112+
if not isinstance(value, (list, tuple)):
113+
# Single item provided for collection - wrap in list
114+
value = [value]
115+
return [
116+
model_from_dict(related_model, **item) if isinstance(item, dict) else item
117+
for item in value # pyright: ignore[reportUnknownVariableType]
118+
]
119+
# One-to-one or many-to-one: expect single value
120+
if isinstance(value, dict):
121+
return model_from_dict(related_model, **value)
122+
return value
123+
124+
91125
def model_from_dict(model: type[ModelT], **kwargs: Any) -> ModelT:
92126
"""Create an ORM model instance from a dictionary of attributes.
93127
128+
This function recursively converts nested dictionaries into their
129+
corresponding SQLAlchemy model instances for relationship attributes.
130+
94131
Args:
95132
model: The SQLAlchemy model class to instantiate.
96133
**kwargs: Keyword arguments containing model attribute values.
134+
For relationship attributes, values can be:
135+
- None: Sets the relationship to None
136+
- dict: Recursively converted to the related model instance
137+
- list[dict]: Each dict converted to related model instances
138+
- Model instance: Passed through unchanged
97139
98140
Returns:
99141
ModelT: A new instance of the model populated with the provided values.
142+
143+
Example:
144+
Basic usage with nested relationships::
145+
146+
data = {
147+
"name": "John Doe",
148+
"profile": {"bio": "Developer"},
149+
"addresses": [
150+
{"street": "123 Main St"},
151+
{"street": "456 Oak Ave"},
152+
],
153+
}
154+
user = model_from_dict(User, **data)
155+
# user.profile is a Profile instance
156+
# user.addresses is a list of Address instances
100157
"""
101-
data = {
102-
attr_name: kwargs[attr_name]
103-
for attr_name in model.__mapper__.attrs.keys() # noqa: SIM118 # pyright: ignore[reportUnknownMemberType]
104-
if attr_name in kwargs
105-
}
106-
return model(**data)
158+
mapper = class_mapper(model)
159+
mapper_attrs = mapper.attrs
160+
converted_data: dict[str, Any] = {}
161+
162+
# Iterate over kwargs instead of mapper.attrs for better performance
163+
# when only a subset of attributes is provided (O(InputKeys) vs O(TotalColumns))
164+
for key, value in kwargs.items():
165+
# Skip keys that aren't mapped attributes (e.g., extra fields)
166+
if key not in mapper_attrs:
167+
continue
168+
169+
attr = mapper_attrs[key]
170+
171+
# Check if this attribute is a relationship
172+
if isinstance(attr, RelationshipProperty):
173+
related_model: type[ModelT] = attr.mapper.class_
174+
converted_data[key] = _convert_relationship_value(
175+
value=value,
176+
related_model=related_model,
177+
is_collection=attr.uselist or False,
178+
)
179+
else:
180+
# Regular column attribute - pass through
181+
converted_data[key] = value
182+
183+
return model(**converted_data)
107184

108185

109186
def get_abstract_loader_options(

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ classifiers = [
1616
"Programming Language :: Python :: 3.11",
1717
"Programming Language :: Python :: 3.12",
1818
"Programming Language :: Python :: 3.13",
19+
"Programming Language :: Python :: 3.14",
1920
"Programming Language :: Python",
2021
"Topic :: Software Development",
2122
"Typing :: Typed",

tests/integration/test_file_object.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import sys
23
from collections.abc import AsyncGenerator, Generator
34
from contextlib import suppress
45
from pathlib import Path
@@ -222,6 +223,10 @@ async def async_session(
222223

223224

224225
@pytest.mark.xdist_group("file_object")
226+
@pytest.mark.xfail(
227+
sys.version_info < (3, 10),
228+
reason="s3fs endpoint_url parameter incompatible with Python 3.9",
229+
)
225230
async def test_fsspec_s3_basic_operations_async(
226231
storage_registry: StorageRegistry,
227232
minio_client: "Minio",
@@ -300,6 +305,10 @@ async def test_fsspec_s3_basic_operations_async(
300305

301306

302307
@pytest.mark.xdist_group("file_object")
308+
@pytest.mark.xfail(
309+
sys.version_info < (3, 10),
310+
reason="s3fs endpoint_url parameter incompatible with Python 3.9",
311+
)
303312
def test_fsspec_s3_basic_operations_sync(
304313
storage_registry: StorageRegistry,
305314
minio_client: "Minio",

tests/unit/test_extensions/test_sanic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def test_infer_types_from_config(async_config: SQLAlchemyAsyncConfig, sync_confi
8080

8181

8282
def test_inject_engine(app: Sanic[Any, Any], alchemy: AdvancedAlchemy) -> None:
83-
@app.get("/")
83+
@app.get("/") # type: ignore[misc]
8484
async def handler(request: Request) -> HTTPResponse:
8585
assert isinstance(getattr(request.app.ctx, alchemy.get_config().engine_key), (Engine, AsyncEngine))
8686
return HTTPResponse(status=200)

0 commit comments

Comments
 (0)