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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|