Coverage for tests/test_storage_codecs.py: 100%
94 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-07 21:13 +0300
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-07 21:13 +0300
1"""SQLite storage type and value codec tests."""
3from __future__ import annotations
5from collections.abc import Callable
6from datetime import UTC, datetime, timedelta, timezone
7from typing import cast
9from snektest import assert_eq, assert_false, assert_raises, assert_true, test
11import snekql
12from snekql import (
13 MISSING,
14 Blob,
15 Boolean,
16 CurrentTimestamp,
17 DateTime,
18 Fetched,
19 Integer,
20 Json,
21 Model,
22 ModelDeclarationError,
23 ModelValidationError,
24 Pending,
25 Real,
26 Text,
27)
28from snekql.model import decode_model_row, encode_model_row
31@test()
32def v1_exposes_only_sqlite_first_storage_classes() -> None:
33 """Text has no length option and Varchar is not a v1 storage class."""
35 text_constructor = cast("Callable[..., object]", Text)
37 assert_false(hasattr(snekql, "Varchar"))
38 with assert_raises(TypeError):
39 _ = text_constructor(length=255)
42@test()
43def storage_classes_expose_sqlite_metadata() -> None:
44 """V1 columns record the SQLite storage class used for schema generation."""
46 class StorageExample[S = Pending](Model[S, "StorageExample[Fetched]"]):
47 """Table model using every v1 storage class."""
49 integer_value: StorageExample.Col[int] = Integer(nullable=False)
50 real_value: StorageExample.Col[float] = Real(nullable=False)
51 text_value: StorageExample.Col[str] = Text(nullable=False)
52 blob_value: StorageExample.Col[bytes] = Blob(nullable=False)
53 json_value: StorageExample.Col[dict[str, object]] = Json(nullable=False)
54 boolean_value: StorageExample.Col[bool] = Boolean(nullable=False)
55 datetime_value: StorageExample.Col[object] = DateTime(nullable=False)
57 columns = StorageExample.__snekql_columns__
59 assert_eq(columns["integer_value"].sqlite_storage_class, "INTEGER")
60 assert_eq(columns["real_value"].sqlite_storage_class, "REAL")
61 assert_eq(columns["text_value"].sqlite_storage_class, "TEXT")
62 assert_eq(columns["blob_value"].sqlite_storage_class, "BLOB")
63 assert_eq(columns["json_value"].sqlite_storage_class, "TEXT")
64 assert_eq(columns["boolean_value"].sqlite_storage_class, "INTEGER")
65 assert_eq(columns["datetime_value"].sqlite_storage_class, "TEXT")
68@test()
69def boolean_values_encode_to_integer_and_decode_before_validation() -> None:
70 """Boolean columns use INTEGER storage while models expose bools."""
72 class FeatureFlag[S = Pending](Model[S, "FeatureFlag[Fetched]"]):
73 """Table model with a boolean flag."""
75 enabled: FeatureFlag.Col[bool] = Boolean(nullable=False)
77 enabled = FeatureFlag(enabled=True)
78 disabled = cast(
79 "FeatureFlag[Fetched]",
80 decode_model_row(FeatureFlag, {"enabled": 0}),
81 )
82 _, encoded_enabled = encode_model_row(enabled)
84 assert_eq(encoded_enabled, {"enabled": 1})
85 assert_false(disabled.enabled)
87 with assert_raises(ModelValidationError):
88 _ = decode_model_row(FeatureFlag, {"enabled": 2})
91@test()
92def json_values_encode_to_text_and_decode_before_validation() -> None:
93 """Json columns store JSON text and expose decoded Python values."""
95 class Event[S = Pending](Model[S, "Event[Fetched]"]):
96 """Table model with a JSON payload."""
98 payload: Event.Col[dict[str, object]] = Json(nullable=False)
100 event = Event(payload={"kind": "created", "count": 2})
101 fetched = cast(
102 "Event[Fetched]",
103 decode_model_row(Event, {"payload": '{"kind":"created","count":2}'}),
104 )
105 _, encoded_event = encode_model_row(event)
107 assert_eq(encoded_event, {"payload": '{"kind":"created","count":2}'})
108 assert_eq(fetched.payload, {"kind": "created", "count": 2})
110 with assert_raises(ModelValidationError):
111 _ = Event(payload={"bad": {object()}})
113 with assert_raises(ModelValidationError):
114 _ = decode_model_row(Event, {"payload": "not json"})
117@test()
118def datetime_values_are_utc_millisecond_text() -> None:
119 """DateTime accepts aware values and stores UTC millisecond text."""
121 class AuditLog[S = Pending](Model[S, "AuditLog[Fetched]"]):
122 """Table model with a timestamp."""
124 created_at: AuditLog.Col[datetime] = DateTime(nullable=False)
126 source_timezone = timezone(timedelta(hours=5, minutes=30))
127 source = datetime(2026, 5, 31, 12, 0, 1, 987654, tzinfo=source_timezone)
128 audit_log = AuditLog(created_at=source)
129 fetched = cast(
130 "AuditLog[Fetched]",
131 decode_model_row(AuditLog, {"created_at": "2026-05-31T06:30:01.987Z"}),
132 )
133 _, encoded_audit_log = encode_model_row(audit_log)
135 expected = datetime(2026, 5, 31, 6, 30, 1, 987000, tzinfo=UTC)
136 assert_eq(audit_log.created_at, expected)
137 assert_eq(encoded_audit_log, {"created_at": "2026-05-31T06:30:01.987Z"})
138 assert_eq(fetched.created_at, expected)
140 with assert_raises(ModelValidationError):
141 _ = AuditLog(created_at=datetime(2026, 5, 31, 12, 0, 1)) # noqa: DTZ001
144@test()
145def external_value_failures_are_wrapped_in_model_validation_error() -> None:
146 """Default factories and codecs do not leak third-party exceptions."""
148 def broken_default() -> object:
149 msg = "outside validation failure"
150 raise ValueError(msg)
152 class ExternalValue[S = Pending](Model[S, "ExternalValue[Fetched]"]):
153 """Table model with an external default provider."""
155 payload: ExternalValue.Col[object] = Json(default_factory=broken_default)
157 with assert_raises(ModelValidationError):
158 _ = ExternalValue()
161@test()
162def current_timestamp_is_valid_only_for_datetime_generated_columns() -> None:
163 """Server timestamp defaults are limited to generated DateTime fields."""
165 class CreatedEvent[S = Pending](Model[S, "CreatedEvent[Fetched]"]):
166 """Valid generated timestamp column."""
168 created_at: CreatedEvent.GenCol[datetime] = DateTime(
169 server_default=CurrentTimestamp(),
170 default=MISSING,
171 )
173 assert_true(
174 isinstance(
175 CreatedEvent.__snekql_columns__["created_at"].server_default,
176 CurrentTimestamp,
177 ),
178 )
180 with assert_raises(ModelDeclarationError):
182 class NonGeneratedTimestamp[S = Pending](
183 Model[S, "NonGeneratedTimestamp[Fetched]"]
184 ):
185 """Invalid non-generated timestamp default."""
187 created_at: NonGeneratedTimestamp.Col[datetime] = DateTime(
188 server_default=CurrentTimestamp(),
189 default=MISSING,
190 )
192 with assert_raises(ModelDeclarationError):
194 class NonDateTimeTimestamp[S = Pending](
195 Model[S, "NonDateTimeTimestamp[Fetched]"]
196 ):
197 """Invalid CurrentTimestamp use outside a DateTime server default."""
199 created_at: NonDateTimeTimestamp.GenCol[datetime] = Text(
200 default=CurrentTimestamp(),
201 )
203 with assert_raises(ModelDeclarationError):
205 class TimestampWithPythonDefault[S = Pending](
206 Model[S, "TimestampWithPythonDefault[Fetched]"],
207 ):
208 """Invalid server default paired with a Python default."""
210 created_at: TimestampWithPythonDefault.GenCol[datetime] = DateTime(
211 server_default=CurrentTimestamp(),
212 default=datetime(2026, 5, 31, tzinfo=UTC),
213 )