diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py index 271d0de..342e626 100644 --- a/custom_components/nestquest/materialize.py +++ b/custom_components/nestquest/materialize.py @@ -125,6 +125,8 @@ async def materialize( database: NestQuestDatabase, start_date: str, end_date: str, + *, + child_ids: list[int] | None = None, ) -> int: """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 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 goes through the atomic :meth:`~.dao_instances.QuestInstancesDao.upsert_if_valid`, 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) ) engine = _build_engine(schedules_records, overrides_records) + child_filter = None if child_ids is None else set(child_ids) definitions = [ (snapshot.definition.id, _decode_rule(snapshot), @@ -174,6 +183,8 @@ async def materialize( if not occurs_on(rule, cursor): continue 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): continue for window in windows: @@ -252,8 +263,13 @@ async def regenerate_for_child( (never a completed instance, never the past) via :meth:`~.dao_instances.QuestInstancesDao.delete_future_uncompleted_for_child`, then the materialization walk re-runs over the rolling horizon - ``[today, today + DEFAULT_HORIZON_DAYS]`` so the child's - now-absent/present dates re-materialize against today's presence. + ``[today, today + DEFAULT_HORIZON_DAYS]`` scoped to that child only, + 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 — :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`), so the delete cutoff and the re-materialization window share ONE execution-day anchor even when the call is queued across midnight. - Returns the number of instances the re-materialization upserted - across the whole snapshot (idempotent — other children's and - unaffected definitions' tuples refresh in place, never duplicate). + Returns the number of instances the re-materialization upserted for + the child across the whole snapshot. """ + 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() instances = QuestInstancesDao(database) @@ -279,4 +297,6 @@ async def regenerate_for_child( ) start = datetime.date.fromisoformat(effective_cutoff) 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] + ) diff --git a/tests/test_materialize.py b/tests/test_materialize.py index 17caad6..62523fd 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -961,4 +961,83 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight( ) is not None return rebuilt - _with_db(tmp_path, "regenerate-child-midnight.db")(_body) \ No newline at end of file + _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) \ No newline at end of file