From ebf4b876a5e1bd896820241cc39d939c1b2a351b Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sun, 13 Sep 2026 21:48:23 -0400 Subject: [PATCH] fix: enforce integer storage and weekday_set validation in schema --- custom_components/nestquest/schema.py | 53 ++++++++-- tests/test_schema.py | 136 ++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 10 deletions(-) diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index 163e98f..ffd3278 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -20,7 +20,19 @@ 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. +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 @@ -35,6 +47,23 @@ 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 ( @@ -42,8 +71,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 ) """, @@ -58,16 +87,20 @@ 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 (interval >= 1), - weekday_set TEXT, - day_of_month INTEGER CHECK (day_of_month IS NULL OR (day_of_month >= 1 AND day_of_month <= 31)), - nth_weekday INTEGER CHECK (nth_weekday IN (-1, 1, 2, 3, 4, 5)), - month INTEGER CHECK (month IS NULL OR (month >= 1 AND month <= 12)), + 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), @@ -91,7 +124,7 @@ SCHEMA_V1_TASK_DEFINITIONS_DDL: list[str] = [ 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 (is_active IN (0, 1)), + is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)), created_at TEXT NOT NULL ) """, diff --git a/tests/test_schema.py b/tests/test_schema.py index 32650da..689bbdc 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -303,6 +303,33 @@ def test_children_is_active_check_enforced(tmp_path) -> None: _run(database.close()) +@pytest.mark.parametrize( + ("column", "bad_value"), + [ + ("sort_order", "abc"), + ("sort_order", 1.5), + ("is_active", "abc"), + ("is_active", 1.5), + ], +) +def test_children_integer_columns_reject_text_and_fraction( + tmp_path, column, bad_value +) -> None: + database = _open_db(tmp_path / f"children-{column}-typeof.db") + try: + _apply(database) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _run( + database.execute( + f"INSERT INTO children (display_name, {column}, created_at) " + "VALUES (?, ?, ?)", + ("Ada", bad_value, "2026-09-13T00:00:00+00:00"), + ) + ) + finally: + _run(database.close()) + + def test_children_created_at_not_null_enforced(tmp_path) -> None: database = _open_db(tmp_path / "created-at.db") try: @@ -488,6 +515,93 @@ def test_schedule_rules_interval_zero_fails(tmp_path) -> None: _run(database.close()) +@pytest.mark.parametrize("bad_interval", ["abc", 1.5]) +def test_schedule_rules_interval_non_integer_fails( + tmp_path, bad_interval +) -> None: + database = _open_db(tmp_path / f"interval-{type(bad_interval).__name__}.db") + try: + _apply(database) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _insert_rule(database, interval=bad_interval) + finally: + _run(database.close()) + + +def test_schedule_rules_interval_integer_one_succeeds(tmp_path) -> None: + database = _open_db(tmp_path / "interval-one.db") + try: + _apply(database) + rule_id = _insert_rule(database, interval=1) + row = _run( + database.fetch_one( + "SELECT interval FROM schedule_rules WHERE id = ?", (rule_id,) + ) + ) + assert row == (1,) + finally: + _run(database.close()) + + +@pytest.mark.parametrize( + ("column", "bad_value"), + [ + ("day_of_month", "abc"), + ("day_of_month", 1.5), + ("nth_weekday", "abc"), + ("nth_weekday", 1.5), + ("month", "abc"), + ("month", 1.5), + ], +) +def test_schedule_rules_integer_columns_reject_text_and_fraction( + tmp_path, column, bad_value +) -> None: + database = _open_db(tmp_path / f"{column}-typeof.db") + try: + _apply(database) + overrides: dict[str, object] = {"rule_type": "monthly", "weekday_set": None} + overrides[column] = bad_value + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _insert_rule(database, **overrides) + finally: + _run(database.close()) + + +@pytest.mark.parametrize( + "bad_weekday_set", + ["", "x", "7", "0,x", "0,8", "0,", "0,1,2,3,4,5,6,0"], +) +def test_schedule_rules_weekday_set_invalid_csv_fails( + tmp_path, bad_weekday_set +) -> None: + database = _open_db(tmp_path / "weekday-set-invalid.db") + try: + _apply(database) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _insert_rule(database, weekday_set=bad_weekday_set) + finally: + _run(database.close()) + + +@pytest.mark.parametrize("weekday_set", ["0", "6", "0,1,2,3,4,5,6"]) +def test_schedule_rules_weekday_set_valid_csv_succeeds( + tmp_path, weekday_set +) -> None: + database = _open_db(tmp_path / "weekday-set-valid.db") + try: + _apply(database) + rule_id = _insert_rule(database, weekday_set=weekday_set) + row = _run( + database.fetch_one( + "SELECT weekday_set FROM schedule_rules WHERE id = ?", (rule_id,) + ) + ) + assert row == (weekday_set,) + finally: + _run(database.close()) + + def test_schedule_rules_day_of_month_out_of_range_fails(tmp_path) -> None: database = _open_db(tmp_path / "day-of-month.db") try: @@ -705,6 +819,28 @@ def test_task_definitions_is_active_check_enforced(tmp_path) -> None: _run(database.close()) +@pytest.mark.parametrize("bad_is_active", ["abc", 1.5]) +def test_task_definitions_is_active_non_integer_fails( + tmp_path, bad_is_active +) -> None: + database = _open_db(tmp_path / "definitions-is-active-typeof.db") + try: + _apply(database) + _run( + database.execute( + "INSERT INTO children (display_name, created_at) VALUES (?, ?)", + ("Ada", "2026-09-13T00:00:00+00:00"), + ) + ) + _insert_rule(database) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _insert_definition( + database, child_id=1, rule_id=1, is_active=bad_is_active + ) + finally: + _run(database.close()) + + def test_task_definitions_title_not_null_enforced(tmp_path) -> None: database = _open_db(tmp_path / "definitions-title.db") try: