Skip to content

Commit f85e115

Browse files
committed
✨ [feat][backend] update_events crud function
1 parent d78d658 commit f85e115

3 files changed

Lines changed: 250 additions & 2 deletions

File tree

backend/kayman/crud/event.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
Event,
88
EventBase,
99
EventCreate,
10+
EventUpdate,
1011
)
1112
from kayman.schemas.event_entry import EventEntry
1213

@@ -53,3 +54,43 @@ def read_events(
5354
scalar = scalar.with_for_update()
5455
events = session.exec(scalar).all()
5556
return events
57+
58+
59+
def update_events(
60+
session: Session,
61+
event_ids: Sequence[int],
62+
events: Sequence[EventUpdate],
63+
commit: bool = True,
64+
) -> Sequence[Event]:
65+
# Verify event_ids and events have the same length
66+
if len(event_ids) != len(events):
67+
raise ValueError("event_ids and events must have the same length")
68+
69+
# Verify all events are valid
70+
db_events = _verify_event_ids(session, event_ids)
71+
72+
# Update events, pairing by id rather than by row order, which SQL does not
73+
# guarantee matches the order of event_ids
74+
id_to_db_event = {db_event.id: db_event for db_event in db_events}
75+
for event_id, event in zip(event_ids, events, strict=True):
76+
db_event = id_to_db_event[event_id]
77+
db_event.sqlmodel_update(event.model_dump(exclude_unset=True))
78+
79+
session.add_all(db_events)
80+
if commit:
81+
session.commit()
82+
for db_event in db_events:
83+
session.refresh(db_event)
84+
else:
85+
session.flush()
86+
87+
return db_events
88+
89+
90+
def _verify_event_ids(session: Session, event_ids: Sequence[int]) -> Sequence[Event]:
91+
db_events = read_events(session, event_ids=event_ids, for_update=True)
92+
missing_ids = set(event_ids) - {event.id for event in db_events}
93+
if missing_ids:
94+
raise ValueError(f"Event id(s) not found: {missing_ids}")
95+
96+
return db_events

backend/kayman/schemas/event.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,10 @@ class EventCreate(EventBase):
4646

4747
class EventRead(EventBase):
4848
id: int
49+
50+
51+
class EventUpdate(SQLModel):
52+
type: EventType | None = None
53+
timestamp: datetime | None = None
54+
timezone: TimeZoneName | None = None
55+
description: str | None = None

backend/kayman/tests/crud/test_event.py

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
from datetime import datetime
1+
from datetime import UTC, datetime
22
from unittest.mock import patch
33

4+
import pytest
45
from sqlmodel import Session
56

6-
from kayman.crud.event import create_events, read_event, read_events
7+
from kayman.crud.event import create_events, read_event, read_events, update_events
8+
from kayman.schemas.event import EventType, EventUpdate
79
from kayman.tests.factories import (
810
CategoryFactory,
911
EventEntryFactory,
@@ -162,3 +164,201 @@ def test_read_events_for_update(session: Session):
162164
args = mock_exec.call_args[0]
163165
statement = str(args[0])
164166
assert "FOR UPDATE" in statement
167+
168+
169+
@pytest.mark.parametrize(
170+
("field", "make_value", "commit"),
171+
[
172+
("type", lambda: EventType.Income, True),
173+
("timezone", lambda: "America/New_York", True),
174+
("description", lambda: "new description", True),
175+
# Datetime fields use commit=False: SQLite drops tzinfo on the
176+
# post-commit refresh, so keep the in-memory tz-aware value to compare.
177+
("timestamp", lambda: datetime(2026, 6, 1, tzinfo=UTC), False),
178+
],
179+
ids=[
180+
"type",
181+
"timezone",
182+
"description",
183+
"timestamp",
184+
],
185+
)
186+
def test_update_event_field(session: Session, field, make_value, commit):
187+
event = EventFactory(
188+
type=EventType.Expense,
189+
timestamp=datetime(2025, 1, 1, 12, 0),
190+
timezone="UTC",
191+
description="original description",
192+
)
193+
originals = {
194+
"type": event.type,
195+
"timestamp": event.timestamp,
196+
"timezone": event.timezone,
197+
"description": event.description,
198+
}
199+
new_value = make_value()
200+
201+
updated = update_events(
202+
session,
203+
[event.id],
204+
[EventUpdate(**{field: new_value})],
205+
commit=commit,
206+
)
207+
208+
assert len(updated) == 1
209+
assert getattr(updated[0], field) == new_value
210+
# Every field the caller did not set must be left untouched.
211+
for other_field, original_value in originals.items():
212+
if other_field == field:
213+
continue
214+
assert getattr(updated[0], other_field) == original_value
215+
216+
217+
def test_update_events_explicit_none_clears_description(session: Session):
218+
event = EventFactory(description="original description")
219+
220+
# description=None is explicitly set, so exclude_unset keeps it and the
221+
# column is nulled.
222+
updated = update_events(session, [event.id], [EventUpdate(description=None)])
223+
224+
assert len(updated) == 1
225+
assert updated[0].description is None
226+
227+
db_events = read_events(session, event_ids=[event.id])
228+
assert len(db_events) == 1
229+
assert db_events[0].description is None
230+
231+
232+
def test_update_events_omitted_description_is_untouched(session: Session):
233+
event = EventFactory(type=EventType.Expense, description="original description")
234+
235+
# description is omitted, so exclude_unset drops it and the column survives.
236+
updated = update_events(session, [event.id], [EventUpdate(type=EventType.Income)])
237+
238+
assert len(updated) == 1
239+
assert updated[0].type == EventType.Income
240+
assert updated[0].description == "original description"
241+
242+
db_events = read_events(session, event_ids=[event.id])
243+
assert len(db_events) == 1
244+
assert db_events[0].description == "original description"
245+
246+
247+
def test_update_events_n_events(session: Session):
248+
event_1 = EventFactory(description="one")
249+
event_2 = EventFactory(description="two")
250+
event_3 = EventFactory(description="three")
251+
252+
updated = update_events(
253+
session,
254+
[event_1.id, event_2.id, event_3.id],
255+
[
256+
EventUpdate(description="one updated"),
257+
EventUpdate(description="two updated"),
258+
EventUpdate(description="three updated"),
259+
],
260+
)
261+
262+
assert len(updated) == 3
263+
descriptions = {event.id: event.description for event in updated}
264+
assert descriptions == {
265+
event_1.id: "one updated",
266+
event_2.id: "two updated",
267+
event_3.id: "three updated",
268+
}
269+
270+
271+
def test_update_events_pairs_by_id_not_position(session: Session):
272+
event_1 = EventFactory(description="one")
273+
event_2 = EventFactory(description="two")
274+
event_3 = EventFactory(description="three")
275+
276+
# ids are passed in reverse of the order the DB naturally returns rows in,
277+
# so an implementation that zips the updates against the fetched rows by
278+
# position will apply each update to the wrong event.
279+
updated = update_events(
280+
session,
281+
[event_3.id, event_1.id],
282+
[
283+
EventUpdate(description="three updated"),
284+
EventUpdate(description="one updated"),
285+
],
286+
)
287+
288+
assert len(updated) == 2
289+
descriptions = {event.id: event.description for event in updated}
290+
assert descriptions == {
291+
event_3.id: "three updated",
292+
event_1.id: "one updated",
293+
}
294+
295+
db_events = read_events(session, event_ids=[event_1.id, event_2.id, event_3.id])
296+
assert len(db_events) == 3
297+
db_descriptions = {event.id: event.description for event in db_events}
298+
assert db_descriptions == {
299+
event_1.id: "one updated",
300+
event_2.id: "two", # untouched
301+
event_3.id: "three updated",
302+
}
303+
304+
305+
def test_update_events_empty(session: Session):
306+
updated = update_events(session, [], [])
307+
308+
assert len(updated) == 0
309+
310+
311+
def test_update_events_no_commit(session: Session, session_2: Session):
312+
event = EventFactory(description="original description")
313+
314+
# The update should be applied in the session
315+
session_events = update_events(
316+
session,
317+
[event.id],
318+
[EventUpdate(description="new description")],
319+
commit=False,
320+
)
321+
assert len(session_events) == 1
322+
assert session_events[0].description == "new description"
323+
324+
# The update should not be visible to other sessions (yet)
325+
session_2_events = read_events(session_2, event_ids=[event.id])
326+
assert len(session_2_events) == 1
327+
assert session_2_events[0].description == "original description"
328+
329+
# Commit the update from main session
330+
session.commit()
331+
332+
# The update should now be visible to other sessions
333+
session_2.expire_all() # drop session_2's identity-map snapshot
334+
session_3_events = read_events(session_2, event_ids=[event.id])
335+
assert len(session_3_events) == 1
336+
assert session_3_events[0].description == "new description"
337+
338+
339+
def test_update_events_missing_id(session: Session):
340+
event = EventFactory(description="original description")
341+
missing_id = event.id + 1000
342+
343+
with pytest.raises(ValueError, match=r"Event id\(s\) not found"):
344+
update_events(
345+
session,
346+
[event.id, missing_id],
347+
[
348+
EventUpdate(description="new description"),
349+
EventUpdate(description="never applied"),
350+
],
351+
)
352+
353+
# Nothing should have been mutated
354+
session.rollback()
355+
db_events = read_events(session, event_ids=[event.id])
356+
assert len(db_events) == 1
357+
assert db_events[0].description == "original description"
358+
359+
360+
def test_update_events_length_mismatch(session: Session):
361+
event = EventFactory()
362+
363+
with pytest.raises(ValueError, match="same length"):
364+
update_events(session, [event.id], [])

0 commit comments

Comments
 (0)