swiftpy-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2034 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import inspect
5
+ import json
6
+ import logging
7
+ import math
8
+ from collections.abc import Awaitable, Callable, Generator, Iterable, Sequence
9
+ from datetime import UTC, datetime
10
+ from typing import (
11
+ Any,
12
+ ClassVar,
13
+ Self,
14
+ TypeVar,
15
+ get_origin,
16
+ get_type_hints,
17
+ overload,
18
+ )
19
+
20
+ from swiftpy.database.exceptions import ModelNotFoundError
21
+ from swiftpy.database.identifiers import validate_identifier
22
+ from swiftpy.database.orm.fields import Field, FieldInfo, VectorFieldInfo
23
+ from swiftpy.database.orm.registry import (
24
+ _MODEL_REGISTRY,
25
+ restore_model_registry,
26
+ snapshot_model_registry,
27
+ )
28
+ from swiftpy.database.orm.relationships import (
29
+ BelongsTo,
30
+ BelongsToMany,
31
+ HasMany,
32
+ HasOne,
33
+ Relation,
34
+ RelationDescriptor,
35
+ )
36
+ from swiftpy.database.pagination import Paginator, SimplePaginator
37
+ from swiftpy.database.query_builder import Query, QueryBuilder, QueryError
38
+ from swiftpy.database.transaction import get_active_connection
39
+ from swiftpy.events.dispatcher import EventDispatcher
40
+ from swiftpy.support.strings import pluralize, snake_case
41
+
42
+ T = TypeVar("T", bound="Model")
43
+ _TRelModel = TypeVar("_TRelModel", bound="Model")
44
+ _MISSING = object()
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ class PrimaryKeyError(RuntimeError, ValueError):
49
+ """Raised when a primary key is invalid, composite, or missing on a persisted model."""
50
+
51
+
52
+ class MassAssignmentError(ValueError):
53
+ """Raised when an attribute cannot be mass-assigned."""
54
+
55
+
56
+ class ModelQueryBuilder[T: Model]:
57
+ """Hydrates database records into strongly typed Active Record Model instances."""
58
+
59
+ def __init__(self, model_cls: type[T]) -> None:
60
+ self.model_cls = model_cls
61
+ self.builder = Query.table(model_cls.__table__)
62
+ self._eager_loads: list[str] = []
63
+
64
+ def clone(self) -> Self:
65
+ """Create an independent copy of this ModelQueryBuilder."""
66
+ cloned = self.__class__(self.model_cls)
67
+ cloned.builder = self.builder.clone()
68
+ cloned._eager_loads = list(self._eager_loads)
69
+ return cloned
70
+
71
+ def allow_all(self) -> Self:
72
+ """Explicitly permit mass UPDATE or DELETE queries without a WHERE clause (Decision D-10)."""
73
+ self.builder.allow_all()
74
+ return self
75
+
76
+ def where(self, column: str, operator_or_value: Any, value: Any = _MISSING) -> Self:
77
+ if value is _MISSING:
78
+ self.builder.where(column, operator_or_value)
79
+ else:
80
+ self.builder.where(column, operator_or_value, value)
81
+ return self
82
+
83
+ def or_where(self, column: str, operator_or_value: Any, value: Any = _MISSING) -> Self:
84
+ if value is _MISSING:
85
+ self.builder.or_where(column, operator_or_value)
86
+ else:
87
+ self.builder.or_where(column, operator_or_value, value)
88
+ return self
89
+
90
+ def where_group(
91
+ self,
92
+ callback: Callable[[ModelQueryBuilder[T]], Any],
93
+ *,
94
+ boolean: str = "AND",
95
+ ) -> Self:
96
+ """Add a nested WHERE group with explicit boolean grouping (parentheses)."""
97
+ def _adapted_callback(qb: QueryBuilder) -> Any:
98
+ mqb = self.__class__(self.model_cls)
99
+ mqb.builder = qb
100
+ return callback(mqb)
101
+
102
+ self.builder.where_group(_adapted_callback, boolean=boolean)
103
+ return self
104
+
105
+ def or_where_group(
106
+ self,
107
+ callback: Callable[[ModelQueryBuilder[T]], Any],
108
+ ) -> Self:
109
+ """Add an OR nested WHERE group."""
110
+ return self.where_group(callback, boolean="OR")
111
+
112
+ def where_in(
113
+ self, column: str, values: list[Any] | tuple[Any, ...] | QueryBuilder
114
+ ) -> Self:
115
+ self.builder.where_in(column, values)
116
+ return self
117
+
118
+ def or_where_in(
119
+ self, column: str, values: list[Any] | tuple[Any, ...] | QueryBuilder
120
+ ) -> Self:
121
+ self.builder.or_where_in(column, values)
122
+ return self
123
+
124
+ def where_not_in(
125
+ self, column: str, values: list[Any] | tuple[Any, ...] | QueryBuilder
126
+ ) -> Self:
127
+ self.builder.where_not_in(column, values)
128
+ return self
129
+
130
+ def or_where_not_in(
131
+ self, column: str, values: list[Any] | tuple[Any, ...] | QueryBuilder
132
+ ) -> Self:
133
+ self.builder.or_where_not_in(column, values)
134
+ return self
135
+
136
+ def where_like(
137
+ self,
138
+ column: str,
139
+ value: str,
140
+ *,
141
+ case_insensitive: bool = False,
142
+ match: str = "contains",
143
+ boolean: str = "AND",
144
+ ) -> Self:
145
+ self.builder.where_like(
146
+ column,
147
+ value,
148
+ case_insensitive=case_insensitive,
149
+ match=match,
150
+ boolean=boolean,
151
+ )
152
+ return self
153
+
154
+ def or_where_like(
155
+ self,
156
+ column: str,
157
+ value: str,
158
+ *,
159
+ case_insensitive: bool = False,
160
+ match: str = "contains",
161
+ ) -> Self:
162
+ self.builder.or_where_like(
163
+ column, value, case_insensitive=case_insensitive, match=match
164
+ )
165
+ return self
166
+
167
+ def where_ilike(
168
+ self,
169
+ column: str,
170
+ value: str,
171
+ *,
172
+ match: str = "contains",
173
+ boolean: str = "AND",
174
+ ) -> Self:
175
+ self.builder.where_ilike(column, value, match=match, boolean=boolean)
176
+ return self
177
+
178
+ def or_where_ilike(
179
+ self,
180
+ column: str,
181
+ value: str,
182
+ *,
183
+ match: str = "contains",
184
+ ) -> Self:
185
+ self.builder.or_where_ilike(column, value, match=match)
186
+ return self
187
+
188
+ def where_between(
189
+ self, column: str, values: Sequence[Any], *, boolean: str = "AND"
190
+ ) -> Self:
191
+ self.builder.where_between(column, values, boolean=boolean)
192
+ return self
193
+
194
+ def where_not_between(
195
+ self, column: str, values: Sequence[Any], *, boolean: str = "AND"
196
+ ) -> Self:
197
+ self.builder.where_not_between(column, values, boolean=boolean)
198
+ return self
199
+
200
+ def or_where_between(self, column: str, values: Sequence[Any]) -> Self:
201
+ self.builder.or_where_between(column, values)
202
+ return self
203
+
204
+ def or_where_not_between(self, column: str, values: Sequence[Any]) -> Self:
205
+ self.builder.or_where_not_between(column, values)
206
+ return self
207
+
208
+ def where_null(self, column: str) -> Self:
209
+ self.builder.where_null(column)
210
+ return self
211
+
212
+ def or_where_null(self, column: str) -> Self:
213
+ self.builder.or_where_null(column)
214
+ return self
215
+
216
+ def where_not_null(self, column: str) -> Self:
217
+ self.builder.where_not_null(column)
218
+ return self
219
+
220
+ def or_where_not_null(self, column: str) -> Self:
221
+ self.builder.or_where_not_null(column)
222
+ return self
223
+
224
+ def where_raw(self, sql: str, bindings: Sequence[Any] | None = None) -> Self:
225
+ self.builder.where_raw(sql, bindings)
226
+ return self
227
+
228
+ def or_where_raw(self, sql: str, bindings: Sequence[Any] | None = None) -> Self:
229
+ self.builder.or_where_raw(sql, bindings)
230
+ return self
231
+
232
+ def join(
233
+ self,
234
+ table: str,
235
+ first: str,
236
+ operator: str,
237
+ second: str,
238
+ type_: str = "INNER",
239
+ ) -> Self:
240
+ self.builder.join(table, first, operator, second, type_=type_)
241
+ return self
242
+
243
+ def left_join(
244
+ self, table: str, first: str, operator: str, second: str
245
+ ) -> Self:
246
+ self.builder.left_join(table, first, operator, second)
247
+ return self
248
+
249
+ def right_join(
250
+ self, table: str, first: str, operator: str, second: str
251
+ ) -> Self:
252
+ self.builder.right_join(table, first, operator, second)
253
+ return self
254
+
255
+ def with_(self, *relations: str) -> Self:
256
+ self._eager_loads.extend(relations)
257
+ return self
258
+
259
+ def limit(self, value: int) -> Self:
260
+ self.builder.limit(value)
261
+ return self
262
+
263
+ def offset(self, value: int) -> Self:
264
+ self.builder.offset(value)
265
+ return self
266
+
267
+ def take(self, value: int) -> Self:
268
+ self.builder.take(value)
269
+ return self
270
+
271
+ def skip(self, value: int) -> Self:
272
+ self.builder.skip(value)
273
+ return self
274
+
275
+ def group_by(self, *columns: str | Sequence[str]) -> Self:
276
+ self.builder.group_by(*columns)
277
+ return self
278
+
279
+ def having(
280
+ self,
281
+ column: str,
282
+ operator_or_value: Any,
283
+ value: Any = _MISSING,
284
+ boolean: str = "AND",
285
+ ) -> Self:
286
+ self.builder.having(column, operator_or_value, value, boolean=boolean)
287
+ return self
288
+
289
+ def or_having(
290
+ self, column: str, operator_or_value: Any, value: Any = _MISSING
291
+ ) -> Self:
292
+ self.builder.or_having(column, operator_or_value, value)
293
+ return self
294
+
295
+ def having_raw(
296
+ self,
297
+ sql: str,
298
+ bindings: Sequence[Any] | None = None,
299
+ boolean: str = "AND",
300
+ ) -> Self:
301
+ self.builder.having_raw(sql, bindings, boolean=boolean)
302
+ return self
303
+
304
+ def or_having_raw(
305
+ self, sql: str, bindings: list[Any] | None = None
306
+ ) -> Self:
307
+ self.builder.or_having_raw(sql, bindings)
308
+ return self
309
+
310
+ def select_raw(
311
+ self, sql: str, bindings: list[Any] | None = None
312
+ ) -> Self:
313
+ self.builder.select_raw(sql, bindings)
314
+ return self
315
+
316
+ def select_count(
317
+ self, column: str = "*", alias: str | None = None
318
+ ) -> Self:
319
+ self.builder.select_count(column, alias)
320
+ return self
321
+
322
+ def select_sum(
323
+ self, column: str, alias: str | None = None
324
+ ) -> Self:
325
+ self.builder.select_sum(column, alias)
326
+ return self
327
+
328
+ def select_avg(
329
+ self, column: str, alias: str | None = None
330
+ ) -> Self:
331
+ self.builder.select_avg(column, alias)
332
+ return self
333
+
334
+ def select_min(
335
+ self, column: str, alias: str | None = None
336
+ ) -> Self:
337
+ self.builder.select_min(column, alias)
338
+ return self
339
+
340
+ def select_max(
341
+ self, column: str, alias: str | None = None
342
+ ) -> Self:
343
+ self.builder.select_max(column, alias)
344
+ return self
345
+
346
+ def distinct(self, *columns: str) -> Self:
347
+ """Apply DISTINCT or DISTINCT ON (columns) to the query projection."""
348
+ self.builder.distinct(*columns)
349
+ return self
350
+
351
+ def order_by(self, column: str, direction: str = "ASC") -> Self:
352
+ self.builder.order_by(column, direction)
353
+ return self
354
+
355
+ def latest(self, column: str = "created_at") -> Self:
356
+ """Order records descending by the specified column (default: created_at)."""
357
+ self.builder.latest(column)
358
+ return self
359
+
360
+ def oldest(self, column: str = "created_at") -> Self:
361
+ """Order records ascending by the specified column (default: created_at)."""
362
+ self.builder.oldest(column)
363
+ return self
364
+
365
+ def in_random_order(self) -> Self:
366
+ """Order records randomly using PostgreSQL RANDOM()."""
367
+ self.builder.in_random_order()
368
+ return self
369
+
370
+ def when(
371
+ self,
372
+ condition: Any,
373
+ callback: Callable[[Self, Any], Any],
374
+ default: Callable[[Self, Any], Any] | None = None,
375
+ ) -> Self:
376
+ """Apply callback if condition is truthy, otherwise apply default callback if provided."""
377
+ if condition:
378
+ res = callback(self, condition)
379
+ return res if isinstance(res, self.__class__) else self
380
+ elif default is not None:
381
+ res = default(self, condition)
382
+ return res if isinstance(res, self.__class__) else self
383
+ return self
384
+
385
+ def unless(
386
+ self,
387
+ condition: Any,
388
+ callback: Callable[[Self, Any], Any],
389
+ default: Callable[[Self, Any], Any] | None = None,
390
+ ) -> Self:
391
+ """Apply callback if condition is falsy, otherwise apply default callback if provided."""
392
+ return self.when(not condition, callback, default)
393
+
394
+ def select(self, *columns: str) -> Self:
395
+ self.builder.select(*columns)
396
+ return self
397
+
398
+ async def count(self, column: str = "*") -> int:
399
+ return await self.builder.count(column)
400
+
401
+ async def exists(self) -> bool:
402
+ return await self.builder.exists()
403
+
404
+ async def sum(self, column: str) -> Any:
405
+ return await self.builder.sum(column)
406
+
407
+ async def min(self, column: str) -> Any:
408
+ return await self.builder.min(column)
409
+
410
+ async def max(self, column: str) -> Any:
411
+ return await self.builder.max(column)
412
+
413
+ async def avg(self, column: str) -> Any:
414
+ return await self.builder.avg(column)
415
+
416
+ async def get(self) -> list[T]:
417
+ rows = await self.builder.get()
418
+ instances: list[T] = []
419
+
420
+ for row in rows:
421
+ inst = self.model_cls._hydrate(row)
422
+ instances.append(inst)
423
+
424
+ if self._eager_loads and instances:
425
+ await self._load_relations(instances)
426
+
427
+ return instances
428
+
429
+ async def first(self) -> T | None:
430
+ res = await self.clone().limit(1).get()
431
+ return res[0] if res else None
432
+
433
+ async def first_or_fail(self) -> T:
434
+ """Fetch the first record or raise ModelNotFoundError if no record matches."""
435
+ instance = await self.first()
436
+ if instance is None:
437
+ raise ModelNotFoundError(model=self.model_cls)
438
+ return instance
439
+
440
+ async def value(self, column: str) -> Any | None:
441
+ """Fetch the value of a single column from the first matching row."""
442
+ return await self.builder.value(column)
443
+
444
+ @overload
445
+ async def pluck(self, column: str, key: None = None) -> list[Any]: ...
446
+
447
+ @overload
448
+ async def pluck(self, column: str, key: str) -> dict[Any, Any]: ...
449
+
450
+ async def pluck(
451
+ self, column: str, key: str | None = None
452
+ ) -> list[Any] | dict[Any, Any]:
453
+ """Retrieve an array or dictionary of values for a single column."""
454
+ return await self.builder.pluck(column, key=key)
455
+
456
+ async def increment(
457
+ self, column: str, amount: float = 1, extra: dict[str, Any] | None = None
458
+ ) -> str:
459
+ """Atomically increment a column in matching database rows."""
460
+ return await self.builder.increment(column, amount, extra=extra)
461
+
462
+ async def decrement(
463
+ self, column: str, amount: float = 1, extra: dict[str, Any] | None = None
464
+ ) -> str:
465
+ """Atomically decrement a column in matching database rows."""
466
+ return await self.builder.decrement(column, amount, extra=extra)
467
+
468
+ async def chunk(
469
+ self,
470
+ count: int,
471
+ callback: Callable[[list[T]], Awaitable[bool | None] | bool | None],
472
+ ) -> bool:
473
+ """
474
+ Process hydrated model records in batches of size count via limit and offset.
475
+ Returning False from callback terminates iteration early.
476
+ """
477
+ if isinstance(count, bool) or not isinstance(count, int) or count < 1:
478
+ raise ValueError("Chunk count must be an integer >= 1.")
479
+
480
+ base_query = self.clone()
481
+ base_query.builder._limit_value = None
482
+ base_query.builder._offset_value = None
483
+
484
+ page = 1
485
+ while True:
486
+ results = await base_query.clone().offset((page - 1) * count).limit(count).get()
487
+ if not results:
488
+ break
489
+
490
+ callback_res = callback(results)
491
+ if inspect.isawaitable(callback_res):
492
+ callback_res = await callback_res
493
+
494
+ if callback_res is False:
495
+ return False
496
+
497
+ if len(results) < count:
498
+ break
499
+ page += 1
500
+
501
+ return True
502
+
503
+ async def chunk_by_id(
504
+ self,
505
+ count: int,
506
+ callback: Callable[[list[T]], Awaitable[bool | None] | bool | None],
507
+ column: str | None = None,
508
+ ) -> bool:
509
+ """
510
+ Process hydrated model records in batches ordered by ID, advancing past the last observed ID.
511
+ Safe against record updates/deletions during iteration.
512
+ """
513
+ if isinstance(count, bool) or not isinstance(count, int) or count < 1:
514
+ raise ValueError("Chunk count must be an integer >= 1.")
515
+
516
+ target_column = self.model_cls.__primary_key__ if column is None else column
517
+ col = validate_identifier(target_column, allow_dotted=False, allow_wildcard=False)
518
+ last_id: Any = None
519
+ base_query = self.clone()
520
+ base_query.builder._limit_value = None
521
+ base_query.builder._offset_value = None
522
+
523
+ while True:
524
+ query = base_query.clone().order_by(col, "ASC").limit(count)
525
+ if last_id is not None:
526
+ query = query.where(col, ">", last_id)
527
+
528
+ results = await query.get()
529
+ if not results:
530
+ break
531
+
532
+ last_record = results[-1]
533
+ if not hasattr(last_record, col) and col not in last_record.get_attributes():
534
+ raise QueryError(
535
+ f"Column '{col}' not present on model for chunk_by_id cursor advancement."
536
+ )
537
+ last_id = getattr(last_record, col, None)
538
+
539
+ callback_res = callback(results)
540
+ if inspect.isawaitable(callback_res):
541
+ callback_res = await callback_res
542
+
543
+ if callback_res is False:
544
+ return False
545
+
546
+ if len(results) < count:
547
+ break
548
+
549
+ return True
550
+
551
+ async def upsert(
552
+ self,
553
+ values: list[dict[str, Any]],
554
+ unique_by: list[str] | str,
555
+ update_columns: list[str] | None = None,
556
+ ) -> int:
557
+ """Perform a PostgreSQL INSERT ... ON CONFLICT (unique_by) DO UPDATE / DO NOTHING."""
558
+ return await self.builder.upsert(values, unique_by, update_columns=update_columns)
559
+
560
+ async def paginate(
561
+ self, per_page: int = 15, page: int = 1
562
+ ) -> Paginator[T]:
563
+ """
564
+ Execute the model query with length-aware pagination, counting total records/groups.
565
+ """
566
+ if isinstance(page, bool) or not isinstance(page, int):
567
+ raise TypeError("page must be an integer.")
568
+ if page < 1:
569
+ raise ValueError("page must be >= 1.")
570
+ if isinstance(per_page, bool) or not isinstance(per_page, int):
571
+ raise TypeError("per_page must be an integer.")
572
+ if per_page < 1:
573
+ raise ValueError("per_page must be >= 1.")
574
+
575
+ offset = (page - 1) * per_page
576
+
577
+ count_builder = self.clone()
578
+ count_builder.builder._limit_value = None
579
+ count_builder.builder._offset_value = None
580
+ count_builder.builder._order_column = None
581
+
582
+ if (
583
+ count_builder.builder._groups
584
+ or count_builder.builder._havings
585
+ or count_builder.builder._raw_havings
586
+ ):
587
+ if (
588
+ count_builder.builder._columns == ["*"]
589
+ and count_builder.builder._groups
590
+ ):
591
+ count_builder.builder._columns = list(count_builder.builder._groups)
592
+
593
+ inner_sql, inner_bindings = count_builder.builder.to_sql(1)
594
+ count_sql = f"SELECT COUNT(*) AS total FROM ({inner_sql}) AS grouped_results"
595
+ conn = await count_builder.builder._connection()
596
+ try:
597
+ val = await conn.fetchval(count_sql, *inner_bindings)
598
+ total = int(val or 0)
599
+ finally:
600
+ await count_builder.builder._release(conn)
601
+ else:
602
+ total = await count_builder.count()
603
+
604
+ data_builder = self.clone()
605
+ data_builder.limit(per_page).offset(offset)
606
+ items = await data_builder.get()
607
+
608
+ last_page = max(1, math.ceil(total / per_page))
609
+ return Paginator(
610
+ items=items,
611
+ total=total,
612
+ per_page=per_page,
613
+ current_page=page,
614
+ last_page=last_page,
615
+ )
616
+
617
+ async def simple_paginate(
618
+ self, per_page: int = 15, page: int = 1
619
+ ) -> SimplePaginator[T]:
620
+ """
621
+ Execute the model query with lightweight pagination without counting total records.
622
+ """
623
+ if isinstance(page, bool) or not isinstance(page, int):
624
+ raise TypeError("page must be an integer.")
625
+ if page < 1:
626
+ raise ValueError("page must be >= 1.")
627
+ if isinstance(per_page, bool) or not isinstance(per_page, int):
628
+ raise TypeError("per_page must be an integer.")
629
+ if per_page < 1:
630
+ raise ValueError("per_page must be >= 1.")
631
+
632
+ offset = (page - 1) * per_page
633
+
634
+ data_builder = self.clone()
635
+ data_builder.limit(per_page + 1).offset(offset)
636
+ raw_items = await data_builder.get()
637
+
638
+ has_more = len(raw_items) > per_page
639
+ items = raw_items[:per_page] if has_more else raw_items
640
+
641
+ return SimplePaginator(
642
+ items=items,
643
+ per_page=per_page,
644
+ current_page=page,
645
+ has_more=has_more,
646
+ )
647
+
648
+ async def insert_returning(
649
+ self,
650
+ data: dict[str, Any],
651
+ *,
652
+ returning: Sequence[str] | str | None = None,
653
+ allow_default_values: bool = False,
654
+ ) -> dict[str, Any]:
655
+ return await self.builder.insert_returning(
656
+ data, returning=returning, allow_default_values=allow_default_values
657
+ )
658
+
659
+ @overload
660
+ async def insert(self, data: dict[str, Any], *, returning: None = None) -> int: ...
661
+
662
+ @overload
663
+ async def insert(
664
+ self, data: dict[str, Any], *, returning: Sequence[str] | str
665
+ ) -> Any: ...
666
+
667
+ async def insert(
668
+ self,
669
+ data: dict[str, Any],
670
+ *,
671
+ returning: Sequence[str] | str | None = None,
672
+ ) -> Any:
673
+ return await self.builder.insert(data, returning=returning)
674
+
675
+ async def update(self, data: dict[str, Any]) -> str:
676
+ return await self.builder.update(data)
677
+
678
+ async def delete(self) -> str:
679
+ return await self.builder.delete()
680
+
681
+ def __await__(self) -> Generator[Any, None, list[T]]:
682
+ return self.get().__await__()
683
+
684
+ async def _load_relations(self, instances: list[T]) -> None:
685
+ if not instances or not self._eager_loads:
686
+ return
687
+
688
+ # Group eager loads by top-level relation name
689
+ tree: dict[str, list[str]] = {}
690
+ for path in self._eager_loads:
691
+ parts = path.split(".", 1)
692
+ top = parts[0]
693
+ sub = parts[1] if len(parts) > 1 else None
694
+ tree.setdefault(top, [])
695
+ if sub:
696
+ tree[top].append(sub)
697
+
698
+ for top_name, sub_paths in tree.items():
699
+ relation = self.model_cls.__relations_config__.get(top_name)
700
+ if not relation:
701
+ raise ValueError(
702
+ f"Relation '{top_name}' not found on model {self.model_cls.__name__}"
703
+ )
704
+ await relation.eager_load(instances)
705
+
706
+ if sub_paths:
707
+ related_items: list[Model] = []
708
+ for inst in instances:
709
+ val = inst._loaded_relations.get(top_name)
710
+ if isinstance(val, list):
711
+ related_items.extend(
712
+ item for item in val if isinstance(item, Model)
713
+ )
714
+ elif isinstance(val, Model):
715
+ related_items.append(val)
716
+
717
+ if related_items:
718
+ target_model = relation.get_target_model()
719
+ sub_builder = target_model.query()
720
+ sub_builder._eager_loads = sub_paths
721
+ await sub_builder._load_relations(related_items)
722
+
723
+
724
+ def _validate_model_vector(field_name: str, value: Any, dimensions: int) -> list[float]:
725
+ if not isinstance(value, (list, tuple)):
726
+ raise TypeError(f"Field '{field_name}' must be a list of floats, got {type(value).__name__}")
727
+ if len(value) != dimensions:
728
+ raise ValueError(
729
+ f"Field '{field_name}' vector dimension {len(value)} does not match expected {dimensions}"
730
+ )
731
+ parsed: list[float] = []
732
+ for idx, x in enumerate(value):
733
+ if not isinstance(x, (int, float)) or isinstance(x, bool):
734
+ raise TypeError(f"Field '{field_name}' element at index {idx} must be a number, got {type(x).__name__}")
735
+ fx = float(x)
736
+ if math.isnan(fx) or math.isinf(fx):
737
+ raise ValueError(f"Field '{field_name}' element at index {idx} is NaN or Infinite.")
738
+ parsed.append(fx)
739
+ return parsed
740
+
741
+
742
+ class Model:
743
+ """Active Record base model with typed attributes, dirty tracking, and relationship support."""
744
+
745
+ __table__: ClassVar[str]
746
+ __primary_key__: ClassVar[str] = "id"
747
+ __fillable__: ClassVar[set[str] | list[str] | tuple[str, ...] | None] = None
748
+ __guarded__: ClassVar[set[str] | list[str] | tuple[str, ...] | None] = None
749
+ __hidden__: ClassVar[set[str] | list[str] | tuple[str, ...]] = ()
750
+ strict_events: ClassVar[bool] = False
751
+ prevent_lazy_loading: ClassVar[bool] = False
752
+
753
+ __fields__: ClassVar[dict[str, Any]]
754
+ __field_defaults__: ClassVar[dict[str, Any]]
755
+ __field_factories__: ClassVar[dict[str, Any]]
756
+ __vector_fields__: ClassVar[dict[str, VectorFieldInfo]]
757
+ __relations_config__: ClassVar[dict[str, Relation[Any]]]
758
+
759
+ def __init_subclass__(cls, **kwargs: Any) -> None:
760
+ super().__init_subclass__(**kwargs)
761
+
762
+ # Validate primary key declaration (composite keys are explicitly unsupported)
763
+ raw_pk = getattr(cls, "__primary_key__", "id")
764
+ if not isinstance(raw_pk, str) or "," in raw_pk or not raw_pk.strip():
765
+ raise PrimaryKeyError(
766
+ f"Composite or non-string primary keys are not supported on {cls.__name__}; "
767
+ "__primary_key__ must be a single valid column identifier."
768
+ )
769
+ cls.__primary_key__ = validate_identifier(raw_pk, param_name="__primary_key__")
770
+
771
+ # Determine table name if not explicitly set (overridable via __table__)
772
+ if "__table__" not in cls.__dict__:
773
+ cls.__table__ = pluralize(snake_case(cls.__name__))
774
+
775
+ # Inherit fields from parent Model classes
776
+ fields: dict[str, Any] = {}
777
+ defaults: dict[str, Any] = {}
778
+ factories: dict[str, Any] = {}
779
+
780
+ for base in reversed(cls.__mro__[1:]):
781
+ fields.update(getattr(base, "__fields__", {}))
782
+ defaults.update(getattr(base, "__field_defaults__", {}))
783
+ factories.update(getattr(base, "__field_factories__", {}))
784
+
785
+ # Resolve typed class attributes using typing.get_type_hints
786
+ globalns = getattr(inspect.getmodule(cls), "__dict__", {})
787
+ localns: dict[str, Any] = {
788
+ name: next(reversed(variants.values()))
789
+ for name, variants in _MODEL_REGISTRY.items()
790
+ }
791
+ localns[cls.__name__] = cls
792
+
793
+ try:
794
+ resolved_hints = get_type_hints(cls, globalns=globalns, localns=localns)
795
+ except (NameError, TypeError):
796
+ resolved_hints = inspect.get_annotations(cls, eval_str=False)
797
+
798
+ for name, hint in resolved_hints.items():
799
+ if name.startswith("__"):
800
+ continue
801
+ if get_origin(hint) is ClassVar:
802
+ continue
803
+
804
+ fields[name] = hint
805
+ if name in cls.__dict__:
806
+ val = cls.__dict__[name]
807
+ if isinstance(val, VectorFieldInfo):
808
+ pass
809
+ elif isinstance(val, FieldInfo):
810
+ if val.primary_key:
811
+ cls.__primary_key__ = name
812
+ if val.default_factory is not None:
813
+ factories[name] = val.default_factory
814
+ elif val.default is not _MISSING:
815
+ defaults[name] = val.default
816
+ elif callable(val) and not isinstance(val, (classmethod, staticmethod, property)):
817
+ factories[name] = val
818
+ else:
819
+ defaults[name] = val
820
+
821
+ for name, val in cls.__dict__.items():
822
+ if name.startswith("__") or name in fields:
823
+ continue
824
+ if isinstance(val, VectorFieldInfo):
825
+ fields[name] = list[float]
826
+ elif isinstance(val, FieldInfo):
827
+ fields[name] = Any
828
+ if val.primary_key:
829
+ cls.__primary_key__ = name
830
+ if val.default_factory is not None:
831
+ factories[name] = val.default_factory
832
+ elif val.default is not _MISSING:
833
+ defaults[name] = val.default
834
+
835
+ # Guarantee primary key field presence
836
+ if cls.__primary_key__ not in fields:
837
+ fields[cls.__primary_key__] = int
838
+
839
+ cls.__fields__ = fields
840
+ cls.__field_defaults__ = defaults
841
+ cls.__field_factories__ = factories
842
+
843
+ # Discover Vector fields
844
+ vectors: dict[str, VectorFieldInfo] = {}
845
+ for base in reversed(cls.__mro__[1:]):
846
+ vectors.update(getattr(base, "__vector_fields__", {}))
847
+ for name, val in cls.__dict__.items():
848
+ if isinstance(val, VectorFieldInfo):
849
+ vectors[name] = val
850
+ cls.__vector_fields__ = vectors
851
+
852
+ # Inherit relations from ancestors and discover newly declared relations
853
+ relations: dict[str, Relation[Any]] = {}
854
+ for base in reversed(cls.__mro__[1:]):
855
+ relations.update(getattr(base, "__relations_config__", {}))
856
+
857
+ for name, value in list(cls.__dict__.items()):
858
+ if isinstance(value, Relation):
859
+ value.name = name
860
+ value.owner_cls = cls
861
+ relations[name] = value
862
+ setattr(cls, name, RelationDescriptor(name, value))
863
+
864
+ cls.__relations_config__ = relations
865
+
866
+ # Register model globally under bare name and fully qualified name
867
+ qualname = f"{cls.__module__}.{cls.__qualname__}"
868
+ _MODEL_REGISTRY.setdefault(cls.__name__, {})[qualname] = cls
869
+
870
+ def fill(self, **attributes: Any) -> Self:
871
+ """Mass-assign attributes to the model instance after validating mass assignment rules."""
872
+ guarded = getattr(self, "__guarded__", None)
873
+ fillable = getattr(self, "__fillable__", None)
874
+
875
+ if guarded is None and fillable is None:
876
+ raise MassAssignmentError(
877
+ f"Model '{self.__class__.__name__}' has no __fillable__ or __guarded__ declared; "
878
+ "mass assignment is denied by default (Decision D-4). "
879
+ "Declare __fillable__ or set __guarded__ = []."
880
+ )
881
+
882
+ for key, val in attributes.items():
883
+ if key.startswith("_"):
884
+ raise MassAssignmentError(
885
+ f"Cannot mass-assign private attribute '{key}'."
886
+ )
887
+ if key not in self.__fields__ and key not in getattr(self, "__relations_config__", {}):
888
+ raise MassAssignmentError(
889
+ f"Unknown attribute '{key}' for model '{self.__class__.__name__}'."
890
+ )
891
+
892
+ if guarded is not None:
893
+ if key in guarded:
894
+ raise MassAssignmentError(
895
+ f"Attribute '{key}' is guarded on model '{self.__class__.__name__}'."
896
+ )
897
+ setattr(self, key, val)
898
+ self._init_keys.add(key)
899
+ elif fillable is not None:
900
+ if key in fillable or key == self.__primary_key__:
901
+ setattr(self, key, val)
902
+ self._init_keys.add(key)
903
+ # Unlisted fields in fillable are omitted/ignored
904
+ return self
905
+
906
+ def force_fill(self, **attributes: Any) -> Self:
907
+ """Mass-assign attributes bypassing fillable/guarded rules (still checks private/unknown)."""
908
+ for key, val in attributes.items():
909
+ if key.startswith("_"):
910
+ raise MassAssignmentError(
911
+ f"Cannot mass-assign private attribute '{key}'."
912
+ )
913
+ if key not in self.__fields__ and key not in getattr(self, "__relations_config__", {}):
914
+ raise MassAssignmentError(
915
+ f"Unknown attribute '{key}' for model '{self.__class__.__name__}'."
916
+ )
917
+ setattr(self, key, val)
918
+ self._init_keys.add(key)
919
+ return self
920
+
921
+ @classmethod
922
+ def _hydrate(cls, row: dict[str, Any]) -> Self:
923
+ """Hydrate a model instance from raw database columns without mass-assignment checks."""
924
+ inst = cls.__new__(cls)
925
+ inst._loaded_relations = {}
926
+ inst._exists = True
927
+ inst._original = {}
928
+ inst._init_keys = set(row.keys())
929
+ inst._modified_keys = set()
930
+ inst._temp_visible = set()
931
+ inst._temp_hidden = set()
932
+
933
+ for name in cls.__fields__:
934
+ if name in row:
935
+ setattr(inst, name, cls._coerce_db_value(name, row[name]))
936
+ elif name in cls.__field_factories__:
937
+ setattr(inst, name, cls.__field_factories__[name]())
938
+ elif name in cls.__field_defaults__:
939
+ setattr(inst, name, copy.deepcopy(cls.__field_defaults__[name]))
940
+ else:
941
+ setattr(inst, name, None)
942
+
943
+ for name, val in row.items():
944
+ if name not in cls.__fields__:
945
+ setattr(inst, name, val)
946
+
947
+ inst._mark_persisted(row.keys())
948
+ return inst
949
+
950
+ def __init__(self, **kwargs: Any) -> None:
951
+ self._loaded_relations: dict[str, Sequence[Model] | Model | None] = {}
952
+ self._exists: bool = False
953
+ self._original: dict[str, Any] = {}
954
+ self._init_keys: set[str] = set()
955
+ self._modified_keys: set[str] = set()
956
+ self._temp_visible: set[str] = set()
957
+ self._temp_hidden: set[str] = set()
958
+
959
+ for name in self.__fields__:
960
+ if name in self.__field_factories__:
961
+ setattr(self, name, self.__field_factories__[name]())
962
+ elif name in self.__field_defaults__:
963
+ setattr(self, name, copy.deepcopy(self.__field_defaults__[name]))
964
+ else:
965
+ setattr(self, name, None)
966
+
967
+ if kwargs:
968
+ self.fill(**kwargs)
969
+
970
+ def __setattr__(self, name: str, value: Any) -> None:
971
+ if hasattr(self, "__vector_fields__") and name in self.__vector_fields__ and value is not None:
972
+ v_info = self.__vector_fields__[name]
973
+ value = _validate_model_vector(name, value, v_info.dimensions)
974
+ if (
975
+ hasattr(self, "_exists")
976
+ and self._exists
977
+ and name in getattr(self, "__fields__", {})
978
+ ):
979
+ orig = getattr(self, "_original", None)
980
+ if orig is not None and name not in orig:
981
+ if not hasattr(self, "_modified_keys"):
982
+ self._modified_keys = set()
983
+ self._modified_keys.add(name)
984
+ super().__setattr__(name, value)
985
+
986
+ def _mark_persisted(self, keys: Iterable[str]) -> None:
987
+ """Mark the instance as persisted and record deep-copied original attribute values for dirty checking."""
988
+ self._exists = True
989
+ self._original = {
990
+ k: copy.deepcopy(getattr(self, k))
991
+ for k in keys
992
+ if k in self.__fields__ and hasattr(self, k)
993
+ }
994
+ if hasattr(self, "_modified_keys"):
995
+ self._modified_keys.clear()
996
+
997
+ def is_dirty(self, *attributes: str) -> bool:
998
+ """Check if the model or specified attributes have changed since being persisted."""
999
+ if not self._exists:
1000
+ return bool(self._init_keys or attributes)
1001
+ dirty = self.get_dirty()
1002
+ if not attributes:
1003
+ return bool(dirty)
1004
+ return any(attr in dirty for attr in attributes)
1005
+
1006
+ def get_dirty(self) -> dict[str, Any]:
1007
+ """Return a mapping of attributes that have changed since being persisted to their current values."""
1008
+ if not self._exists:
1009
+ return {
1010
+ k: getattr(self, k)
1011
+ for k in self.__fields__
1012
+ if hasattr(self, k) and getattr(self, k) is not None
1013
+ }
1014
+
1015
+ dirty: dict[str, Any] = {}
1016
+ for name, original in self._original.items():
1017
+ if name == self.__primary_key__:
1018
+ continue
1019
+ if not hasattr(self, name):
1020
+ continue
1021
+ current_val = getattr(self, name)
1022
+ if current_val != original:
1023
+ dirty[name] = current_val
1024
+
1025
+ for name in getattr(self, "_modified_keys", ()):
1026
+ if name == self.__primary_key__:
1027
+ continue
1028
+ if hasattr(self, name):
1029
+ dirty[name] = getattr(self, name)
1030
+
1031
+ return dirty
1032
+
1033
+ @classmethod
1034
+ def _coerce_db_value(cls, field_name: str, value: Any) -> Any:
1035
+ """Coerce database values such as JSON strings back to typed Python structures if needed."""
1036
+ if field_name in getattr(cls, "__vector_fields__", {}):
1037
+ if value is None:
1038
+ return None
1039
+ if isinstance(value, str):
1040
+ clean_s = value.strip("[] \t\n\r")
1041
+ if not clean_s:
1042
+ return []
1043
+ try:
1044
+ return [float(x.strip()) for x in clean_s.split(",") if x.strip()]
1045
+ except (ValueError, TypeError):
1046
+ return value
1047
+ if isinstance(value, (list, tuple)):
1048
+ return [float(x) for x in value]
1049
+ return value
1050
+
1051
+ if isinstance(value, str) and field_name in cls.__fields__:
1052
+ hint = cls.__fields__[field_name]
1053
+ origin = get_origin(hint) or hint
1054
+ is_json_type = (
1055
+ origin in (dict, list)
1056
+ or (
1057
+ isinstance(hint, str)
1058
+ and hint.startswith(("dict", "list"))
1059
+ )
1060
+ )
1061
+ if is_json_type:
1062
+ try:
1063
+ return json.loads(value)
1064
+ except (ValueError, TypeError):
1065
+ return value
1066
+ return value
1067
+
1068
+ @classmethod
1069
+ def _persistable(cls, value: Any, field_name: str | None = None) -> Any:
1070
+ if isinstance(value, Model):
1071
+ return value.get_primary_key()
1072
+ vector_fields = getattr(cls, "__vector_fields__", {})
1073
+ is_vector = (
1074
+ field_name in vector_fields
1075
+ if field_name is not None
1076
+ else bool(vector_fields)
1077
+ )
1078
+ if (
1079
+ is_vector
1080
+ and isinstance(value, list)
1081
+ and value
1082
+ and all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in value)
1083
+ ):
1084
+ return f"[{','.join(str(float(x)) for x in value)}]"
1085
+ if isinstance(value, (dict, list)):
1086
+ return json.dumps(value)
1087
+ return value
1088
+
1089
+ @classmethod
1090
+ def query(cls) -> ModelQueryBuilder[Self]:
1091
+ """Return a new query builder for this model."""
1092
+ return ModelQueryBuilder(cls)
1093
+
1094
+ @classmethod
1095
+ async def find(cls, id: Any) -> Self | None:
1096
+ """Find a model instance by its primary key."""
1097
+ if id is None:
1098
+ return None
1099
+ return await cls.where(cls.__primary_key__, id).first()
1100
+
1101
+ @classmethod
1102
+ async def find_or_fail(cls, id: Any) -> Self:
1103
+ """Find a model instance by its primary key or raise ModelNotFoundError."""
1104
+ if id is None:
1105
+ raise ValueError("Primary key lookup value cannot be None.")
1106
+ instance = await cls.find(id)
1107
+ if instance is None:
1108
+ raise ModelNotFoundError(model=cls, primary_key=id)
1109
+ return instance
1110
+
1111
+ @classmethod
1112
+ async def first(cls) -> Self | None:
1113
+ """Fetch the first record matching the query, or None."""
1114
+ return await cls.query().first()
1115
+
1116
+ @classmethod
1117
+ async def first_or_fail(cls) -> Self:
1118
+ """Fetch the first record or raise ModelNotFoundError if not found."""
1119
+ return await cls.query().first_or_fail()
1120
+
1121
+ @classmethod
1122
+ async def count(cls, column: str = "*") -> int:
1123
+ """Count the records matching the model query."""
1124
+ return await cls.query().count(column)
1125
+
1126
+ @classmethod
1127
+ async def exists(cls) -> bool:
1128
+ """Check if any records match the model query."""
1129
+ return await cls.query().exists()
1130
+
1131
+ @classmethod
1132
+ async def sum(cls, column: str) -> Any:
1133
+ return await cls.query().sum(column)
1134
+
1135
+ @classmethod
1136
+ async def avg(cls, column: str) -> Any:
1137
+ return await cls.query().avg(column)
1138
+
1139
+ @classmethod
1140
+ async def min(cls, column: str) -> Any:
1141
+ return await cls.query().min(column)
1142
+
1143
+ @classmethod
1144
+ async def max(cls, column: str) -> Any:
1145
+ return await cls.query().max(column)
1146
+
1147
+ @classmethod
1148
+ def select(cls, *columns: str) -> ModelQueryBuilder[Self]:
1149
+ return cls.query().select(*columns)
1150
+
1151
+ @classmethod
1152
+ def select_raw(
1153
+ cls, sql: str, bindings: list[Any] | None = None
1154
+ ) -> ModelQueryBuilder[Self]:
1155
+ return cls.query().select_raw(sql, bindings)
1156
+
1157
+ @classmethod
1158
+ def distinct(cls, *columns: str) -> ModelQueryBuilder[Self]:
1159
+ return cls.query().distinct(*columns)
1160
+
1161
+ @classmethod
1162
+ def where(
1163
+ cls, column: str, operator_or_value: Any, value: Any = _MISSING
1164
+ ) -> ModelQueryBuilder[Self]:
1165
+ """Add a basic where clause to the query."""
1166
+ return cls.query().where(column, operator_or_value, value)
1167
+
1168
+ @classmethod
1169
+ def or_where(
1170
+ cls, column: str, operator_or_value: Any, value: Any = _MISSING
1171
+ ) -> ModelQueryBuilder[Self]:
1172
+ """Add an OR WHERE clause to the query."""
1173
+ if value is _MISSING:
1174
+ return cls.query().or_where(column, operator_or_value)
1175
+ return cls.query().or_where(column, operator_or_value, value)
1176
+
1177
+ @classmethod
1178
+ def where_group(
1179
+ cls,
1180
+ callback: Callable[[ModelQueryBuilder[Self]], Any],
1181
+ *,
1182
+ boolean: str = "AND",
1183
+ ) -> ModelQueryBuilder[Self]:
1184
+ """Add a nested WHERE group with explicit parentheses to the query."""
1185
+ return cls.query().where_group(callback, boolean=boolean)
1186
+
1187
+ @classmethod
1188
+ def or_where_group(
1189
+ cls,
1190
+ callback: Callable[[ModelQueryBuilder[Self]], Any],
1191
+ ) -> ModelQueryBuilder[Self]:
1192
+ """Add an OR nested WHERE group with explicit parentheses to the query."""
1193
+ return cls.query().or_where_group(callback)
1194
+
1195
+ @classmethod
1196
+ def where_in(
1197
+ cls, column: str, values: list[Any] | tuple[Any, ...] | QueryBuilder
1198
+ ) -> ModelQueryBuilder[Self]:
1199
+ """Add a WHERE IN clause to the query."""
1200
+ return cls.query().where_in(column, values)
1201
+
1202
+ @classmethod
1203
+ def or_where_in(
1204
+ cls, column: str, values: list[Any] | tuple[Any, ...] | QueryBuilder
1205
+ ) -> ModelQueryBuilder[Self]:
1206
+ """Add an OR WHERE IN clause to the query."""
1207
+ return cls.query().or_where_in(column, values)
1208
+
1209
+ @classmethod
1210
+ def where_not_in(
1211
+ cls, column: str, values: list[Any] | tuple[Any, ...] | QueryBuilder
1212
+ ) -> ModelQueryBuilder[Self]:
1213
+ """Add a WHERE NOT IN clause to the query."""
1214
+ return cls.query().where_not_in(column, values)
1215
+
1216
+ @classmethod
1217
+ def or_where_not_in(
1218
+ cls, column: str, values: list[Any] | tuple[Any, ...] | QueryBuilder
1219
+ ) -> ModelQueryBuilder[Self]:
1220
+ """Add an OR WHERE NOT IN clause to the query."""
1221
+ return cls.query().or_where_not_in(column, values)
1222
+
1223
+ @classmethod
1224
+ def where_like(
1225
+ cls,
1226
+ column: str,
1227
+ value: str,
1228
+ *,
1229
+ case_insensitive: bool = False,
1230
+ match: str = "contains",
1231
+ boolean: str = "AND",
1232
+ ) -> ModelQueryBuilder[Self]:
1233
+ return cls.query().where_like(
1234
+ column,
1235
+ value,
1236
+ case_insensitive=case_insensitive,
1237
+ match=match,
1238
+ boolean=boolean,
1239
+ )
1240
+
1241
+ @classmethod
1242
+ def or_where_like(
1243
+ cls,
1244
+ column: str,
1245
+ value: str,
1246
+ *,
1247
+ case_insensitive: bool = False,
1248
+ match: str = "contains",
1249
+ ) -> ModelQueryBuilder[Self]:
1250
+ return cls.query().or_where_like(
1251
+ column, value, case_insensitive=case_insensitive, match=match
1252
+ )
1253
+
1254
+ @classmethod
1255
+ def where_ilike(
1256
+ cls,
1257
+ column: str,
1258
+ value: str,
1259
+ *,
1260
+ match: str = "contains",
1261
+ boolean: str = "AND",
1262
+ ) -> ModelQueryBuilder[Self]:
1263
+ return cls.query().where_ilike(column, value, match=match, boolean=boolean)
1264
+
1265
+ @classmethod
1266
+ def or_where_ilike(
1267
+ cls,
1268
+ column: str,
1269
+ value: str,
1270
+ *,
1271
+ match: str = "contains",
1272
+ ) -> ModelQueryBuilder[Self]:
1273
+ return cls.query().or_where_ilike(column, value, match=match)
1274
+
1275
+ @classmethod
1276
+ def where_between(
1277
+ cls, column: str, values: Sequence[Any], *, boolean: str = "AND"
1278
+ ) -> ModelQueryBuilder[Self]:
1279
+ return cls.query().where_between(column, values, boolean=boolean)
1280
+
1281
+ @classmethod
1282
+ def where_not_between(
1283
+ cls, column: str, values: Sequence[Any], *, boolean: str = "AND"
1284
+ ) -> ModelQueryBuilder[Self]:
1285
+ return cls.query().where_not_between(column, values, boolean=boolean)
1286
+
1287
+ @classmethod
1288
+ def or_where_between(
1289
+ cls, column: str, values: Sequence[Any]
1290
+ ) -> ModelQueryBuilder[Self]:
1291
+ return cls.query().or_where_between(column, values)
1292
+
1293
+ @classmethod
1294
+ def or_where_not_between(
1295
+ cls, column: str, values: Sequence[Any]
1296
+ ) -> ModelQueryBuilder[Self]:
1297
+ return cls.query().or_where_not_between(column, values)
1298
+
1299
+ @classmethod
1300
+ def where_null(cls, column: str) -> ModelQueryBuilder[Self]:
1301
+ """Add a where null clause to the query."""
1302
+ return cls.query().where_null(column)
1303
+
1304
+ @classmethod
1305
+ def or_where_null(cls, column: str) -> ModelQueryBuilder[Self]:
1306
+ """Add an OR where null clause to the query."""
1307
+ return cls.query().or_where_null(column)
1308
+
1309
+ @classmethod
1310
+ def where_not_null(cls, column: str) -> ModelQueryBuilder[Self]:
1311
+ """Add a where not null clause to the query."""
1312
+ return cls.query().where_not_null(column)
1313
+
1314
+ @classmethod
1315
+ def or_where_not_null(cls, column: str) -> ModelQueryBuilder[Self]:
1316
+ """Add an OR where not null clause to the query."""
1317
+ return cls.query().or_where_not_null(column)
1318
+
1319
+ @classmethod
1320
+ def where_raw(
1321
+ cls, sql: str, bindings: Sequence[Any] | None = None
1322
+ ) -> ModelQueryBuilder[Self]:
1323
+ """Add a raw SQL where clause."""
1324
+ return cls.query().where_raw(sql, bindings)
1325
+
1326
+ @classmethod
1327
+ def or_where_raw(
1328
+ cls, sql: str, bindings: Sequence[Any] | None = None
1329
+ ) -> ModelQueryBuilder[Self]:
1330
+ """Add an OR raw SQL where clause."""
1331
+ return cls.query().or_where_raw(sql, bindings)
1332
+
1333
+ @classmethod
1334
+ def latest(cls, column: str = "created_at") -> ModelQueryBuilder[Self]:
1335
+ """Order records descending by the specified column (default: created_at)."""
1336
+ return cls.query().latest(column)
1337
+
1338
+ @classmethod
1339
+ def oldest(cls, column: str = "created_at") -> ModelQueryBuilder[Self]:
1340
+ """Order records ascending by the specified column (default: created_at)."""
1341
+ return cls.query().oldest(column)
1342
+
1343
+ @classmethod
1344
+ def in_random_order(cls) -> ModelQueryBuilder[Self]:
1345
+ """Order records randomly using PostgreSQL RANDOM()."""
1346
+ return cls.query().in_random_order()
1347
+
1348
+ @classmethod
1349
+ def when(
1350
+ cls,
1351
+ condition: Any,
1352
+ callback: Callable[[ModelQueryBuilder[Self], Any], Any],
1353
+ default: Callable[[ModelQueryBuilder[Self], Any], Any] | None = None,
1354
+ ) -> ModelQueryBuilder[Self]:
1355
+ return cls.query().when(condition, callback, default)
1356
+
1357
+ @classmethod
1358
+ def unless(
1359
+ cls,
1360
+ condition: Any,
1361
+ callback: Callable[[ModelQueryBuilder[Self], Any], Any],
1362
+ default: Callable[[ModelQueryBuilder[Self], Any], Any] | None = None,
1363
+ ) -> ModelQueryBuilder[Self]:
1364
+ return cls.query().unless(condition, callback, default)
1365
+
1366
+ @classmethod
1367
+ async def value(cls, column: str) -> Any | None:
1368
+ return await cls.query().value(column)
1369
+
1370
+ @overload
1371
+ @classmethod
1372
+ async def pluck(cls, column: str, key: None = None) -> list[Any]: ...
1373
+
1374
+ @overload
1375
+ @classmethod
1376
+ async def pluck(cls, column: str, key: str) -> dict[Any, Any]: ...
1377
+
1378
+ @classmethod
1379
+ async def pluck(
1380
+ cls, column: str, key: str | None = None
1381
+ ) -> list[Any] | dict[Any, Any]:
1382
+ return await cls.query().pluck(column, key=key)
1383
+
1384
+ @classmethod
1385
+ async def chunk(
1386
+ cls,
1387
+ count: int,
1388
+ callback: Callable[[list[Self]], Awaitable[bool | None] | bool | None],
1389
+ ) -> bool:
1390
+ return await cls.query().chunk(count, callback)
1391
+
1392
+ @classmethod
1393
+ async def chunk_by_id(
1394
+ cls,
1395
+ count: int,
1396
+ callback: Callable[[list[Self]], Awaitable[bool | None] | bool | None],
1397
+ column: str | None = None,
1398
+ ) -> bool:
1399
+ return await cls.query().chunk_by_id(count, callback, column=column)
1400
+
1401
+ @classmethod
1402
+ def join(
1403
+ cls,
1404
+ table: str,
1405
+ first: str,
1406
+ operator: str,
1407
+ second: str,
1408
+ type_: str = "INNER",
1409
+ ) -> ModelQueryBuilder[Self]:
1410
+ return cls.query().join(table, first, operator, second, type_=type_)
1411
+
1412
+ @classmethod
1413
+ def left_join(
1414
+ cls, table: str, first: str, operator: str, second: str
1415
+ ) -> ModelQueryBuilder[Self]:
1416
+ return cls.query().left_join(table, first, operator, second)
1417
+
1418
+ @classmethod
1419
+ def right_join(
1420
+ cls, table: str, first: str, operator: str, second: str
1421
+ ) -> ModelQueryBuilder[Self]:
1422
+ return cls.query().right_join(table, first, operator, second)
1423
+
1424
+ @classmethod
1425
+ async def first_or_create(
1426
+ cls,
1427
+ attributes: dict[str, Any],
1428
+ values: dict[str, Any] | None = None,
1429
+ ) -> Self:
1430
+ """Find the first matching record by attributes, or create it if not found."""
1431
+ query = cls.query()
1432
+ for k, v in attributes.items():
1433
+ query = query.where(k, v)
1434
+ instance = await query.first()
1435
+ if instance is not None:
1436
+ return instance
1437
+
1438
+ creation_data = dict(attributes)
1439
+ if values:
1440
+ creation_data.update(values)
1441
+ return await cls.create(creation_data)
1442
+
1443
+ @classmethod
1444
+ async def update_or_create(
1445
+ cls,
1446
+ attributes: dict[str, Any],
1447
+ values: dict[str, Any],
1448
+ ) -> Self:
1449
+ """Find the first matching record and update it with values, or create it if not found."""
1450
+ query = cls.query()
1451
+ for k, v in attributes.items():
1452
+ query = query.where(k, v)
1453
+ instance = await query.first()
1454
+ if instance is not None:
1455
+ instance.fill(**values)
1456
+ await instance.save()
1457
+ return instance
1458
+
1459
+ creation_data = dict(attributes)
1460
+ creation_data.update(values)
1461
+ return await cls.create(creation_data)
1462
+
1463
+ @classmethod
1464
+ async def upsert(
1465
+ cls,
1466
+ values: list[dict[str, Any]],
1467
+ unique_by: list[str] | str,
1468
+ update_columns: list[str] | None = None,
1469
+ ) -> int:
1470
+ return await cls.query().upsert(values, unique_by, update_columns=update_columns)
1471
+
1472
+ @classmethod
1473
+ def with_(cls, *relations: str) -> ModelQueryBuilder[Self]:
1474
+ """Eager-load specified relationships."""
1475
+ return cls.query().with_(*relations)
1476
+
1477
+ @classmethod
1478
+ async def all(cls) -> list[Self]:
1479
+ """Retrieve all model instances."""
1480
+ return await cls.query().get()
1481
+
1482
+ @classmethod
1483
+ def limit(cls, value: int) -> ModelQueryBuilder[Self]:
1484
+ """Set query result limit."""
1485
+ return cls.query().limit(value)
1486
+
1487
+ @classmethod
1488
+ def offset(cls, value: int) -> ModelQueryBuilder[Self]:
1489
+ """Set query result offset."""
1490
+ return cls.query().offset(value)
1491
+
1492
+ @classmethod
1493
+ def take(cls, value: int) -> ModelQueryBuilder[Self]:
1494
+ """Alias for limit()."""
1495
+ return cls.query().take(value)
1496
+
1497
+ @classmethod
1498
+ def skip(cls, value: int) -> ModelQueryBuilder[Self]:
1499
+ """Alias for offset()."""
1500
+ return cls.query().skip(value)
1501
+
1502
+ @classmethod
1503
+ def order_by(
1504
+ cls, column: str, direction: str = "ASC"
1505
+ ) -> ModelQueryBuilder[Self]:
1506
+ """Order the model query by a column."""
1507
+ return cls.query().order_by(column, direction)
1508
+
1509
+ @classmethod
1510
+ def group_by(cls, *columns: str | Sequence[str]) -> ModelQueryBuilder[Self]:
1511
+ """Add GROUP BY columns to the query."""
1512
+ return cls.query().group_by(*columns)
1513
+
1514
+ @classmethod
1515
+ def having(
1516
+ cls,
1517
+ column: str,
1518
+ operator_or_value: Any,
1519
+ value: Any = _MISSING,
1520
+ boolean: str = "AND",
1521
+ ) -> ModelQueryBuilder[Self]:
1522
+ """Add a HAVING condition to the model query."""
1523
+ return cls.query().having(column, operator_or_value, value, boolean=boolean)
1524
+
1525
+ @classmethod
1526
+ def or_having(
1527
+ cls, column: str, operator_or_value: Any, value: Any = _MISSING
1528
+ ) -> ModelQueryBuilder[Self]:
1529
+ """Add an OR HAVING condition to the model query."""
1530
+ return cls.query().or_having(column, operator_or_value, value)
1531
+
1532
+ @classmethod
1533
+ def having_raw(
1534
+ cls,
1535
+ sql: str,
1536
+ bindings: list[Any] | None = None,
1537
+ boolean: str = "AND",
1538
+ ) -> ModelQueryBuilder[Self]:
1539
+ """Add a raw HAVING condition to the model query."""
1540
+ return cls.query().having_raw(sql, bindings, boolean=boolean)
1541
+
1542
+ @classmethod
1543
+ def or_having_raw(
1544
+ cls, sql: str, bindings: list[Any] | None = None
1545
+ ) -> ModelQueryBuilder[Self]:
1546
+ """Add an OR raw HAVING condition to the model query."""
1547
+ return cls.query().or_having_raw(sql, bindings)
1548
+
1549
+ @classmethod
1550
+ async def paginate(
1551
+ cls, per_page: int = 15, page: int = 1
1552
+ ) -> Paginator[Self]:
1553
+ """Execute length-aware pagination on the model."""
1554
+ return await cls.query().paginate(per_page=per_page, page=page)
1555
+
1556
+ @classmethod
1557
+ async def simple_paginate(
1558
+ cls, per_page: int = 15, page: int = 1
1559
+ ) -> SimplePaginator[Self]:
1560
+ """Execute lightweight pagination on the model without total count."""
1561
+ return await cls.query().simple_paginate(per_page=per_page, page=page)
1562
+
1563
+ @classmethod
1564
+ async def _dispatch_lifecycle_event(
1565
+ cls,
1566
+ event_suffix: str,
1567
+ instance: Model,
1568
+ *,
1569
+ is_after_event: bool = False,
1570
+ ) -> None:
1571
+ """
1572
+ Dispatch a model lifecycle event.
1573
+
1574
+ - Before events (is_after_event=False) execute inline immediately. If any listener raises,
1575
+ the operation is aborted and the exception propagates.
1576
+ - After events (is_after_event=True) are deferred to transaction commit via on_commit if
1577
+ inside an active transaction. By default (strict_events=False), listener exceptions do
1578
+ not fail an already committed database write.
1579
+ """
1580
+ event_name = f"{snake_case(cls.__name__)}.{event_suffix}"
1581
+
1582
+ async def _do_dispatch() -> None:
1583
+ try:
1584
+ await EventDispatcher.get_instance().dispatch(event_name, instance)
1585
+ except Exception:
1586
+ if is_after_event and not getattr(cls, "strict_events", False):
1587
+ logger.exception(
1588
+ "Error executing lifecycle event listener for %s",
1589
+ event_name,
1590
+ )
1591
+ else:
1592
+ raise
1593
+
1594
+ if is_after_event:
1595
+ active_conn = get_active_connection()
1596
+ if active_conn is not None and hasattr(active_conn, "on_commit"):
1597
+ active_conn.on_commit(_do_dispatch)
1598
+ return
1599
+
1600
+ await _do_dispatch()
1601
+
1602
+ def _build_insert_data(self) -> dict[str, Any]:
1603
+ """Build column payload for INSERT, preserving defaults and non-None values."""
1604
+ pk_name = self.__primary_key__
1605
+ data: dict[str, Any] = {}
1606
+ for name in self.__fields__:
1607
+ val = getattr(self, name, None)
1608
+ if name == pk_name:
1609
+ if val is not None:
1610
+ data[name] = self._persistable(val, field_name=name)
1611
+ elif name in self._init_keys or val is not None:
1612
+ data[name] = self._persistable(val, field_name=name)
1613
+ return data
1614
+
1615
+ def _apply_returning_row(
1616
+ self, row: dict[str, Any], insert_keys: Iterable[str]
1617
+ ) -> None:
1618
+ """Apply RETURNING * columns onto the model and record persisted state."""
1619
+ cls = self.__class__
1620
+ for col, val in row.items():
1621
+ if col in cls.__fields__:
1622
+ setattr(self, col, cls._coerce_db_value(col, val))
1623
+ elif col == cls.__primary_key__:
1624
+ setattr(self, col, val)
1625
+ self._mark_persisted([*insert_keys, *row.keys()])
1626
+
1627
+ @classmethod
1628
+ async def create(cls, *args: dict[str, Any], **kwargs: Any) -> Self:
1629
+ """Create and persist a new model instance, validating mass assignment rules."""
1630
+ data: dict[str, Any] = {}
1631
+ if args:
1632
+ data.update(args[0])
1633
+ data.update(kwargs)
1634
+
1635
+ instance = cls()
1636
+ instance.fill(**data)
1637
+
1638
+ await cls._dispatch_lifecycle_event("creating", instance, is_after_event=False)
1639
+
1640
+ insert_data = instance._build_insert_data()
1641
+ row = await cls.query().insert_returning(
1642
+ insert_data, allow_default_values=True
1643
+ )
1644
+ instance._apply_returning_row(row, insert_data.keys())
1645
+
1646
+ await cls._dispatch_lifecycle_event("created", instance, is_after_event=True)
1647
+ return instance
1648
+
1649
+ @classmethod
1650
+ async def force_create(cls, *args: dict[str, Any], **kwargs: Any) -> Self:
1651
+ """Create and persist a new model instance, bypassing fillable/guarded checks for trusted code."""
1652
+ data: dict[str, Any] = {}
1653
+ if args:
1654
+ data.update(args[0])
1655
+ data.update(kwargs)
1656
+
1657
+ instance = cls()
1658
+ instance.force_fill(**data)
1659
+
1660
+ await cls._dispatch_lifecycle_event("creating", instance, is_after_event=False)
1661
+
1662
+ insert_data = instance._build_insert_data()
1663
+ row = await cls.query().insert_returning(
1664
+ insert_data, allow_default_values=True
1665
+ )
1666
+ instance._apply_returning_row(row, insert_data.keys())
1667
+
1668
+ await cls._dispatch_lifecycle_event("created", instance, is_after_event=True)
1669
+ return instance
1670
+
1671
+ async def save(self) -> None:
1672
+ """Save the current model instance, writing only changed attributes on update."""
1673
+ pk_name = self.__primary_key__
1674
+ pk = getattr(self, pk_name, None)
1675
+
1676
+ if self._exists:
1677
+ if pk is None:
1678
+ raise PrimaryKeyError(
1679
+ f"Cannot save existing {self.__class__.__name__} without primary key '{pk_name}'."
1680
+ )
1681
+
1682
+ if not any(name != pk_name for name in self.get_dirty()):
1683
+ return
1684
+
1685
+ await self.__class__._dispatch_lifecycle_event(
1686
+ "updating", self, is_after_event=False
1687
+ )
1688
+
1689
+ changed = {
1690
+ name: self._persistable(val, field_name=name)
1691
+ for name, val in self.get_dirty().items()
1692
+ if name != pk_name
1693
+ }
1694
+ if not changed:
1695
+ return
1696
+
1697
+ if "updated_at" in self.__fields__:
1698
+ now = datetime.now(UTC)
1699
+ changed["updated_at"] = now
1700
+ self.updated_at = now
1701
+
1702
+ await self.__class__.query().where(pk_name, pk).update(changed)
1703
+ for name in changed:
1704
+ self._original[name] = copy.deepcopy(getattr(self, name))
1705
+ if hasattr(self, "_modified_keys"):
1706
+ self._modified_keys.clear()
1707
+ await self.__class__._dispatch_lifecycle_event(
1708
+ "updated", self, is_after_event=True
1709
+ )
1710
+ return
1711
+
1712
+ await self.__class__._dispatch_lifecycle_event(
1713
+ "creating", self, is_after_event=False
1714
+ )
1715
+
1716
+ data = self._build_insert_data()
1717
+ row = await self.__class__.query().insert_returning(
1718
+ data, allow_default_values=True
1719
+ )
1720
+ self._apply_returning_row(row, data.keys())
1721
+ await self.__class__._dispatch_lifecycle_event(
1722
+ "created", self, is_after_event=True
1723
+ )
1724
+
1725
+ async def delete(self) -> None:
1726
+ """Delete this model instance from the database."""
1727
+ pk_name = self.__primary_key__
1728
+ pk = getattr(self, pk_name, None)
1729
+
1730
+ if not self._exists or pk is None:
1731
+ raise RuntimeError(f"Cannot delete unsaved {self.__class__.__name__}.")
1732
+
1733
+ await self.__class__._dispatch_lifecycle_event(
1734
+ "deleting", self, is_after_event=False
1735
+ )
1736
+ await self.__class__.query().where(pk_name, pk).delete()
1737
+ self._exists = False
1738
+ await self.__class__._dispatch_lifecycle_event(
1739
+ "deleted", self, is_after_event=True
1740
+ )
1741
+
1742
+ async def refresh(self) -> Self:
1743
+ """Reload the model attributes from the database."""
1744
+ pk = self.get_primary_key()
1745
+ if not self._exists or pk is None:
1746
+ raise RuntimeError(f"Cannot refresh unsaved {self.__class__.__name__}.")
1747
+
1748
+ refreshed = await self.__class__.find(pk)
1749
+ if refreshed is None:
1750
+ raise RuntimeError(
1751
+ f"Record for {self.__class__.__name__} with "
1752
+ f"{self.__primary_key__}={pk} no longer exists."
1753
+ )
1754
+
1755
+ for k, v in refreshed.get_attributes().items():
1756
+ setattr(self, k, v)
1757
+
1758
+ self._original = dict(refreshed._original)
1759
+ self._init_keys = set(refreshed._init_keys)
1760
+ self._modified_keys.clear()
1761
+ self._loaded_relations.clear()
1762
+ return self
1763
+
1764
+ async def increment(
1765
+ self, column: str, amount: float = 1, extra: dict[str, Any] | None = None
1766
+ ) -> None:
1767
+ """
1768
+ Atomically increment a column in the database and refresh this instance.
1769
+
1770
+ LIFECYCLE CONSISTENCY:
1771
+ If the database update succeeds but refresh() fails (e.g., due to a temporary
1772
+ connection disruption), the database record will contain the updated value while
1773
+ the in-memory object remains un-refreshed. Callers can safely re-invoke
1774
+ await instance.refresh() to synchronize state once connectivity is restored.
1775
+ """
1776
+ pk_name = self.__primary_key__
1777
+ pk_val = self.get_primary_key()
1778
+ if not self._exists or pk_val is None:
1779
+ raise PrimaryKeyError(
1780
+ f"Cannot increment unsaved or primary-key-less model [{self.__class__.__name__}]."
1781
+ )
1782
+ await self.__class__.query().where(pk_name, pk_val).increment(column, amount, extra=extra)
1783
+ await self.refresh()
1784
+
1785
+ async def decrement(
1786
+ self, column: str, amount: float = 1, extra: dict[str, Any] | None = None
1787
+ ) -> None:
1788
+ """
1789
+ Atomically decrement a column in the database and refresh this instance.
1790
+
1791
+ LIFECYCLE CONSISTENCY:
1792
+ If the database update succeeds but refresh() fails (e.g., due to a temporary
1793
+ connection disruption), the database record will contain the updated value while
1794
+ the in-memory object remains un-refreshed. Callers can safely re-invoke
1795
+ await instance.refresh() to synchronize state once connectivity is restored.
1796
+ """
1797
+ pk_name = self.__primary_key__
1798
+ pk_val = self.get_primary_key()
1799
+ if not self._exists or pk_val is None:
1800
+ raise PrimaryKeyError(
1801
+ f"Cannot decrement unsaved or primary-key-less model [{self.__class__.__name__}]."
1802
+ )
1803
+ await self.__class__.query().where(pk_name, pk_val).decrement(column, amount, extra=extra)
1804
+ await self.refresh()
1805
+
1806
+ def get_primary_key(self) -> Any:
1807
+ """Return the value of the primary key field."""
1808
+ return getattr(self, self.__primary_key__, None)
1809
+
1810
+ def get_attributes(self) -> dict[str, Any]:
1811
+ """Return a dictionary of model attributes."""
1812
+ attrs: dict[str, Any] = {}
1813
+ for f in self.__fields__:
1814
+ if hasattr(self, f):
1815
+ attrs[f] = getattr(self, f)
1816
+ return attrs
1817
+
1818
+ def make_visible(self, *fields: str) -> Self:
1819
+ """Temporarily make specified fields visible in to_dict() serialization."""
1820
+ self._temp_visible.update(fields)
1821
+ self._temp_hidden.difference_update(fields)
1822
+ return self
1823
+
1824
+ def make_hidden(self, *fields: str) -> Self:
1825
+ """Temporarily hide specified fields from to_dict() serialization."""
1826
+ self._temp_hidden.update(fields)
1827
+ self._temp_visible.difference_update(fields)
1828
+ return self
1829
+
1830
+ def to_dict(self, include_hidden: bool = False) -> dict[str, Any]:
1831
+ """Convert the model and its loaded relations to a raw dictionary, omitting __hidden__ fields."""
1832
+ if include_hidden:
1833
+ hidden: set[str] = set()
1834
+ else:
1835
+ base_hidden = set(getattr(self, "__hidden__", ()) or ())
1836
+ hidden = (base_hidden | self._temp_hidden) - self._temp_visible
1837
+
1838
+ data = {
1839
+ k: v for k, v in self.get_attributes().items() if k not in hidden
1840
+ }
1841
+ for rel_name, rel_value in self._loaded_relations.items():
1842
+ if rel_name in hidden:
1843
+ continue
1844
+ if isinstance(rel_value, list):
1845
+ data[rel_name] = [
1846
+ (
1847
+ item.to_dict(include_hidden=include_hidden)
1848
+ if isinstance(item, Model)
1849
+ else item
1850
+ )
1851
+ for item in rel_value
1852
+ ]
1853
+ elif isinstance(rel_value, Model):
1854
+ data[rel_name] = rel_value.to_dict(include_hidden=include_hidden)
1855
+ else:
1856
+ data[rel_name] = rel_value
1857
+ return data
1858
+
1859
+ @overload
1860
+ def has_many(
1861
+ self,
1862
+ related: type[_TRelModel],
1863
+ foreign_key: str | None = None,
1864
+ local_key: str | None = None,
1865
+ ) -> HasMany[_TRelModel]: ...
1866
+
1867
+ @overload
1868
+ def has_many(
1869
+ self,
1870
+ related: str,
1871
+ foreign_key: str | None = None,
1872
+ local_key: str | None = None,
1873
+ ) -> HasMany[Any]: ...
1874
+
1875
+ def has_many(
1876
+ self,
1877
+ related: str | type[Model],
1878
+ foreign_key: str | None = None,
1879
+ local_key: str | None = None,
1880
+ ) -> HasMany[Any]:
1881
+ """Construct a HasMany relationship instance."""
1882
+ if isinstance(related, str):
1883
+ rel: HasMany[Any] = HasMany(related, foreign_key, local_key)
1884
+ else:
1885
+ rel = HasMany(related, foreign_key, local_key)
1886
+ rel.owner_cls = self.__class__
1887
+ return rel
1888
+
1889
+ @overload
1890
+ def belongs_to(
1891
+ self,
1892
+ related: type[_TRelModel],
1893
+ foreign_key: str | None = None,
1894
+ local_key: str | None = None,
1895
+ ) -> BelongsTo[_TRelModel]: ...
1896
+
1897
+ @overload
1898
+ def belongs_to(
1899
+ self,
1900
+ related: str,
1901
+ foreign_key: str | None = None,
1902
+ local_key: str | None = None,
1903
+ ) -> BelongsTo[Any]: ...
1904
+
1905
+ def belongs_to(
1906
+ self,
1907
+ related: str | type[Model],
1908
+ foreign_key: str | None = None,
1909
+ local_key: str | None = None,
1910
+ ) -> BelongsTo[Any]:
1911
+ """Construct a BelongsTo relationship instance."""
1912
+ if isinstance(related, str):
1913
+ rel: BelongsTo[Any] = BelongsTo(related, foreign_key, local_key)
1914
+ else:
1915
+ rel = BelongsTo(related, foreign_key, local_key)
1916
+ rel.owner_cls = self.__class__
1917
+ return rel
1918
+
1919
+ @overload
1920
+ def has_one(
1921
+ self,
1922
+ related: type[_TRelModel],
1923
+ foreign_key: str | None = None,
1924
+ local_key: str | None = None,
1925
+ ) -> HasOne[_TRelModel]: ...
1926
+
1927
+ @overload
1928
+ def has_one(
1929
+ self,
1930
+ related: str,
1931
+ foreign_key: str | None = None,
1932
+ local_key: str | None = None,
1933
+ ) -> HasOne[Any]: ...
1934
+
1935
+ def has_one(
1936
+ self,
1937
+ related: str | type[Model],
1938
+ foreign_key: str | None = None,
1939
+ local_key: str | None = None,
1940
+ ) -> HasOne[Any]:
1941
+ """Construct a HasOne relationship instance."""
1942
+ if isinstance(related, str):
1943
+ rel: HasOne[Any] = HasOne(related, foreign_key, local_key)
1944
+ else:
1945
+ rel = HasOne(related, foreign_key, local_key)
1946
+ rel.owner_cls = self.__class__
1947
+ return rel
1948
+
1949
+ @overload
1950
+ def belongs_to_many(
1951
+ self,
1952
+ related: type[_TRelModel],
1953
+ pivot_table: str | None = None,
1954
+ foreign_pivot_key: str | None = None,
1955
+ related_pivot_key: str | None = None,
1956
+ parent_key: str | None = None,
1957
+ related_key: str | None = None,
1958
+ ) -> BelongsToMany[_TRelModel]: ...
1959
+
1960
+ @overload
1961
+ def belongs_to_many(
1962
+ self,
1963
+ related: str,
1964
+ pivot_table: str | None = None,
1965
+ foreign_pivot_key: str | None = None,
1966
+ related_pivot_key: str | None = None,
1967
+ parent_key: str | None = None,
1968
+ related_key: str | None = None,
1969
+ ) -> BelongsToMany[Any]: ...
1970
+
1971
+ def belongs_to_many(
1972
+ self,
1973
+ related: str | type[Model],
1974
+ pivot_table: str | None = None,
1975
+ foreign_pivot_key: str | None = None,
1976
+ related_pivot_key: str | None = None,
1977
+ parent_key: str | None = None,
1978
+ related_key: str | None = None,
1979
+ ) -> BelongsToMany[Any]:
1980
+ """Construct a BelongsToMany relationship instance."""
1981
+ if isinstance(related, str):
1982
+ rel: BelongsToMany[Any] = BelongsToMany(
1983
+ related,
1984
+ pivot_table,
1985
+ foreign_pivot_key,
1986
+ related_pivot_key,
1987
+ parent_key,
1988
+ related_key,
1989
+ )
1990
+ else:
1991
+ rel = BelongsToMany(
1992
+ related,
1993
+ pivot_table,
1994
+ foreign_pivot_key,
1995
+ related_pivot_key,
1996
+ parent_key,
1997
+ related_key,
1998
+ )
1999
+ rel.owner_cls = self.__class__
2000
+ return rel
2001
+
2002
+ def __repr__(self) -> str:
2003
+ pk = self.get_primary_key()
2004
+ return f"<{self.__class__.__name__} {self.__primary_key__}={pk!r}>"
2005
+
2006
+ def __eq__(self, other: object) -> bool:
2007
+ if type(self) is not type(other):
2008
+ return False
2009
+ pk = self.get_primary_key()
2010
+ other_pk = other.get_primary_key()
2011
+ if pk is not None and other_pk is not None:
2012
+ return bool(pk == other_pk)
2013
+ return self is other
2014
+
2015
+ def __hash__(self) -> int:
2016
+ pk = self.get_primary_key()
2017
+ if pk is None:
2018
+ raise TypeError(
2019
+ f"Unsaved {self.__class__.__name__} without primary key is unhashable."
2020
+ )
2021
+ return hash((self.__class__, pk))
2022
+
2023
+
2024
+ __all__ = [
2025
+ "_MODEL_REGISTRY",
2026
+ "Field",
2027
+ "FieldInfo",
2028
+ "MassAssignmentError",
2029
+ "Model",
2030
+ "ModelQueryBuilder",
2031
+ "PrimaryKeyError",
2032
+ "restore_model_registry",
2033
+ "snapshot_model_registry",
2034
+ ]