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

1"""Table model declaration and value semantics tests.""" 

2 

3from __future__ import annotations 

4 

5from abc import abstractmethod 

6from collections.abc import Callable 

7from typing import TYPE_CHECKING, Any, ClassVar, cast 

8 

9from snektest import assert_eq, assert_false, assert_is, assert_raises, test 

10 

11from snekql import ( 

12 MISSING, 

13 FrozenModelError, 

14 Index, 

15 Integer, 

16 Json, 

17 Model, 

18 ModelDeclarationError, 

19 ModelValidationError, 

20 Pending, 

21 Text, 

22) 

23 

24if TYPE_CHECKING: 

25 from snekql import Fetched 

26 

27 

28@test(mark="fast") 

29def pending_model_construction_applies_defaults_and_missing() -> None: 

30 """Constructed table models expose provided values, defaults, and MISSING.""" 

31 

32 class User[S = Pending](Model[S, "User[Fetched]"]): 

33 """Table model with normal and generated columns.""" 

34 

35 id: User.GenCol[int] = Integer(default=MISSING) 

36 email: User.Col[str] = Text(nullable=False) 

37 status: User.Col[str] = Text(default="active") 

38 

39 user = User(email="alice@example.com") 

40 

41 assert_is(user.id, MISSING) 

42 assert_eq(user.email, "alice@example.com") 

43 assert_eq(user.status, "active") 

44 

45 

46@test(mark="fast") 

47def model_construction_rejects_missing_and_unknown_values() -> None: 

48 """Constructing pending models validates constructor field names.""" 

49 

50 class User[S = Pending](Model[S, "User[Fetched]"]): 

51 """Table model with one required field.""" 

52 

53 email: User.Col[str] = Text(nullable=False) 

54 

55 user_constructor = cast("Callable[..., User[Pending]]", User) 

56 

57 with assert_raises(ModelValidationError): 

58 _ = user_constructor() 

59 

60 with assert_raises(ModelValidationError): 

61 _ = user_constructor(email="alice@example.com", nickname="alice") 

62 

63 

64@test(mark="fast") 

65def model_construction_calls_default_factories_per_instance() -> None: 

66 """Default factories create real values independently for each model.""" 

67 

68 class Event[S = Pending](Model[S, "Event[Fetched]"]): 

69 """Table model with a default factory.""" 

70 

71 tags: Event.Col[list[str]] = Json(default_factory=list) 

72 

73 first = Event() 

74 second = Event() 

75 

76 first.tags.append("first") 

77 

78 assert_eq(first.tags, ["first"]) 

79 assert_eq(second.tags, []) 

80 

81 

82@test(mark="fast") 

83def model_instances_are_frozen_after_construction() -> None: 

84 """Post-construction assignment raises the domain frozen error.""" 

85 

86 class User[S = Pending](Model[S, "User[Fetched]"]): 

87 """Table model with one mutable-looking field.""" 

88 

89 email: User.Col[str] = Text(nullable=False) 

90 

91 user = User(email="alice@example.com") 

92 

93 with assert_raises(FrozenModelError): 

94 user.email = "eve@example.com" 

95 

96 with assert_raises(FrozenModelError): 

97 user.nickname = "alice" 

98 

99 

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.""" 

103 

104 class User[S = Pending](Model[S, "User[Fetched]"]): 

105 """Table model for deterministic value semantics.""" 

106 

107 id: User.GenCol[int] = Integer(default=MISSING) 

108 email: User.Col[str] = Text(nullable=False) 

109 

110 first = User(email="alice@example.com") 

111 second = User(email="alice@example.com") 

112 third = User(email="bob@example.com") 

113 

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) 

119 

120 

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.""" 

124 

125 class AuditLog[S = Pending](Model[S, "AuditLog[Fetched]"]): 

126 """Table model using inferred table name.""" 

127 

128 message: AuditLog.Col[str] = Text(nullable=False) 

129 

130 class User[S = Pending](Model[S, "User[Fetched]"]): 

131 """Table model using explicit table name.""" 

132 

133 __tablename__ = "users" 

134 email: User.Col[str] = Text(nullable=False) 

135 

136 assert_eq(AuditLog.__tablename__, "audit_log") 

137 assert_eq(User.__tablename__, "users") 

138 

139 with assert_raises(ModelDeclarationError): 

140 

141 class InvalidName[S = Pending](Model[S, "InvalidName[Fetched]"]): 

142 """Table model with invalid table name.""" 

143 

144 __tablename__ = "not valid" 

145 email: InvalidName.Col[str] = Text(nullable=False) 

146 

147 with assert_raises(ModelDeclarationError): 

148 _ = type("InvalidColumn", (Model,), {"not valid": Text(nullable=False)}) 

149 

150 

151@test(mark="fast") 

152def unsupported_model_body_members_raise_declaration_errors() -> None: 

153 """V1 model bodies reject non-column annotations and computed properties.""" 

154 

155 with assert_raises(ModelDeclarationError): 

156 

157 class PlainAnnotation[S = Pending](Model[S, "PlainAnnotation[Fetched]"]): 

158 """Invalid table model with a plain instance annotation.""" 

159 

160 email: str 

161 

162 class WithClassVar[S = Pending](Model[S, "WithClassVar[Fetched]"]): 

163 """Valid table model with an allowed class-level constant.""" 

164 

165 category: ClassVar[str] = "users" 

166 email: WithClassVar.Col[str] = Text(nullable=False) 

167 

168 assert_eq(WithClassVar.category, "users") 

169 

170 with assert_raises(ModelDeclarationError): 

171 

172 class ComputedProperty[S = Pending](Model[S, "ComputedProperty[Fetched]"]): 

173 """Invalid table model with a computed property.""" 

174 

175 email: ComputedProperty.Col[str] = Text(nullable=False) 

176 

177 @property 

178 def normalized_email(self) -> str: 

179 return "computed" 

180 

181 with assert_raises(ModelDeclarationError): 

182 

183 class AbstractModel[S = Pending](Model[S, "AbstractModel[Fetched]"]): 

184 """Invalid abstract table model.""" 

185 

186 email: AbstractModel.Col[str] = Text(nullable=False) 

187 

188 @abstractmethod 

189 def normalize(self) -> str: 

190 """Abstract behavior is intentionally unsupported for v1.""" 

191 

192 

193@test(mark="fast") 

194def index_declarations_are_validated_in_model_bodies() -> None: 

195 """Model declarations reject malformed or duplicate index metadata.""" 

196 

197 with assert_raises(ModelDeclarationError): 

198 _unused_empty_index: object = Index[Any]() 

199 

200 with assert_raises(ModelDeclarationError): 

201 _unused_invalid_index: object = Index(cast("Any", "email")) 

202 

203 with assert_raises(ModelDeclarationError): 

204 

205 class PrimaryKeyUnique[S = Pending](Model[S, "PrimaryKeyUnique[Fetched]"]): 

206 """Invalid redundant primary key unique declaration.""" 

207 

208 id: PrimaryKeyUnique.GenCol[int] = Integer( 

209 primary_key=True, 

210 unique=True, 

211 default=MISSING, 

212 ) 

213 

214 with assert_raises(ModelDeclarationError): 

215 

216 class TupleIndexes[S = Pending](Model[S, "TupleIndexes[Fetched]"]): 

217 """Invalid tuple index collection.""" 

218 

219 email: TupleIndexes.Col[str] = Text(nullable=False) 

220 __indexes__ = (Index(email),) 

221 

222 with assert_raises(ModelDeclarationError): 

223 

224 class DuplicateIndexColumns[S = Pending]( 

225 Model[S, "DuplicateIndexColumns[Fetched]"], 

226 ): 

227 """Invalid duplicate exact ordered column list.""" 

228 

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 ] 

234 

235 

236@test(mark="fast") 

237def non_direct_model_declarations_are_rejected() -> None: 

238 """V1 table models reject concrete subclasses and mixin bases.""" 

239 

240 class User[S = Pending](Model[S, "User[Fetched]"]): 

241 """Concrete table model.""" 

242 

243 email: User.Col[str] = Text(nullable=False) 

244 

245 class EmailMixin: 

246 """Mixin that is intentionally unsupported for v1 models.""" 

247 

248 with assert_raises(ModelDeclarationError): 

249 _ = type("AdminUser", (User,), {}) 

250 

251 with assert_raises(ModelDeclarationError): 

252 _ = type("MixedUser", (EmailMixin, Model), {})