Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e96459ce9 | ||
|
|
01905ec9ee | ||
|
|
30748f786a |
@@ -12,11 +12,163 @@ caller/DAO rather than a DB default, per the feature guardrails. No
|
||||
produced by callers is not fixed yet (``datetime.isoformat()`` may
|
||||
include microseconds, changing the length), so enforcing a fixed length
|
||||
here would reject valid values. Only non-null is enforced for now.
|
||||
|
||||
Date policy: ``start_date``/``end_date`` are ISO date strings (YYYY-MM-DD,
|
||||
no time component) compared lexically by the ``end_date >= start_date``
|
||||
CHECK; due_date/due_time string shapes are caller-validated by the rule
|
||||
engine and DAO layers, not by DDL.
|
||||
|
||||
``weekday_set`` format: comma-separated weekday integers (CSV), 0 =
|
||||
Monday .. 6 = Sunday, e.g. ``'0,2,4'`` for Mon/Wed/Fri. Chosen over JSON
|
||||
because it sorts, compares and parses trivially in SQL. The format is
|
||||
enforced at the DB level: a CHECK matches ``weekday_set`` against GLOB
|
||||
patterns for element counts 1..7 of single digits 0-6 (GLOB has no
|
||||
repetition quantifier, so the element count is enumerated; more than 7
|
||||
elements is impossible for a weekday set). ``''``, ``'x'``, ``'7'`` and
|
||||
``'0,x'`` are all rejected; NULL stays allowed for non-weekly rules.
|
||||
This keeps the schema layer storage-only — the DAO layer (tasks
|
||||
8c72300c+) does not need its own validator, it can rely on the CHECK.
|
||||
|
||||
Type affinity: SQLite applies column affinity on insert, but CHECKs
|
||||
still observe text and fractional values ('abc' stays text, '1.5'
|
||||
becomes REAL), so every INTEGER column carrying rule semantics carries
|
||||
a ``typeof(...) = 'integer'`` guard in its CHECK.
|
||||
|
||||
Rule-type coherence: two CHECKs enforce that ``weekly`` carries a
|
||||
non-null ``weekday_set``, ``monthly`` carries a ``day_of_month`` or an
|
||||
``nth_weekday``, ``yearly`` carries a ``month``, and ``daily``/``custom``
|
||||
carry none of the month-specific fields. Relaxed deliberately: the DDL
|
||||
does NOT forbid ``month`` on weekly/monthly rules (an "every 2 weeks"
|
||||
rule or a monthly rule could later be pinned to a month) nor extra
|
||||
fields on weekly/monthly/yearly rules generally, so the CHECKs only
|
||||
reject what the rule engine definitively cannot express now.
|
||||
|
||||
Presence pattern format: ``presence_schedules.pattern`` encodes the
|
||||
whole N-week repeating cycle in one TEXT column as pipe-separated
|
||||
segments, one per week of the cycle: segment ``i`` is a ``weekday_set``
|
||||
CSV (same single-digit 0-6 convention as ``schedule_rules.weekday_set``)
|
||||
for week ``i``, e.g. ``'0,2,4|1,3'`` = Mon/Wed/Fri in week 0, Tue/Thu in
|
||||
week 1. An empty segment means the child is absent every day of that
|
||||
week; a wholly empty pattern with ``cycle_length_weeks = 1`` means
|
||||
absent every day of every week, which is distinct from having no
|
||||
schedule row at all (a child with no schedule is present every day, per
|
||||
the feature guardrails). Chosen over JSON or per-week rows to stay
|
||||
consistent with the CSV ``weekday_set`` convention and fully checkable
|
||||
by plain CHECKs. DB-level validation is pure CHECK, no triggers or
|
||||
custom functions: char-class GLOBs reject every character outside
|
||||
digits 0-6, comma and pipe; adjacency GLOBs reject empty elements
|
||||
(double comma, comma next to a pipe, leading/trailing comma) and
|
||||
two-digit runs; a further GLOB rejects contiguous runs of 8+ comma
|
||||
separated digits, which caps a segment at 7 elements — the GLOB cannot
|
||||
match across a ``|``, so cross-segment boundaries stay safe; and the
|
||||
segment count is pinned to ``cycle_length_weeks`` by
|
||||
``LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1``. Per
|
||||
weekday_set this is not full CSV-set normalisation (duplicates like
|
||||
``'0,0'`` or unsorted sets stay storable, as with ``schedule_rules``);
|
||||
the engine treats the segment as a set. A
|
||||
one-row-per-``week_index`` normalization was considered and rejected:
|
||||
the flat single-column encoding keeps the done-condition's table shape
|
||||
and proved fully checkable with plain CHECKs.
|
||||
|
||||
``cycle_length_weeks`` is capped at 4: 2 (alternating weeks) is the
|
||||
common case, and a small cap keeps anchor arithmetic and the encoded
|
||||
pattern human-readable. Revisit the cap — and re-run the pattern
|
||||
validation matrix in tests — if a longer cycle is ever needed.
|
||||
``anchor_date`` is an ISO date string pinning the cycle: the week index
|
||||
of a date is (days since anchor) // 7 mod ``cycle_length_weeks``,
|
||||
computed by the presence engine (Feature 05) from anchor-date
|
||||
arithmetic, never ISO week parity. Like other date columns it carries
|
||||
no shape CHECK, matching the established date policy.
|
||||
|
||||
Presence overrides: ``presence_overrides.end_date`` is NOT NULL,
|
||||
unlike ``schedule_rules.end_date`` — an override is always a concrete
|
||||
date range; a single-day override stores the same date in both columns.
|
||||
``is_present`` 0/1 marks the child absent/present for the whole range;
|
||||
``note`` is optional free text.
|
||||
|
||||
Task instances: ``task_instances`` deliberately carries NO completion
|
||||
status column. An instance's current state derives from the latest row
|
||||
in ``completion_events`` (Feature 08), so materialization (Feature 07)
|
||||
can insert instances without knowing anything about completion and the
|
||||
append-only log stays the single source of truth. ``definition_id``,
|
||||
``due_date`` and ``child_id`` snapshot the materialization-time facts
|
||||
("why this instance exists"): reassignment and rule edits change future
|
||||
instances only and never rewrite these rows. ``due_time`` snapshots
|
||||
the definition's optional due time at generation for the same reason —
|
||||
the instance keeps its generation-time value even if the definition is
|
||||
edited later. ``generated_at`` is a caller-set UTC ISO-8601 timestamp
|
||||
per the module timestamp policy (no DB default, so a generation batch
|
||||
stamps one coherent time).
|
||||
|
||||
Uniqueness: the (definition_id, due_date) pair is declared as a
|
||||
table-level ``UNIQUE`` constraint rather than a separate ``CREATE
|
||||
UNIQUE INDEX`` statement. SQLite materializes it as an implicit unique
|
||||
index (``sqlite_autoindex_task_instances_1``) — the required "unique
|
||||
index prevents duplicate instances" — and the constraint form cannot be
|
||||
silently dropped the way a standalone index can, so the duplicate door
|
||||
stays shut. Idempotent materialization keys on exactly this pair. No
|
||||
secondary indexes beyond it are declared yet: history and board queries
|
||||
(Features 08/10/13) should justify their indexes as a later migration
|
||||
once their query shapes exist.
|
||||
|
||||
No ``ON DELETE`` clauses are declared on any foreign key in this
|
||||
module: children and definitions are deactivated, never deleted, so a
|
||||
deletion that would orphan instances or events is a bug and must fail
|
||||
loudly rather than cascade.
|
||||
|
||||
Completion events: ``completion_events`` is strictly append-only. That
|
||||
property is enforced at the DAO layer — the completion-events DAO
|
||||
exposes no UPDATE or DELETE path (task a9ea75fc) — and deliberately NOT
|
||||
by database triggers, matching the module's no-trigger policy: a RAISE
|
||||
trigger would be the one exception to "pure CHECK, no triggers",
|
||||
complicate the later migration runner, and still prove nothing (any
|
||||
writer can run raw SQL). ``event_type`` is constrained to
|
||||
``completed``/``uncompleted``; a ``missed`` row is intentionally not
|
||||
representable — missed is a derived state announced on the HA event bus
|
||||
(Feature 10), never an appended event.
|
||||
|
||||
``child_id`` is deliberately denormalized (it is also derivable via
|
||||
``task_instances``): history reporting (Feature 13) filters and rates
|
||||
per child directly against the log, and the log should stay
|
||||
self-describing without joins.
|
||||
|
||||
Actor policy: ``actor_source`` distinguishes an authenticated HA user
|
||||
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
|
||||
panel tap with no user context (``'panel'``, with ``actor_user_id``
|
||||
NULL — the tapped child profile is already ``child_id``). A coherence
|
||||
CHECK makes the pair shape non-negotiable: ``'user'`` requires
|
||||
``actor_user_id``, ``'panel'`` forbids it. Other actor sources are
|
||||
rejected; if an unattended system actor is ever needed, that is a
|
||||
schema migration, not a silent widening.
|
||||
|
||||
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
|
||||
``completed`` events — at completion time the due date is known and the
|
||||
flag is decidable. For ``uncompleted`` events the flag is optional:
|
||||
Feature 08 decides whether a reversal carries the reversed completion's
|
||||
on-time flag or NULL, and the DDL deliberately accepts both rather than
|
||||
guessing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .db import NestQuestDatabase
|
||||
|
||||
#: GLOB patterns matching a valid ``weekday_set`` CSV: one GLOB per
|
||||
#: possible element count 1..7 (a weekday set cannot exceed 7 elements).
|
||||
#: Each element is a single digit 0-6, separated by a literal comma.
|
||||
#: GLOB has no repetition quantifier, so the element count is enumerated.
|
||||
#: ``''`` and NULL are deliberately NOT matched: NULL is allowed for
|
||||
#: non-weekly rules via a separate ``IS NULL`` arm, and '' is invalid
|
||||
#: because weekly rules need at least one weekday.
|
||||
WEEKDAY_SET_GLOB_PATTERNS: list[str] = [
|
||||
"[0-6]",
|
||||
"[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
"[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]",
|
||||
]
|
||||
|
||||
SCHEMA_V1_CHILDREN_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS children (
|
||||
@@ -24,8 +176,8 @@ SCHEMA_V1_CHILDREN_DDL: list[str] = [
|
||||
display_name TEXT NOT NULL,
|
||||
colour TEXT,
|
||||
avatar_ref TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0 CHECK (typeof(sort_order) = 'integer'),
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
@@ -40,9 +192,134 @@ SCHEMA_V1_ADMIN_USERS_DDL: list[str] = [
|
||||
""",
|
||||
]
|
||||
|
||||
_WEEKDAY_SET_CHECK = " OR ".join(
|
||||
f"weekday_set GLOB '{pattern}'" for pattern in WEEKDAY_SET_GLOB_PATTERNS
|
||||
)
|
||||
|
||||
SCHEMA_V1_SCHEDULE_RULES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS schedule_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_type TEXT NOT NULL CHECK (rule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')),
|
||||
interval INTEGER NOT NULL DEFAULT 1 CHECK (typeof(interval) = 'integer' AND interval >= 1),
|
||||
weekday_set TEXT CHECK (weekday_set IS NULL OR ({_WEEKDAY_SET_CHECK})),
|
||||
day_of_month INTEGER CHECK (day_of_month IS NULL OR (typeof(day_of_month) = 'integer' AND day_of_month >= 1 AND day_of_month <= 31)),
|
||||
nth_weekday INTEGER CHECK (nth_weekday IS NULL OR (typeof(nth_weekday) = 'integer' AND nth_weekday IN (-1, 1, 2, 3, 4, 5))),
|
||||
month INTEGER CHECK (month IS NULL OR (typeof(month) = 'integer' AND month >= 1 AND month <= 12)),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT,
|
||||
CHECK (end_date IS NULL OR end_date >= start_date),
|
||||
CHECK (
|
||||
(rule_type = 'weekly' AND weekday_set IS NOT NULL)
|
||||
OR (rule_type = 'monthly' AND (day_of_month IS NOT NULL OR nth_weekday IS NOT NULL))
|
||||
OR (rule_type = 'yearly' AND month IS NOT NULL)
|
||||
OR (rule_type IN ('daily', 'custom') AND day_of_month IS NULL AND nth_weekday IS NULL AND month IS NULL)
|
||||
)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_TASK_DEFINITIONS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_definitions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
|
||||
due_time TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_PRESENCE_SCHEDULES_DDL: list[str] = [
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS presence_schedules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
child_id INTEGER NOT NULL UNIQUE REFERENCES children(id),
|
||||
cycle_length_weeks INTEGER NOT NULL CHECK (typeof(cycle_length_weeks) = 'integer' AND cycle_length_weeks >= 1 AND cycle_length_weeks <= 4),
|
||||
anchor_date TEXT NOT NULL,
|
||||
pattern TEXT NOT NULL CHECK (
|
||||
NOT pattern GLOB '*[^0-6,|]*'
|
||||
AND NOT pattern GLOB '*[0-6][0-6]*'
|
||||
AND NOT pattern GLOB '*,,*'
|
||||
AND NOT pattern GLOB '*,|*'
|
||||
AND NOT pattern GLOB '*|,*'
|
||||
AND NOT pattern GLOB ',*'
|
||||
AND NOT pattern GLOB '*,'
|
||||
AND NOT pattern GLOB '*[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6],[0-6]*'
|
||||
AND (LENGTH(pattern) - LENGTH(REPLACE(pattern, '|', '')) + 1) = cycle_length_weeks
|
||||
)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_PRESENCE_OVERRIDES_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS presence_overrides (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
is_present INTEGER NOT NULL CHECK (typeof(is_present) = 'integer' AND is_present IN (0, 1)),
|
||||
note TEXT,
|
||||
CHECK (end_date >= start_date)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_TASK_INSTANCES_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_instances (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
definition_id INTEGER NOT NULL REFERENCES task_definitions(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
due_date TEXT NOT NULL,
|
||||
due_time TEXT,
|
||||
generated_at TEXT NOT NULL,
|
||||
UNIQUE (definition_id, due_date)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS completion_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
instance_id INTEGER NOT NULL REFERENCES task_instances(id),
|
||||
child_id INTEGER NOT NULL REFERENCES children(id),
|
||||
event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')),
|
||||
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
|
||||
actor_user_id TEXT,
|
||||
occurred_at TEXT NOT NULL,
|
||||
was_on_time INTEGER CHECK (
|
||||
was_on_time IS NULL
|
||||
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
|
||||
),
|
||||
CHECK (
|
||||
(actor_source = 'user' AND actor_user_id IS NOT NULL)
|
||||
OR (actor_source = 'panel' AND actor_user_id IS NULL)
|
||||
),
|
||||
CHECK (
|
||||
(event_type = 'completed' AND was_on_time IS NOT NULL)
|
||||
OR (event_type = 'uncompleted')
|
||||
)
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
SCHEMA_V1_STATEMENTS: list[str] = [
|
||||
*SCHEMA_V1_CHILDREN_DDL,
|
||||
*SCHEMA_V1_ADMIN_USERS_DDL,
|
||||
*SCHEMA_V1_SCHEDULE_RULES_DDL,
|
||||
*SCHEMA_V1_TASK_DEFINITIONS_DDL,
|
||||
*SCHEMA_V1_PRESENCE_SCHEDULES_DDL,
|
||||
*SCHEMA_V1_PRESENCE_OVERRIDES_DDL,
|
||||
*SCHEMA_V1_TASK_INSTANCES_DDL,
|
||||
*SCHEMA_V1_COMPLETION_EVENTS_DDL,
|
||||
]
|
||||
|
||||
|
||||
|
||||
+20
-1
@@ -236,4 +236,23 @@ async def test_unload_closes_database_even_when_listener_removal_raises() -> Non
|
||||
await async_unload_entry(hass, entry)
|
||||
assert database.connected is False, "database leaked despite remover error"
|
||||
assert entry.runtime_data is None
|
||||
assert DOMAIN not in hass.data
|
||||
assert DOMAIN not in hass.data
|
||||
|
||||
|
||||
async def test_setup_applies_all_v1_tables(hass, make_entry) -> None:
|
||||
"""Setup applies the full v1 DDL: all four v1 tables exist afterwards."""
|
||||
entry = _wire(make_entry(), hass.registry)
|
||||
assert await async_setup_entry(hass, entry) is True
|
||||
database = entry.runtime_data.database
|
||||
rows = await database.fetch_all(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||||
"AND name IN ('children', 'admin_users', 'schedule_rules', "
|
||||
"'task_definitions') ORDER BY name"
|
||||
)
|
||||
assert [row[0] for row in rows] == [
|
||||
"admin_users",
|
||||
"children",
|
||||
"schedule_rules",
|
||||
"task_definitions",
|
||||
]
|
||||
await async_unload_entry(hass, entry)
|
||||
+1752
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user