Skip to content

Commit 9afde80

Browse files
authored
fix: correctly handle lazy attributes on update (#533)
Correctly handle `viewownly` and `lazy` loaded relationships during update.
1 parent f3c9309 commit 9afde80

File tree

3 files changed

+199
-15
lines changed

3 files changed

+199
-15
lines changed

advanced_alchemy/repository/_async.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,6 +1507,16 @@ async def update(
15071507
# Handle relationships by merging objects into session first
15081508
for relationship in mapper.mapper.relationships:
15091509
if (new_value := getattr(data, relationship.key, MISSING)) is not MISSING:
1510+
# Skip relationships that cannot be handled by generic merge operations
1511+
if relationship.viewonly or relationship.lazy in { # pragma: no cover
1512+
"write_only",
1513+
"dynamic",
1514+
"raise",
1515+
"raise_on_sql",
1516+
}:
1517+
# Skip relationships with incompatible lazy loading strategies
1518+
continue
1519+
15101520
if isinstance(new_value, list):
15111521
merged_values = [ # pyright: ignore
15121522
await self.session.merge(item, load=False) # pyright: ignore

advanced_alchemy/repository/_sync.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,6 +1508,16 @@ def update(
15081508
# Handle relationships by merging objects into session first
15091509
for relationship in mapper.mapper.relationships:
15101510
if (new_value := getattr(data, relationship.key, MISSING)) is not MISSING:
1511+
# Skip relationships that cannot be handled by generic merge operations
1512+
if relationship.viewonly or relationship.lazy in { # pragma: no cover
1513+
"write_only",
1514+
"dynamic",
1515+
"raise",
1516+
"raise_on_sql",
1517+
}:
1518+
# Skip relationships with incompatible lazy loading strategies
1519+
continue
1520+
15111521
if isinstance(new_value, list):
15121522
merged_values = [ # pyright: ignore
15131523
self.session.merge(item, load=False) # pyright: ignore

tests/unit/test_repository.py

Lines changed: 179 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,25 +1332,189 @@ def test_model_from_dict_includes_relationship_attributes() -> None:
13321332
assert "books" in attrs_keys, "books relationship should be in attrs.keys()"
13331333

13341334

1335-
def test_model_from_dict_with_relationship_data() -> None:
1336-
"""Test that model_from_dict can handle relationship data."""
1337-
from tests.fixtures.uuid.models import UUIDAuthor, UUIDBook
1335+
# Tests for write_only relationship handling in update method (issue #524)
13381336

1339-
# Create test data with relationship
1340-
book1 = UUIDBook(title="Test Book 1", author_id="dummy-uuid")
1341-
book2 = UUIDBook(title="Test Book 2", author_id="dummy-uuid")
13421337

1343-
author_data = {"name": "Test Author", "string_field": "test value", "books": [book1, book2]}
1338+
async def test_update_skips_write_only_relationships(
1339+
mock_repo: SQLAlchemyAsyncRepository[Any],
1340+
mocker: MockerFixture,
1341+
) -> None:
1342+
"""Test that update method skips write_only relationships without error."""
1343+
id_ = 3
1344+
mock_instance = MagicMock()
1345+
existing_instance = MagicMock()
13441346

1345-
# model_from_dict should handle the relationship attribute
1346-
author = model_from_dict(UUIDAuthor, **author_data)
1347+
# Mock the mapper and relationship
1348+
mock_mapper = MagicMock()
1349+
mock_relationship = MagicMock()
1350+
mock_relationship.key = "items"
1351+
mock_relationship.lazy = "write_only"
1352+
mock_relationship.viewonly = False
1353+
mock_mapper.mapper.columns = []
1354+
mock_mapper.mapper.relationships = [mock_relationship]
13471355

1348-
assert author.name == "Test Author"
1349-
assert author.string_field == "test value"
1350-
assert hasattr(author, "books"), "Author should have books attribute"
1351-
assert len(author.books) == 2
1352-
assert author.books[0].title == "Test Book 1"
1353-
assert author.books[1].title == "Test Book 2"
1356+
# Mock the data object to have the write_only relationship attribute
1357+
mock_instance.items = MagicMock() # This would be a WriteOnlyCollection in reality
1358+
1359+
mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
1360+
mocker.patch.object(mock_repo, "get", return_value=existing_instance)
1361+
mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_mapper)
1362+
mock_repo.session.merge.return_value = existing_instance # pyright: ignore[reportFunctionMemberAccess]
1363+
1364+
# This should not raise an error even though items is a write_only relationship
1365+
instance = await maybe_async(mock_repo.update(mock_instance))
1366+
1367+
# Verify the relationship was not processed (no merge attempted for relationships)
1368+
mock_repo.session.merge.assert_called_once_with(existing_instance, load=True) # pyright: ignore[reportFunctionMemberAccess]
1369+
assert instance is existing_instance
1370+
1371+
1372+
async def test_update_skips_dynamic_relationships(
1373+
mock_repo: SQLAlchemyAsyncRepository[Any],
1374+
mocker: MockerFixture,
1375+
) -> None:
1376+
"""Test that update method skips dynamic relationships without error."""
1377+
id_ = 3
1378+
mock_instance = MagicMock()
1379+
existing_instance = MagicMock()
1380+
1381+
# Mock the mapper and relationship
1382+
mock_mapper = MagicMock()
1383+
mock_relationship = MagicMock()
1384+
mock_relationship.key = "items"
1385+
mock_relationship.lazy = "dynamic"
1386+
mock_relationship.viewonly = False
1387+
mock_mapper.mapper.columns = []
1388+
mock_mapper.mapper.relationships = [mock_relationship]
1389+
1390+
# Mock the data object to have the dynamic relationship attribute
1391+
mock_instance.items = MagicMock() # This would be an AppenderQuery in reality
1392+
1393+
mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
1394+
mocker.patch.object(mock_repo, "get", return_value=existing_instance)
1395+
mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_mapper)
1396+
mock_repo.session.merge.return_value = existing_instance # pyright: ignore[reportFunctionMemberAccess]
1397+
1398+
# This should not raise an error even though items is a dynamic relationship
1399+
instance = await maybe_async(mock_repo.update(mock_instance))
1400+
1401+
# Verify the relationship was not processed (no merge attempted for relationships)
1402+
mock_repo.session.merge.assert_called_once_with(existing_instance, load=True) # pyright: ignore[reportFunctionMemberAccess]
1403+
assert instance is existing_instance
1404+
1405+
1406+
async def test_update_skips_viewonly_relationships(
1407+
mock_repo: SQLAlchemyAsyncRepository[Any],
1408+
mocker: MockerFixture,
1409+
) -> None:
1410+
"""Test that update method skips viewonly relationships without error."""
1411+
id_ = 3
1412+
mock_instance = MagicMock()
1413+
existing_instance = MagicMock()
1414+
1415+
# Mock the mapper and relationship
1416+
mock_mapper = MagicMock()
1417+
mock_relationship = MagicMock()
1418+
mock_relationship.key = "readonly_items"
1419+
mock_relationship.lazy = "select" # Normal lazy loading
1420+
mock_relationship.viewonly = True # But marked as view-only
1421+
mock_mapper.mapper.columns = []
1422+
mock_mapper.mapper.relationships = [mock_relationship]
1423+
1424+
# Mock the data object to have the viewonly relationship attribute
1425+
mock_instance.readonly_items = [MagicMock()]
1426+
1427+
mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
1428+
mocker.patch.object(mock_repo, "get", return_value=existing_instance)
1429+
mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_mapper)
1430+
mock_repo.session.merge.return_value = existing_instance # pyright: ignore[reportFunctionMemberAccess]
1431+
1432+
# This should not raise an error even though readonly_items is viewonly
1433+
instance = await maybe_async(mock_repo.update(mock_instance))
1434+
1435+
# Verify the relationship was not processed (no merge attempted for relationships)
1436+
mock_repo.session.merge.assert_called_once_with(existing_instance, load=True) # pyright: ignore[reportFunctionMemberAccess]
1437+
assert instance is existing_instance
1438+
1439+
1440+
async def test_update_skips_raise_lazy_relationships(
1441+
mock_repo: SQLAlchemyAsyncRepository[Any],
1442+
mocker: MockerFixture,
1443+
) -> None:
1444+
"""Test that update method skips raise lazy strategy relationships without error."""
1445+
id_ = 3
1446+
mock_instance = MagicMock()
1447+
existing_instance = MagicMock()
1448+
1449+
# Mock the mapper and relationship
1450+
mock_mapper = MagicMock()
1451+
mock_relationship = MagicMock()
1452+
mock_relationship.key = "items"
1453+
mock_relationship.lazy = "raise"
1454+
mock_relationship.viewonly = False
1455+
mock_mapper.mapper.columns = []
1456+
mock_mapper.mapper.relationships = [mock_relationship]
1457+
1458+
# Mock the data object to have the raise relationship attribute
1459+
mock_instance.items = MagicMock()
1460+
1461+
mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
1462+
mocker.patch.object(mock_repo, "get", return_value=existing_instance)
1463+
mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_mapper)
1464+
mock_repo.session.merge.return_value = existing_instance # pyright: ignore[reportFunctionMemberAccess]
1465+
1466+
# This should not raise an error even though items has lazy="raise"
1467+
instance = await maybe_async(mock_repo.update(mock_instance))
1468+
1469+
# Verify the relationship was not processed (no merge attempted for relationships)
1470+
mock_repo.session.merge.assert_called_once_with(existing_instance, load=True) # pyright: ignore[reportFunctionMemberAccess]
1471+
assert instance is existing_instance
1472+
1473+
1474+
async def test_update_processes_normal_relationships(
1475+
mock_repo: SQLAlchemyAsyncRepository[Any],
1476+
mocker: MockerFixture,
1477+
) -> None:
1478+
"""Test that update method still processes normal relationships correctly."""
1479+
id_ = 3
1480+
mock_instance = MagicMock()
1481+
existing_instance = MagicMock()
1482+
related_item = MagicMock()
1483+
merged_related_item = MagicMock()
1484+
1485+
# Mock the mapper and relationship
1486+
mock_mapper = MagicMock()
1487+
mock_relationship = MagicMock()
1488+
mock_relationship.key = "items"
1489+
mock_relationship.lazy = "select" # Normal lazy loading
1490+
mock_relationship.viewonly = False
1491+
mock_mapper.mapper.columns = []
1492+
mock_mapper.mapper.relationships = [mock_relationship]
1493+
1494+
# Mock the data object to have a normal relationship with items
1495+
mock_instance.items = [related_item]
1496+
1497+
mocker.patch.object(mock_repo, "get_id_attribute_value", return_value=id_)
1498+
mocker.patch.object(mock_repo, "get", return_value=existing_instance)
1499+
mocker.patch("advanced_alchemy.repository._async.inspect", return_value=mock_mapper)
1500+
1501+
# Mock session.merge to return different objects for main instance vs related items
1502+
async def mock_merge(obj: Any, load: bool = True) -> Any:
1503+
if obj is existing_instance:
1504+
return existing_instance
1505+
if obj is related_item:
1506+
return merged_related_item
1507+
return obj
1508+
1509+
mock_repo.session.merge.side_effect = mock_merge
1510+
1511+
# This should process the normal relationship correctly
1512+
instance = await maybe_async(mock_repo.update(mock_instance))
1513+
1514+
# Verify the relationship was processed - at minimum the main instance should be merged
1515+
assert mock_repo.session.merge.call_count >= 1 # At least the main instance
1516+
# The main point is that normal relationships don't cause errors
1517+
assert instance is existing_instance
13541518

13551519

13561520
def test_model_from_dict_backward_compatibility() -> None:

0 commit comments

Comments
 (0)