Skip to content

Commit 23b84ce

Browse files
authored
fix: prevent update() from overwriting unset fields with None (litestar-org#560) (litestar-org#563)
Added `was_attribute_set()` helper function that uses SQLAlchemy's instance state inspection to check which attributes were actually modified/set on the input instance. The `update()` method now only copies attributes that have been explicitly set by the user.
1 parent a737b63 commit 23b84ce

7 files changed

Lines changed: 1770 additions & 1289 deletions

File tree

.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.13.1"
25+
rev: "v0.14.0"
2626
hooks:
2727
# Run the linter.
2828
- id: ruff

advanced_alchemy/repository/_async.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
compare_values,
5353
get_abstract_loader_options,
5454
get_instrumented_attr,
55+
was_attribute_set,
5556
)
5657
from advanced_alchemy.repository.typing import MISSING, ModelT, OrderingPair, T
5758
from advanced_alchemy.service.typing import schema_dump
@@ -1502,6 +1503,10 @@ async def update(
15021503
# This prevents overwriting columns that should use their defaults
15031504
if new_field_value is None and column_has_defaults(column):
15041505
continue
1506+
# Only copy attributes that were explicitly set on the input instance
1507+
# This prevents overwriting existing values with uninitialized None values
1508+
if not was_attribute_set(data, mapper, field_name):
1509+
continue
15051510
existing_field_value = getattr(existing_instance, field_name, MISSING)
15061511
if existing_field_value is not MISSING and not compare_values(
15071512
existing_field_value, new_field_value

advanced_alchemy/repository/_sync.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
compare_values,
5454
get_abstract_loader_options,
5555
get_instrumented_attr,
56+
was_attribute_set,
5657
)
5758
from advanced_alchemy.repository.typing import MISSING, ModelT, OrderingPair, T
5859
from advanced_alchemy.service.typing import schema_dump
@@ -1503,6 +1504,10 @@ def update(
15031504
# This prevents overwriting columns that should use their defaults
15041505
if new_field_value is None and column_has_defaults(column):
15051506
continue
1507+
# Only copy attributes that were explicitly set on the input instance
1508+
# This prevents overwriting existing values with uninitialized None values
1509+
if not was_attribute_set(data, mapper, field_name):
1510+
continue
15061511
existing_field_value = getattr(existing_instance, field_name, MISSING)
15071512
if existing_field_value is not MISSING and not compare_values(
15081513
existing_field_value, new_field_value

advanced_alchemy/repository/_util.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,42 @@ def column_has_defaults(column: Any) -> bool:
371371
)
372372

373373

374+
def was_attribute_set(instance: Any, mapper: Any, attr_name: str) -> bool:
375+
"""Check if an attribute was explicitly set on a model instance.
376+
377+
This function distinguishes between attributes that were explicitly set
378+
(even to None) versus attributes that are simply uninitialized and defaulting
379+
to None. This is crucial for partial updates where only modified fields
380+
should be copied.
381+
382+
Args:
383+
instance: The model instance to check.
384+
mapper: The SQLAlchemy mapper/inspector for the instance.
385+
attr_name: The name of the attribute to check.
386+
387+
Returns:
388+
bool: True if the attribute was explicitly set, False if uninitialized.
389+
"""
390+
try:
391+
# Get the attribute state
392+
attr_state = mapper.attrs.get(attr_name)
393+
if attr_state is None:
394+
return False
395+
396+
# Check if the attribute has history (was modified)
397+
# For a new transient instance, modified attributes will have history
398+
history = attr_state.history
399+
if history.has_changes():
400+
return True
401+
402+
# For attributes with no history, check if they're in the instance dict
403+
# This handles the case where an attribute was set during __init__
404+
return hasattr(instance, "__dict__") and attr_name in instance.__dict__
405+
except (AttributeError, KeyError): # pragma: no cover
406+
# If we can't determine, assume it was set to be safe
407+
return True
408+
409+
374410
def compare_values(existing_value: Any, new_value: Any) -> bool:
375411
"""Safely compare two values, handling numpy arrays and other special types.
376412

tests/integration/test_repository.py

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -734,12 +734,10 @@ async def test_repo_update_many_non_returning_backend_refresh(
734734

735735
# Create multiple authors
736736
authors = await maybe_async(
737-
author_repo.create_many(
738-
[
739-
{"name": "Author A", "dob": datetime.date(1990, 1, 1)},
740-
{"name": "Author B", "dob": datetime.date(1991, 2, 2)},
741-
]
742-
)
737+
author_repo.create_many([
738+
{"name": "Author A", "dob": datetime.date(1990, 1, 1)},
739+
{"name": "Author B", "dob": datetime.date(1991, 2, 2)},
740+
])
743741
)
744742

745743
# Prepare update data with partial changes
@@ -775,13 +773,11 @@ async def test_service_mixed_input_types_update_many(
775773

776774
# Create multiple authors
777775
authors = await maybe_async(
778-
author_service.create_many(
779-
[
780-
{"name": "Author 1", "dob": datetime.date(1990, 1, 1)},
781-
{"name": "Author 2", "dob": datetime.date(1991, 2, 2)},
782-
{"name": "Author 3", "dob": datetime.date(1992, 3, 3)},
783-
]
784-
)
776+
author_service.create_many([
777+
{"name": "Author 1", "dob": datetime.date(1990, 1, 1)},
778+
{"name": "Author 2", "dob": datetime.date(1991, 2, 2)},
779+
{"name": "Author 3", "dob": datetime.date(1992, 3, 3)},
780+
])
785781
)
786782

787783
# Get ID type from model for dynamic schema creation
@@ -831,3 +827,73 @@ class AuthorUpdateMsgspec(msgspec.Struct): # type: ignore[name-defined,misc]
831827
assert author.dob is not None
832828
assert author.created_at is not None
833829
assert author.updated_at is not None
830+
831+
832+
async def test_repo_update_with_model_instance_partial_fields_github_560(
833+
seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
834+
) -> None:
835+
"""Test repository update with model instances for partial updates (GitHub Issue #560).
836+
837+
This test verifies that when updating with a model instance where only some fields
838+
are explicitly set, the unset fields do not overwrite existing data with None.
839+
"""
840+
_session, models = seeded_test_session_async
841+
author_model = models["author"]
842+
author_repo = get_repository_from_session(seeded_test_session_async, "author")
843+
844+
# Create an author with all fields populated
845+
author = await maybe_async(author_repo.create({"name": "Original Name", "dob": datetime.date(1990, 1, 1)}))
846+
original_dob = author.dob
847+
original_id = author.id
848+
849+
# Create a partial update using a model instance with only id and name set
850+
# This mimics the pattern: Author(id=1, name="Updated Name")
851+
# SQLAlchemy initializes 'dob' to None, but it wasn't explicitly set by the user
852+
partial_update = author_model(id=original_id, name="Updated Name")
853+
854+
# Update via repository - should only update name, leave dob unchanged
855+
updated_author = await maybe_async(author_repo.update(partial_update))
856+
857+
# Verify: name was updated, but dob remains unchanged (not overwritten with None)
858+
assert updated_author.name == "Updated Name"
859+
assert updated_author.dob == original_dob # Should be unchanged
860+
assert updated_author.id == original_id
861+
862+
863+
async def test_repo_update_many_with_model_instances_partial_fields_github_560(
864+
seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
865+
) -> None:
866+
"""Test repository update_many with model instances for partial updates (GitHub Issue #560).
867+
868+
This test verifies that update_many correctly handles model instances with partially
869+
set fields, preventing None values from overwriting existing data.
870+
"""
871+
_session, models = seeded_test_session_async
872+
author_model = models["author"]
873+
author_repo = get_repository_from_session(seeded_test_session_async, "author")
874+
875+
# Create multiple authors with all fields populated
876+
author1 = await maybe_async(author_repo.create({"name": "Author One", "dob": datetime.date(1990, 1, 1)}))
877+
author2 = await maybe_async(author_repo.create({"name": "Author Two", "dob": datetime.date(1991, 2, 2)}))
878+
879+
original_dob1 = author1.dob
880+
original_dob2 = author2.dob
881+
882+
# Create partial updates using model instances with only id and name set
883+
partial_updates = [
884+
author_model(id=author1.id, name="Updated One"),
885+
author_model(id=author2.id, name="Updated Two"),
886+
]
887+
888+
# Update via repository - should only update names, leave dobs unchanged
889+
updated_authors = await maybe_async(author_repo.update_many(partial_updates))
890+
891+
# Verify: names were updated, but dobs remain unchanged
892+
assert len(updated_authors) == 2
893+
updated_by_id = {author.id: author for author in updated_authors}
894+
895+
assert updated_by_id[author1.id].name == "Updated One"
896+
assert updated_by_id[author1.id].dob == original_dob1 # Should be unchanged
897+
898+
assert updated_by_id[author2.id].name == "Updated Two"
899+
assert updated_by_id[author2.id].dob == original_dob2 # Should be unchanged

tests/unit/test_repository.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1832,3 +1832,77 @@ def test_repository_update_methods_with_numpy_arrays() -> None:
18321832
complex3 = np.array([1 + 2j, 5 + 6j])
18331833
assert compare_values(complex1, complex2) is True
18341834
assert compare_values(complex1, complex3) is False
1835+
1836+
1837+
def test_was_attribute_set_with_explicitly_set_attributes() -> None:
1838+
"""Test was_attribute_set correctly identifies explicitly set attributes."""
1839+
from sqlalchemy import inspect
1840+
1841+
from advanced_alchemy.repository._util import was_attribute_set
1842+
1843+
# Create an instance with explicitly set attributes
1844+
instance = UUIDModel()
1845+
instance.id = uuid4() # Explicitly set id
1846+
1847+
# Get the mapper/inspector
1848+
mapper = inspect(instance)
1849+
1850+
# Explicitly set attributes should return True
1851+
assert was_attribute_set(instance, mapper, "id") is True
1852+
1853+
1854+
def test_was_attribute_set_with_uninitialized_attributes() -> None:
1855+
"""Test was_attribute_set correctly identifies uninitialized attributes."""
1856+
from sqlalchemy import inspect
1857+
1858+
from advanced_alchemy.repository._util import was_attribute_set
1859+
1860+
# Use the existing UUIDModel which has created_at and updated_at audit fields
1861+
# Create an instance - created_at and updated_at won't be in instance dict yet
1862+
instance = UUIDModel()
1863+
1864+
# Get the mapper/inspector
1865+
mapper = inspect(instance)
1866+
1867+
# Uninitialized audit attributes should return False
1868+
# They exist on the model but haven't been explicitly set
1869+
assert was_attribute_set(instance, mapper, "created_at") is False
1870+
assert was_attribute_set(instance, mapper, "updated_at") is False
1871+
1872+
1873+
def test_was_attribute_set_with_modified_attributes() -> None:
1874+
"""Test was_attribute_set detects attributes with modification history."""
1875+
from sqlalchemy import inspect
1876+
1877+
from advanced_alchemy.repository._util import was_attribute_set
1878+
1879+
# Create an instance and explicitly set attributes
1880+
instance = UUIDModel()
1881+
instance.id = uuid4() # Explicitly set id
1882+
1883+
# Also test setting a datetime attribute
1884+
now = datetime.datetime.now(datetime.timezone.utc)
1885+
instance.created_at = now # Explicitly modify created_at
1886+
1887+
# Get the mapper/inspector
1888+
mapper = inspect(instance)
1889+
1890+
# Modified attributes should return True
1891+
assert was_attribute_set(instance, mapper, "id") is True
1892+
assert was_attribute_set(instance, mapper, "created_at") is True
1893+
1894+
1895+
def test_was_attribute_set_with_nonexistent_attribute() -> None:
1896+
"""Test was_attribute_set handles nonexistent attributes gracefully."""
1897+
from sqlalchemy import inspect
1898+
1899+
from advanced_alchemy.repository._util import was_attribute_set
1900+
1901+
# Create an instance
1902+
instance = UUIDModel()
1903+
1904+
# Get the mapper/inspector
1905+
mapper = inspect(instance)
1906+
1907+
# Nonexistent attribute should return False (attr_state is None)
1908+
assert was_attribute_set(instance, mapper, "nonexistent_field") is False

0 commit comments

Comments
 (0)