Skip to content

Commit fa56eb0

Browse files
authored
fix: updated_at not being updated (#551)
Corrects a regression where `updated_at` timestamp was no longer updated on model changes
1 parent 4becd50 commit fa56eb0

7 files changed

Lines changed: 94 additions & 11 deletions

File tree

advanced_alchemy/_listeners.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
if TYPE_CHECKING:
1414
from sqlalchemy.orm import Session, UOWTransaction
15+
from sqlalchemy.orm.state import InstanceState
1516

1617
from advanced_alchemy.types.file_object import FileObjectSessionTracker, StorageRegistry
1718

@@ -452,8 +453,31 @@ def touch_updated_timestamp(session: "Session", *_: Any) -> None: # pragma: no
452453
"""
453454
for instance in session.dirty:
454455
state = inspect(instance)
455-
if not state or not hasattr(state.mapper.class_, "updated_at"):
456+
if not state or not hasattr(state.mapper.class_, "updated_at") or state.deleted or instance in session.new:
456457
continue
457458
updated_at_attr = state.attrs.get("updated_at")
458-
if updated_at_attr and not updated_at_attr.history.has_changes():
459+
if not updated_at_attr or updated_at_attr.history.added:
460+
# Respect explicit user assignments such as manual overrides or import routines
461+
continue
462+
463+
if _has_persistent_column_changes(state, skip_keys={"updated_at"}):
459464
instance.updated_at = datetime.datetime.now(datetime.timezone.utc)
465+
466+
467+
def _has_persistent_column_changes(
468+
state: "InstanceState[Any]",
469+
*,
470+
skip_keys: "Optional[set[str]]" = None,
471+
) -> bool:
472+
"""Check if any mapped column (excluding ``skip_keys``) has modifications pending flush."""
473+
if skip_keys is None:
474+
skip_keys = set()
475+
476+
mapper = state.mapper
477+
for attr in mapper.column_attrs:
478+
if attr.key in skip_keys:
479+
continue
480+
attr_state = state.attrs.get(attr.key)
481+
if attr_state is not None and attr_state.history.has_changes():
482+
return True
483+
return False

advanced_alchemy/repository/_async.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1586,12 +1586,17 @@ async def update_many(
15861586
error_messages=error_messages,
15871587
default_messages=self.error_messages,
15881588
)
1589+
supports_updated_at = hasattr(self.model_type, "updated_at")
15891590
data_to_update: list[dict[str, Any]] = []
15901591
for v in data:
15911592
if isinstance(v, self.model_type) or (hasattr(v, "to_dict") and callable(v.to_dict)):
1592-
data_to_update.append(v.to_dict())
1593+
update_payload = v.to_dict()
15931594
else:
1594-
data_to_update.append(cast("dict[str, Any]", schema_dump(v)))
1595+
update_payload = cast("dict[str, Any]", schema_dump(v))
1596+
1597+
if supports_updated_at and (update_payload.get("updated_at") is None):
1598+
update_payload["updated_at"] = datetime.datetime.now(datetime.timezone.utc)
1599+
data_to_update.append(update_payload)
15951600
with wrap_sqlalchemy_exception(
15961601
error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions
15971602
):

advanced_alchemy/repository/_sync.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,12 +1587,17 @@ def update_many(
15871587
error_messages=error_messages,
15881588
default_messages=self.error_messages,
15891589
)
1590+
supports_updated_at = hasattr(self.model_type, "updated_at")
15901591
data_to_update: list[dict[str, Any]] = []
15911592
for v in data:
15921593
if isinstance(v, self.model_type) or (hasattr(v, "to_dict") and callable(v.to_dict)):
1593-
data_to_update.append(v.to_dict())
1594+
update_payload = v.to_dict()
15941595
else:
1595-
data_to_update.append(cast("dict[str, Any]", schema_dump(v)))
1596+
update_payload = cast("dict[str, Any]", schema_dump(v))
1597+
1598+
if supports_updated_at and (update_payload.get("updated_at") is None):
1599+
update_payload["updated_at"] = datetime.datetime.now(datetime.timezone.utc)
1600+
data_to_update.append(update_payload)
15961601
with wrap_sqlalchemy_exception(
15971602
error_messages=error_messages, dialect_name=self._dialect.name, wrap_exceptions=self.wrap_exceptions
15981603
):

advanced_alchemy/service/_async.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ def filter_unset(attr: Any, value: Any) -> bool: # noqa: ARG001
474474
)
475475

476476
# Fallback for objects with __dict__ (e.g., regular classes)
477-
if hasattr(data, "__dict__"):
477+
if hasattr(data, "__dict__") and not isinstance(data, self.model_type):
478478
return model_from_dict(
479479
model=self.model_type,
480480
**data.__dict__,

advanced_alchemy/service/_sync.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@ def filter_unset(attr: Any, value: Any) -> bool: # noqa: ARG001
473473
)
474474

475475
# Fallback for objects with __dict__ (e.g., regular classes)
476-
if hasattr(data, "__dict__"):
476+
if hasattr(data, "__dict__") and not isinstance(data, self.model_type):
477477
return model_from_dict(
478478
model=self.model_type,
479479
**data.__dict__,

docs/usage/modeling.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Additionally, Advanced Alchemy provides mixins to enhance model functionality:
5757
* - ``AuditColumns``
5858
- | Automatic created_at/updated_at timestamps
5959
| Tracks record modifications
60+
| ``updated_at`` refreshes during flush when any mapped column value changes, while preserving explicit timestamp overrides
6061
* - ``BigIntPrimaryKey``
6162
- | Adds BigInt primary key with sequence
6263
* - ``IdentityPrimaryKey``

tests/integration/test_repository.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Integration tests for the SQLAlchemy Repository implementation using session-based fixtures."""
22

3+
import asyncio
34
import datetime
45
from collections.abc import Generator
56
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
@@ -339,14 +340,43 @@ async def test_repo_update_method(seeded_test_session_async: "tuple[AsyncSession
339340
# Get first author
340341
authors = await maybe_async(author_repo.list())
341342
author = authors[0]
343+
342344
original_name = author.name
345+
original_created_at = author.created_at
346+
original_updated_at = author.updated_at
343347

344348
# Update the author
345349
author.name = "Updated Name"
346350
updated_author = await maybe_async(author_repo.update(author))
347351

348352
assert updated_author.name == "Updated Name"
349353
assert updated_author.name != original_name
354+
assert updated_author.created_at == original_created_at
355+
assert updated_author.updated_at > original_updated_at
356+
357+
358+
async def test_service_update_with_dict_data_refreshes_timestamp(
359+
seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]",
360+
frozen_datetime: "Coordinates",
361+
) -> None:
362+
"""Service update should refresh audit timestamps when supplied with dict payloads."""
363+
session, models = seeded_test_session_async
364+
author_service = get_service_from_session((session, models), "author")
365+
366+
authors = await maybe_async(author_service.list())
367+
author = authors[0]
368+
369+
original_created_at = author.created_at
370+
original_updated_at = author.updated_at
371+
372+
frozen_datetime.shift(datetime.timedelta(seconds=5))
373+
update_payload = {"name": "Dict Driven Update"}
374+
375+
updated_author = await maybe_async(author_service.update(update_payload, item_id=author.id))
376+
377+
assert updated_author.name == "Dict Driven Update"
378+
assert updated_author.created_at == original_created_at
379+
assert updated_author.updated_at > original_updated_at
350380

351381

352382
async def test_repo_update_many_method_stale_data_fix(
@@ -385,7 +415,7 @@ async def test_repo_update_many_method_stale_data_fix(
385415
# updated_at should be newer than before
386416
if updated_author.id == authors[0].id:
387417
assert updated_author.created_at == original_created_at
388-
assert updated_author.updated_at >= original_updated_at
418+
assert updated_author.updated_at > original_updated_at
389419

390420

391421
async def test_repo_update_many_mixed_types(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
@@ -498,11 +528,18 @@ async def test_service_update_method(seeded_test_session_async: "tuple[AsyncSess
498528
author = authors[0]
499529
author_id = author.id
500530

531+
original_name = author.name
532+
original_created_at = author.created_at
533+
original_updated_at = author.updated_at
534+
501535
# Update via service - correct parameter order is (data, item_id)
502-
update_data = {"name": "Service Updated Name"}
503-
updated_author = await maybe_async(author_service.update(update_data, item_id=author_id))
536+
author.name = "Service Updated Name"
537+
updated_author = await maybe_async(author_service.update(author, item_id=author_id))
504538

505539
assert updated_author.name == "Service Updated Name"
540+
assert updated_author.name != original_name
541+
assert updated_author.created_at == original_created_at
542+
assert updated_author.updated_at > original_updated_at
506543

507544

508545
async def test_service_delete_method(seeded_test_session_async: "tuple[AsyncSession, dict[str, type]]") -> None:
@@ -679,6 +716,10 @@ async def test_service_update_many_schema_types_github_535(
679716

680717
original_dob1 = author1.dob
681718
original_dob2 = author2.dob
719+
original_created_at1 = author1.created_at
720+
original_created_at2 = author2.created_at
721+
original_updated_at1 = author1.updated_at
722+
original_updated_at2 = author2.updated_at
682723

683724
# Get ID type from model for dynamic schema creation
684725
# For Pydantic compatibility, we need to map database-specific types to Python types
@@ -707,6 +748,9 @@ class AuthorUpdateMsgspecSchema(msgspec.Struct): # type: ignore[name-defined,mi
707748
AuthorUpdateMsgspecSchema(id=author2.id, name="Updated Author Two"), # msgspec with UNSET dob
708749
]
709750

751+
# Sleep to ensure timestamp difference for databases with lower precision
752+
await asyncio.sleep(1.1)
753+
710754
# Update via service - should only update names, leave dobs unchanged
711755
updated_authors = await maybe_async(author_service.update_many(update_data))
712756

@@ -720,9 +764,13 @@ class AuthorUpdateMsgspecSchema(msgspec.Struct): # type: ignore[name-defined,mi
720764
# Verify: names were updated, but dobs remain unchanged
721765
assert updated_author1.name == "Updated Author One"
722766
assert updated_author1.dob == original_dob1 # Should be unchanged
767+
assert updated_author1.created_at == original_created_at1
768+
assert updated_author1.updated_at > original_updated_at1
723769

724770
assert updated_author2.name == "Updated Author Two"
725771
assert updated_author2.dob == original_dob2 # Should be unchanged
772+
assert updated_author2.created_at == original_created_at2
773+
assert updated_author2.updated_at > original_updated_at2
726774

727775

728776
async def test_repo_update_many_non_returning_backend_refresh(

0 commit comments

Comments
 (0)