Coverage for tests/test_model_declaration.py: 99%
117 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"""Table model declaration and value semantics tests."""
3from __future__ import annotations
5from abc import abstractmethod
6from collections.abc import Callable
7from typing import TYPE_CHECKING, Any, ClassVar, cast
9from snektest import assert_eq, assert_false, assert_is, assert_raises, test
11from snekql import (
12 MISSING,
13 FrozenModelError,
14 Index,
15 Integer,
16 Json,
17 Model,
18 ModelDeclarationError,
19 ModelValidationError,
20 Pending,
21 Text,
22)
24if TYPE_CHECKING:
25 from snekql import Fetched
28@test(mark="fast")
29def pending_model_construction_applies_defaults_and_missing() -> None:
30 """Constructed table models expose provided values, defaults, and MISSING."""
32 class User[S = Pending](Model[S, "User[Fetched]"]):
33 """Table model with normal and generated columns."""
35 id: User.GenCol[int] = Integer(default=MISSING)
36 email: User.Col[str] = Text(nullable=False)
37 status: User.Col[str] = Text(default="active")
39 user = User(email="alice@example.com")
41 assert_is(user.id, MISSING)
42 assert_eq(user.email, "alice@example.com")
43 assert_eq(user.status, "active")
46@test(mark="fast")
47def model_construction_rejects_missing_and_unknown_values() -> None:
48 """Constructing pending models validates constructor field names."""
50 class User[S = Pending](Model[S, "User[Fetched]"]):
51 """Table model with one required field."""
53 email: User.Col[str] = Text(nullable=False)
55 user_constructor = cast("Callable[..., User[Pending]]", User)
57 with assert_raises(ModelValidationError):
58 _ = user_constructor()
60 with assert_raises(ModelValidationError):
61 _ = user_constructor(email="alice@example.com", nickname="alice")
64@test(mark="fast")
65def model_construction_calls_default_factories_per_instance() -> None:
66 """Default factories create real values independently for each model."""
68 class Event[S = Pending](Model[S, "Event[Fetched]"]):
69 """Table model with a default factory."""
71 tags: Event.Col[list[str]] = Json(default_factory=list)
73 first = Event()
74 second = Event()
76 first.tags.append("first")
78 assert_eq(first.tags, ["first"])
79 assert_eq(second.tags, [])
82@test(mark="fast")
83def model_instances_are_frozen_after_construction() -> None:
84 """Post-construction assignment raises the domain frozen error."""
86 class User[S = Pending](Model[S, "User[Fetched]"]):
87 """Table model with one mutable-looking field."""
89 email: User.Col[str] = Text(nullable=False)
91 user = User(email="alice@example.com")
93 with assert_raises(FrozenModelError):
94 user.email = "eve@example.com"
96 with assert_raises(FrozenModelError):
97 user.nickname = "alice"
100@test(mark="fast")
101def model_repr_equality_and_hashing_are_value_based() -> None:
102 """Models compare by field values, omit MISSING in repr, and are unhashable."""
104 class User[S = Pending](Model[S, "User[Fetched]"]):
105 """Table model for deterministic value semantics."""
107 id: User.GenCol[int] = Integer(default=MISSING)
108 email: User.Col[str] = Text(nullable=False)
110 first = User(email="alice@example.com")
111 second = User(email="alice@example.com")
112 third = User(email="bob@example.com")
114 assert_eq(repr(first), "User[Pending](email='alice@example.com')")
115 assert_eq(first, second)
116 assert_false(first == third)
117 with assert_raises(TypeError):
118 _ = hash(first)
121@test(mark="fast")
122def table_names_are_inferred_or_overridden_and_validated() -> None:
123 """Model class creation resolves stable table names from public rules."""
125 class AuditLog[S = Pending](Model[S, "AuditLog[Fetched]"]):
126 """Table model using inferred table name."""
128 message: AuditLog.Col[str] = Text(nullable=False)
130 class User[S = Pending](Model[S, "User[Fetched]"]):
131 """Table model using explicit table name."""
133 __tablename__ = "users"
134 email: User.Col[str] = Text(nullable=False)
136 assert_eq(AuditLog.__tablename__, "audit_log")
137 assert_eq(User.__tablename__, "users")
139 with assert_raises(ModelDeclarationError):
141 class InvalidName[S = Pending](Model[S, "InvalidName[Fetched]"]):
142 """Table model with invalid table name."""
144 __tablename__ = "not valid"
145 email: InvalidName.Col[str] = Text(nullable=False)
147 with assert_raises(ModelDeclarationError):
148 _ = type("InvalidColumn", (Model,), {"not valid": Text(nullable=False)})
151@test(mark="fast")
152def unsupported_model_body_members_raise_declaration_errors() -> None:
153 """V1 model bodies reject non-column annotations and computed properties."""
155 with assert_raises(ModelDeclarationError):
157 class PlainAnnotation[S = Pending](Model[S, "PlainAnnotation[Fetched]"]):
158 """Invalid table model with a plain instance annotation."""
160 email: str
162 class WithClassVar[S = Pending](Model[S, "WithClassVar[Fetched]"]):
163 """Valid table model with an allowed class-level constant."""
165 category: ClassVar[str] = "users"
166 email: WithClassVar.Col[str] = Text(nullable=False)
168 assert_eq(WithClassVar.category, "users")
170 with assert_raises(ModelDeclarationError):
172 class ComputedProperty[S = Pending](Model[S, "ComputedProperty[Fetched]"]):
173 """Invalid table model with a computed property."""
175 email: ComputedProperty.Col[str] = Text(nullable=False)
177 @property
178 def normalized_email(self) -> str:
179 return "computed"
181 with assert_raises(ModelDeclarationError):
183 class AbstractModel[S = Pending](Model[S, "AbstractModel[Fetched]"]):
184 """Invalid abstract table model."""
186 email: AbstractModel.Col[str] = Text(nullable=False)
188 @abstractmethod
189 def normalize(self) -> str:
190 """Abstract behavior is intentionally unsupported for v1."""
193@test(mark="fast")
194def index_declarations_are_validated_in_model_bodies() -> None:
195 """Model declarations reject malformed or duplicate index metadata."""
197 with assert_raises(ModelDeclarationError):
198 _unused_empty_index: object = Index[Any]()
200 with assert_raises(ModelDeclarationError):
201 _unused_invalid_index: object = Index(cast("Any", "email"))
203 with assert_raises(ModelDeclarationError):
205 class PrimaryKeyUnique[S = Pending](Model[S, "PrimaryKeyUnique[Fetched]"]):
206 """Invalid redundant primary key unique declaration."""
208 id: PrimaryKeyUnique.GenCol[int] = Integer(
209 primary_key=True,
210 unique=True,
211 default=MISSING,
212 )
214 with assert_raises(ModelDeclarationError):
216 class TupleIndexes[S = Pending](Model[S, "TupleIndexes[Fetched]"]):
217 """Invalid tuple index collection."""
219 email: TupleIndexes.Col[str] = Text(nullable=False)
220 __indexes__ = (Index(email),)
222 with assert_raises(ModelDeclarationError):
224 class DuplicateIndexColumns[S = Pending](
225 Model[S, "DuplicateIndexColumns[Fetched]"],
226 ):
227 """Invalid duplicate exact ordered column list."""
229 email: DuplicateIndexColumns.Col[str] = Text(nullable=False)
230 __indexes__: ClassVar[list[Index[Any]]] = [
231 Index(email),
232 Index(email, name="ix_duplicate_email"),
233 ]
236@test(mark="fast")
237def non_direct_model_declarations_are_rejected() -> None:
238 """V1 table models reject concrete subclasses and mixin bases."""
240 class User[S = Pending](Model[S, "User[Fetched]"]):
241 """Concrete table model."""
243 email: User.Col[str] = Text(nullable=False)
245 class EmailMixin:
246 """Mixin that is intentionally unsupported for v1 models."""
248 with assert_raises(ModelDeclarationError):
249 _ = type("AdminUser", (User,), {})
251 with assert_raises(ModelDeclarationError):
252 _ = type("MixedUser", (EmailMixin, Model), {})