fix: enforce integer storage and weekday_set validation in schema
This commit is contained in:
@@ -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
|
||||
)
|
||||
""",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user