feat: add typed DAO layer for schedule_rules and task_definitions (#19)
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
"""Typed DAO layer for the schedule_rules and task_definitions tables.
|
||||
|
||||
All SQL for ``schedule_rules`` and ``task_definitions`` lives in this
|
||||
module per the feature guardrails: callers get typed dataclasses back
|
||||
and never see raw rows or SQL. Every method is async and runs through
|
||||
:class:`~.db.NestQuestDatabase`, so each statement executes on the HA
|
||||
executor and the event loop never blocks.
|
||||
|
||||
Guardrail mapping:
|
||||
|
||||
- ``schedule_rules`` deletion is delete-if-unreferenced: a rule still
|
||||
referenced by any task definition is rejected (the done-condition),
|
||||
not cascaded.
|
||||
- ``task_definitions`` has no delete path at all: definitions are
|
||||
deactivated, never hard-deleted, and history must survive.
|
||||
- ``set_assignee`` changes future instances only — the DAO writes the
|
||||
definition row and nothing else; reassignment never rewrites
|
||||
existing instances or completion history (Feature 06 guardrail).
|
||||
- Validation of rule well-formedness and child activeness lives partly
|
||||
in the schema CHECKs (rule-type coherence) and partly in
|
||||
:meth:`TaskDefinitionsDao.create` (active child, rule existence);
|
||||
no permission checks here, that is the Feature 09 gate's job.
|
||||
|
||||
Concurrency: ``delete_rule_if_unreferenced`` performs its reference
|
||||
check and DELETE inside one transaction under the connection-scoped
|
||||
lock shared with :mod:`.dao_children`, so a definition referencing the
|
||||
rule cannot appear between check and delete on this connection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .dao_children import _connection_lock
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleRuleRecord:
|
||||
"""One row of ``schedule_rules``."""
|
||||
|
||||
id: int
|
||||
rule_type: str
|
||||
interval: int
|
||||
weekday_set: str | None
|
||||
day_of_month: int | None
|
||||
nth_weekday: int | None
|
||||
month: int | None
|
||||
start_date: str
|
||||
end_date: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskDefinitionRecord:
|
||||
"""One row of ``task_definitions``."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
description: str | None
|
||||
icon: str | None
|
||||
child_id: int
|
||||
schedule_rule_id: int
|
||||
due_time: str | None
|
||||
is_active: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
_RULE_COLUMNS = (
|
||||
"id, rule_type, interval, weekday_set, day_of_month, nth_weekday, "
|
||||
"month, start_date, end_date"
|
||||
)
|
||||
_DEFINITION_COLUMNS = (
|
||||
"id, title, description, icon, child_id, schedule_rule_id, due_time, "
|
||||
"is_active, created_at"
|
||||
)
|
||||
|
||||
|
||||
def _rule_from_row(row: tuple) -> ScheduleRuleRecord:
|
||||
return ScheduleRuleRecord(
|
||||
id=row[0],
|
||||
rule_type=row[1],
|
||||
interval=row[2],
|
||||
weekday_set=row[3],
|
||||
day_of_month=row[4],
|
||||
nth_weekday=row[5],
|
||||
month=row[6],
|
||||
start_date=row[7],
|
||||
end_date=row[8],
|
||||
)
|
||||
|
||||
|
||||
def _definition_from_row(row: tuple) -> TaskDefinitionRecord:
|
||||
return TaskDefinitionRecord(
|
||||
id=row[0],
|
||||
title=row[1],
|
||||
description=row[2],
|
||||
icon=row[3],
|
||||
child_id=row[4],
|
||||
schedule_rule_id=row[5],
|
||||
due_time=row[6],
|
||||
is_active=bool(row[7]),
|
||||
created_at=row[8],
|
||||
)
|
||||
|
||||
|
||||
class ScheduleRulesDao:
|
||||
"""Typed async access to the ``schedule_rules`` table."""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def create(
|
||||
self,
|
||||
rule_type: str,
|
||||
start_date: str,
|
||||
*,
|
||||
interval: int = 1,
|
||||
weekday_set: str | None = None,
|
||||
day_of_month: int | None = None,
|
||||
nth_weekday: int | None = None,
|
||||
month: int | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> ScheduleRuleRecord:
|
||||
"""Insert one rule and return the record as stored.
|
||||
|
||||
Rule-type coherence (a weekly rule needs ``weekday_set``, a
|
||||
yearly rule needs ``month``, etc.) is enforced by the schema
|
||||
CHECKs and surfaces as :class:`sqlite3.IntegrityError`.
|
||||
"""
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO schedule_rules (rule_type, interval, weekday_set, "
|
||||
"day_of_month, nth_weekday, month, start_date, end_date) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
rule_type,
|
||||
interval,
|
||||
weekday_set,
|
||||
day_of_month,
|
||||
nth_weekday,
|
||||
month,
|
||||
start_date,
|
||||
end_date,
|
||||
),
|
||||
)
|
||||
rule = await self.get(result.lastrowid)
|
||||
assert rule is not None
|
||||
return rule
|
||||
|
||||
async def get(self, rule_id: int) -> ScheduleRuleRecord | None:
|
||||
"""Return the rule with ``rule_id``, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_RULE_COLUMNS} FROM schedule_rules WHERE id = ?",
|
||||
(rule_id,),
|
||||
)
|
||||
return _rule_from_row(row) if row is not None else None
|
||||
|
||||
async def update(
|
||||
self,
|
||||
rule_id: int,
|
||||
*,
|
||||
rule_type: str | None = None,
|
||||
interval: int | None = None,
|
||||
weekday_set: str | None = None,
|
||||
day_of_month: int | None = None,
|
||||
nth_weekday: int | None = None,
|
||||
month: int | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> int:
|
||||
"""Update the given fields; returns rows updated (0 if absent).
|
||||
|
||||
Only non-None arguments are written. Editing a rule that a
|
||||
definition references changes future materializations only —
|
||||
this method touches no instances (Feature 06/07 separation).
|
||||
"""
|
||||
assignments: list[str] = []
|
||||
parameters: list[object] = []
|
||||
for column, value in (
|
||||
("rule_type", rule_type),
|
||||
("interval", interval),
|
||||
("weekday_set", weekday_set),
|
||||
("day_of_month", day_of_month),
|
||||
("nth_weekday", nth_weekday),
|
||||
("month", month),
|
||||
("start_date", start_date),
|
||||
("end_date", end_date),
|
||||
):
|
||||
if value is not None:
|
||||
assignments.append(f"{column} = ?")
|
||||
parameters.append(value)
|
||||
if not assignments:
|
||||
return 0
|
||||
parameters.append(rule_id)
|
||||
result = await self._database.execute(
|
||||
f"UPDATE schedule_rules SET {', '.join(assignments)} "
|
||||
"WHERE id = ?",
|
||||
tuple(parameters),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def delete_if_unreferenced(self, rule_id: int) -> bool:
|
||||
"""Delete the rule only if no definition references it.
|
||||
|
||||
Returns True if the rule was deleted, False if it is still
|
||||
referenced (or does not exist — distinguish via :meth:`get`).
|
||||
The reference check and the DELETE run inside one transaction
|
||||
under the connection lock, so a definition cannot start
|
||||
referencing the rule between the check and the delete.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
row = await self._database.fetch_one(
|
||||
"SELECT 1 FROM task_definitions "
|
||||
"WHERE schedule_rule_id = ? LIMIT 1",
|
||||
(rule_id,),
|
||||
)
|
||||
if row is not None:
|
||||
return False
|
||||
result = await self._database.execute(
|
||||
"DELETE FROM schedule_rules WHERE id = ?",
|
||||
(rule_id,),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
class TaskDefinitionsDao:
|
||||
"""Typed async access to the ``task_definitions`` table.
|
||||
|
||||
No delete method exists: definitions are deactivated via
|
||||
:meth:`set_active`, never hard-deleted, so completion history keeps
|
||||
its references (feature guardrail).
|
||||
"""
|
||||
|
||||
def __init__(self, database: NestQuestDatabase) -> None:
|
||||
self._database = database
|
||||
|
||||
async def create(
|
||||
self,
|
||||
title: str,
|
||||
child_id: int,
|
||||
schedule_rule_id: int,
|
||||
created_at: str,
|
||||
*,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
due_time: str | None = None,
|
||||
is_active: bool = True,
|
||||
) -> TaskDefinitionRecord:
|
||||
"""Insert one definition and return the record as stored.
|
||||
|
||||
Validates the assignment target before saving: the child must
|
||||
exist and be active, and the rule must exist. The schema
|
||||
foreign keys would reject unknown ids anyway, but an INACTIVE
|
||||
child would pass them — that check must happen here.
|
||||
"""
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT is_active FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
if not child[0]:
|
||||
raise ValueError(
|
||||
f"child {child_id} is inactive and cannot be assigned"
|
||||
)
|
||||
rule = await self._database.fetch_one(
|
||||
"SELECT 1 FROM schedule_rules WHERE id = ?",
|
||||
(schedule_rule_id,),
|
||||
)
|
||||
if rule is None:
|
||||
raise ValueError(f"schedule rule {schedule_rule_id} does not exist")
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO task_definitions (title, description, icon, "
|
||||
"child_id, schedule_rule_id, due_time, is_active, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
child_id,
|
||||
schedule_rule_id,
|
||||
due_time,
|
||||
int(is_active),
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
definition = await self.get(result.lastrowid)
|
||||
assert definition is not None
|
||||
return definition
|
||||
|
||||
async def get(self, definition_id: int) -> TaskDefinitionRecord | None:
|
||||
"""Return the definition with ``definition_id``, or None."""
|
||||
row = await self._database.fetch_one(
|
||||
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
|
||||
"WHERE id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
return _definition_from_row(row) if row is not None else None
|
||||
|
||||
async def list_by_child(self, child_id: int) -> list[TaskDefinitionRecord]:
|
||||
"""Return all definitions assigned to ``child_id``, newest first."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
|
||||
"WHERE child_id = ? ORDER BY id DESC",
|
||||
(child_id,),
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
|
||||
async def list_active(self) -> list[TaskDefinitionRecord]:
|
||||
"""Return all active definitions, oldest first (stable order)."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_DEFINITION_COLUMNS} FROM task_definitions "
|
||||
"WHERE is_active = 1 ORDER BY id"
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
|
||||
async def update(
|
||||
self,
|
||||
definition_id: int,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
due_time: str | None = None,
|
||||
) -> int:
|
||||
"""Update the given fields; returns rows updated (0 if absent).
|
||||
|
||||
Assignment and activation are deliberately NOT settable here:
|
||||
they have their own explicit operations (:meth:`set_assignee`,
|
||||
:meth:`set_active`) so they stay separately loggable and
|
||||
permission-gateable.
|
||||
"""
|
||||
assignments: list[str] = []
|
||||
parameters: list[object] = []
|
||||
for column, value in (
|
||||
("title", title),
|
||||
("description", description),
|
||||
("icon", icon),
|
||||
("due_time", due_time),
|
||||
):
|
||||
if value is not None:
|
||||
assignments.append(f"{column} = ?")
|
||||
parameters.append(value)
|
||||
if not assignments:
|
||||
return 0
|
||||
parameters.append(definition_id)
|
||||
result = await self._database.execute(
|
||||
f"UPDATE task_definitions SET {', '.join(assignments)} "
|
||||
"WHERE id = ?",
|
||||
tuple(parameters),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def set_active(self, definition_id: int, is_active: bool) -> int:
|
||||
"""Deactivate/reactivate; returns rows updated (0 if absent).
|
||||
|
||||
Deactivating stops future instance generation but leaves
|
||||
existing instances and completion history untouched.
|
||||
"""
|
||||
result = await self._database.execute(
|
||||
"UPDATE task_definitions SET is_active = ? WHERE id = ?",
|
||||
(int(is_active), definition_id),
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
async def set_assignee(
|
||||
self, definition_id: int, child_id: int
|
||||
) -> int:
|
||||
"""Reassign the definition to ``child_id``; rows updated (0 if absent).
|
||||
|
||||
Validates the new assignee like :meth:`create` (existing and
|
||||
active). Changes future instances only: already-generated
|
||||
instances and completion history are never rewritten here.
|
||||
"""
|
||||
child = await self._database.fetch_one(
|
||||
"SELECT is_active FROM children WHERE id = ?", (child_id,)
|
||||
)
|
||||
if child is None:
|
||||
raise ValueError(f"child {child_id} does not exist")
|
||||
if not child[0]:
|
||||
raise ValueError(
|
||||
f"child {child_id} is inactive and cannot be assigned"
|
||||
)
|
||||
result = await self._database.execute(
|
||||
"UPDATE task_definitions SET child_id = ? WHERE id = ?",
|
||||
(child_id, definition_id),
|
||||
)
|
||||
return result.rowcount
|
||||
Reference in New Issue
Block a user