fix: regenerate_for_child is child-scoped (no cross-child refresh)
This commit is contained in:
@@ -125,6 +125,8 @@ async def materialize(
|
|||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
start_date: str,
|
start_date: str,
|
||||||
end_date: str,
|
end_date: str,
|
||||||
|
*,
|
||||||
|
child_ids: list[int] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Generate quest instances for the closed range [start_date, end_date].
|
"""Generate quest instances for the closed range [start_date, end_date].
|
||||||
|
|
||||||
@@ -134,6 +136,12 @@ async def materialize(
|
|||||||
of instances upserted for the range (idempotent — a re-run refreshes
|
of instances upserted for the range (idempotent — a re-run refreshes
|
||||||
the same rows, never duplicates them).
|
the same rows, never duplicates them).
|
||||||
|
|
||||||
|
``child_ids`` optionally scopes the walk to exactly those children:
|
||||||
|
assignees outside the list are never written, so a caller that only
|
||||||
|
wants one child's instances rebuilt (e.g. :func:`regenerate_for_child`
|
||||||
|
after a presence change) cannot touch any unrelated child's rows.
|
||||||
|
When omitted (or None) the walk covers every assignee as before.
|
||||||
|
|
||||||
All inputs are read in ONE locked transaction; each tuple's write then
|
All inputs are read in ONE locked transaction; each tuple's write then
|
||||||
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
|
goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`,
|
||||||
which re-validates the tuple and its current ``due_time`` in the same
|
which re-validates the tuple and its current ``due_time`` in the same
|
||||||
@@ -153,6 +161,7 @@ async def materialize(
|
|||||||
load_materialization_input(database, start_date, end_date)
|
load_materialization_input(database, start_date, end_date)
|
||||||
)
|
)
|
||||||
engine = _build_engine(schedules_records, overrides_records)
|
engine = _build_engine(schedules_records, overrides_records)
|
||||||
|
child_filter = None if child_ids is None else set(child_ids)
|
||||||
|
|
||||||
definitions = [
|
definitions = [
|
||||||
(snapshot.definition.id, _decode_rule(snapshot),
|
(snapshot.definition.id, _decode_rule(snapshot),
|
||||||
@@ -174,6 +183,8 @@ async def materialize(
|
|||||||
if not occurs_on(rule, cursor):
|
if not occurs_on(rule, cursor):
|
||||||
continue
|
continue
|
||||||
for child in assignees:
|
for child in assignees:
|
||||||
|
if child_filter is not None and child.id not in child_filter:
|
||||||
|
continue
|
||||||
if not engine.is_present(child.id, cursor):
|
if not engine.is_present(child.id, cursor):
|
||||||
continue
|
continue
|
||||||
for window in windows:
|
for window in windows:
|
||||||
@@ -252,8 +263,13 @@ async def regenerate_for_child(
|
|||||||
(never a completed instance, never the past) via
|
(never a completed instance, never the past) via
|
||||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`,
|
||||||
then the materialization walk re-runs over the rolling horizon
|
then the materialization walk re-runs over the rolling horizon
|
||||||
``[today, today + DEFAULT_HORIZON_DAYS]`` so the child's
|
``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only,
|
||||||
now-absent/present dates re-materialize against today's presence.
|
so the child's now-absent/present dates re-materialize against
|
||||||
|
today's presence WITHOUT touching any other child's rows.
|
||||||
|
|
||||||
|
``child_id`` must be a plain int — bool and float are rejected
|
||||||
|
(SQLite binds a bool as 0/1 and a float would round, so a malformed
|
||||||
|
id must never mutate the wrong profile).
|
||||||
|
|
||||||
"today" is computed the same way the walk computes it —
|
"today" is computed the same way the walk computes it —
|
||||||
:func:`~.dao_instances._today` — and the re-materialization range uses the
|
:func:`~.dao_instances._today` — and the re-materialization range uses the
|
||||||
@@ -261,10 +277,12 @@ async def regenerate_for_child(
|
|||||||
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
|
:meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`),
|
||||||
so the delete cutoff and the re-materialization window share ONE
|
so the delete cutoff and the re-materialization window share ONE
|
||||||
execution-day anchor even when the call is queued across midnight.
|
execution-day anchor even when the call is queued across midnight.
|
||||||
Returns the number of instances the re-materialization upserted
|
Returns the number of instances the re-materialization upserted for
|
||||||
across the whole snapshot (idempotent — other children's and
|
the child across the whole snapshot.
|
||||||
unaffected definitions' tuples refresh in place, never duplicate).
|
|
||||||
"""
|
"""
|
||||||
|
if isinstance(child_id, bool) or not isinstance(child_id, int):
|
||||||
|
raise ValueError(f"child_id must be an integer, got {child_id!r}")
|
||||||
|
|
||||||
today = _today()
|
today = _today()
|
||||||
|
|
||||||
instances = QuestInstancesDao(database)
|
instances = QuestInstancesDao(database)
|
||||||
@@ -279,4 +297,6 @@ async def regenerate_for_child(
|
|||||||
)
|
)
|
||||||
start = datetime.date.fromisoformat(effective_cutoff)
|
start = datetime.date.fromisoformat(effective_cutoff)
|
||||||
end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
end_date = (start + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat()
|
||||||
return await materialize(database, start.isoformat(), end_date)
|
return await materialize(
|
||||||
|
database, start.isoformat(), end_date, child_ids=[child_id]
|
||||||
|
)
|
||||||
|
|||||||
@@ -962,3 +962,82 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
|
|||||||
return rebuilt
|
return rebuilt
|
||||||
|
|
||||||
_with_db(tmp_path, "regenerate-child-midnight.db")(_body)
|
_with_db(tmp_path, "regenerate-child-midnight.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_for_child_is_child_scoped(tmp_path) -> None:
|
||||||
|
"""Regenerating one child must never touch another child's rows."""
|
||||||
|
async def _body(database):
|
||||||
|
from custom_components.nestquest.dao_presence import (
|
||||||
|
PresenceOverridesDao,
|
||||||
|
)
|
||||||
|
|
||||||
|
children = ChildrenDao(database)
|
||||||
|
overrides = PresenceOverridesDao(database)
|
||||||
|
a = await children.create("Ada", NOW)
|
||||||
|
b = await children.create("Bo", NOW)
|
||||||
|
|
||||||
|
today = datetime.date.today()
|
||||||
|
horizon_end = today + datetime.timedelta(days=14)
|
||||||
|
start_iso = today.isoformat()
|
||||||
|
|
||||||
|
await create_quest_definition(
|
||||||
|
database,
|
||||||
|
"Daily chore",
|
||||||
|
ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso),
|
||||||
|
[a.id, b.id],
|
||||||
|
[("morning", "09:00")],
|
||||||
|
)
|
||||||
|
|
||||||
|
dao = QuestInstancesDao(database)
|
||||||
|
await materialize(database, start_iso, horizon_end.isoformat())
|
||||||
|
|
||||||
|
# Baseline for child b, keyed by due_date, so we can prove its rows
|
||||||
|
# are byte-for-byte unchanged afterward.
|
||||||
|
baseline_b = {
|
||||||
|
r.due_date: (r.id, r.due_time, r.generated_at)
|
||||||
|
for r in await dao.list_by_date_range(
|
||||||
|
b.id, start_iso, horizon_end.isoformat()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assert len(baseline_b) == 15
|
||||||
|
|
||||||
|
# An ABSENT override for child a only, covering days 2..4.
|
||||||
|
absent_start = (today + datetime.timedelta(days=2)).isoformat()
|
||||||
|
absent_end = (today + datetime.timedelta(days=4)).isoformat()
|
||||||
|
await overrides.create(
|
||||||
|
a.id, absent_start, absent_end, False, note="away"
|
||||||
|
)
|
||||||
|
|
||||||
|
await regenerate_for_child(database, a.id)
|
||||||
|
|
||||||
|
# Child a's now-absent days vanished.
|
||||||
|
a_by_due = {
|
||||||
|
r.due_date: r
|
||||||
|
for r in await dao.list_by_date_range(
|
||||||
|
a.id, start_iso, horizon_end.isoformat()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for offset in range(2, 5):
|
||||||
|
iso = (today + datetime.timedelta(days=offset)).isoformat()
|
||||||
|
assert iso not in a_by_due
|
||||||
|
|
||||||
|
# Child b is untouched: same physical rows, same snapshot columns.
|
||||||
|
b_records = await dao.list_by_date_range(
|
||||||
|
b.id, start_iso, horizon_end.isoformat()
|
||||||
|
)
|
||||||
|
assert len(b_records) == 15
|
||||||
|
for r in b_records:
|
||||||
|
assert (r.id, r.due_time, r.generated_at) == baseline_b[r.due_date]
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "regenerate-child-scoped.db")(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_regenerate_for_child_rejects_non_int_child_id(tmp_path) -> None:
|
||||||
|
async def _body(database):
|
||||||
|
for bad in (True, False, 1.0, "1"):
|
||||||
|
with pytest.raises(ValueError, match="child_id must be an integer"):
|
||||||
|
await regenerate_for_child(database, bad)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_with_db(tmp_path, "regenerate-child-badid.db")(_body)
|
||||||
Reference in New Issue
Block a user