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,1803 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, cast, overload
|
|
9
|
+
|
|
10
|
+
import asyncpg
|
|
11
|
+
|
|
12
|
+
from swiftpy.core.container import get_container
|
|
13
|
+
from swiftpy.database.exceptions import RecordNotFoundError
|
|
14
|
+
from swiftpy.database.identifiers import (
|
|
15
|
+
quote_identifier,
|
|
16
|
+
validate_column_identifier,
|
|
17
|
+
validate_identifier,
|
|
18
|
+
)
|
|
19
|
+
from swiftpy.database.pagination.paginator import Paginator
|
|
20
|
+
from swiftpy.database.pagination.simple_paginator import SimplePaginator
|
|
21
|
+
from swiftpy.database.pool import DatabasePool
|
|
22
|
+
from swiftpy.database.transaction import get_active_connection
|
|
23
|
+
|
|
24
|
+
_MISSING = object()
|
|
25
|
+
|
|
26
|
+
_HAVING_AGGREGATE_RE = re.compile(
|
|
27
|
+
r"^(COUNT|SUM|AVG|MIN|MAX)\(\s*(DISTINCT\s+)?(\*|[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\s*\)$",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _validate_boolean(boolean: str, *, context: str = "WHERE") -> str:
|
|
33
|
+
if not isinstance(boolean, str):
|
|
34
|
+
raise TypeError(f"{context} boolean operator must be a string.")
|
|
35
|
+
bool_op = boolean.upper().strip()
|
|
36
|
+
if bool_op not in {"AND", "OR"}:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"{context} boolean operator must be 'AND' or 'OR', got {boolean!r}."
|
|
39
|
+
)
|
|
40
|
+
return bool_op
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_having_aggregate(column: str) -> str | None:
|
|
44
|
+
match = _HAVING_AGGREGATE_RE.fullmatch(column.strip())
|
|
45
|
+
if match is None:
|
|
46
|
+
return None
|
|
47
|
+
func = match.group(1).upper()
|
|
48
|
+
distinct_kw = "DISTINCT " if match.group(2) else ""
|
|
49
|
+
target = match.group(3)
|
|
50
|
+
if target == "*":
|
|
51
|
+
if func != "COUNT" or distinct_kw:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Wildcard '*' in HAVING is only allowed with COUNT(*), got {column!r}."
|
|
54
|
+
)
|
|
55
|
+
return "COUNT(*)"
|
|
56
|
+
safe_target = validate_identifier(target, allow_dotted=True, allow_wildcard=False)
|
|
57
|
+
return f"{func}({distinct_kw}{quote_identifier(safe_target, allow_dotted=True, allow_wildcard=False)})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_raw_bindings(
|
|
61
|
+
sql: str,
|
|
62
|
+
bindings: Sequence[Any] | None,
|
|
63
|
+
*,
|
|
64
|
+
method: str,
|
|
65
|
+
) -> list[Any]:
|
|
66
|
+
if bindings is None:
|
|
67
|
+
bindings_list: list[Any] = []
|
|
68
|
+
elif isinstance(bindings, (str, bytes, bytearray)) or not isinstance(
|
|
69
|
+
bindings, Sequence
|
|
70
|
+
):
|
|
71
|
+
raise TypeError(
|
|
72
|
+
f"{method} bindings must be a sequence (list or tuple), not {type(bindings).__name__}."
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
bindings_list = list(bindings)
|
|
76
|
+
|
|
77
|
+
placeholder_count = sql.count("?")
|
|
78
|
+
if placeholder_count != len(bindings_list):
|
|
79
|
+
raise QueryError(
|
|
80
|
+
f"Placeholder count mismatch in {method}: SQL expression contains "
|
|
81
|
+
f"{placeholder_count} '?' placeholder(s), but {len(bindings_list)} binding(s) were provided."
|
|
82
|
+
)
|
|
83
|
+
return bindings_list
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _compile_raw_sql(raw: Raw, start_idx: int) -> tuple[str, list[Any], int]:
|
|
87
|
+
if not raw.bindings:
|
|
88
|
+
return raw.sql, [], start_idx
|
|
89
|
+
idx = start_idx
|
|
90
|
+
|
|
91
|
+
def _replace(_match: re.Match[str]) -> str:
|
|
92
|
+
nonlocal idx
|
|
93
|
+
placeholder = f"${idx}"
|
|
94
|
+
idx += 1
|
|
95
|
+
return placeholder
|
|
96
|
+
|
|
97
|
+
compiled_sql = re.sub(r"\?", _replace, raw.sql, count=len(raw.bindings))
|
|
98
|
+
return compiled_sql, list(raw.bindings), idx
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Column:
|
|
102
|
+
def __init__(self, name: str, *, alias: str | None = None) -> None:
|
|
103
|
+
self.name = validate_column_identifier(name)
|
|
104
|
+
|
|
105
|
+
self.alias = validate_identifier(alias) if alias is not None else None
|
|
106
|
+
|
|
107
|
+
def to_sql(self) -> str:
|
|
108
|
+
quoted = quote_identifier(self.name)
|
|
109
|
+
if self.alias is None:
|
|
110
|
+
return quoted
|
|
111
|
+
|
|
112
|
+
return f'{quoted} AS "{self.alias}"'
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Raw:
|
|
116
|
+
"""
|
|
117
|
+
Trusted raw SQL fragment.
|
|
118
|
+
|
|
119
|
+
SQL passed here is inserted directly into the query structure.
|
|
120
|
+
Never pass untrusted/user-controlled SQL into this object.
|
|
121
|
+
|
|
122
|
+
Dynamic values must be supplied through bindings.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, sql: str, bindings: list[Any] | None = None) -> None:
|
|
126
|
+
self.sql = sql
|
|
127
|
+
self.bindings = bindings or []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(slots=True, frozen=True)
|
|
131
|
+
class _WhereClause:
|
|
132
|
+
boolean: str
|
|
133
|
+
column: str
|
|
134
|
+
operator: str
|
|
135
|
+
value: Any
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(slots=True, frozen=True)
|
|
139
|
+
class _WhereBetweenClause:
|
|
140
|
+
boolean: str
|
|
141
|
+
column: str
|
|
142
|
+
values: tuple[Any, Any]
|
|
143
|
+
negated: bool = False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(slots=True, frozen=True)
|
|
147
|
+
class _WhereRawClause:
|
|
148
|
+
boolean: str
|
|
149
|
+
raw: Raw
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(slots=True, frozen=True)
|
|
153
|
+
class _WhereGroupClause:
|
|
154
|
+
boolean: str
|
|
155
|
+
clauses: tuple[_WhereClauseType, ...]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_WhereClauseType = (
|
|
159
|
+
_WhereClause | _WhereBetweenClause | _WhereRawClause | _WhereGroupClause
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _clone_where_clause(wc: _WhereClauseType) -> _WhereClauseType:
|
|
164
|
+
if isinstance(wc, _WhereRawClause):
|
|
165
|
+
return _WhereRawClause(wc.boolean, Raw(wc.raw.sql, list(wc.raw.bindings)))
|
|
166
|
+
if isinstance(wc, _WhereGroupClause):
|
|
167
|
+
return _WhereGroupClause(
|
|
168
|
+
wc.boolean,
|
|
169
|
+
tuple(_clone_where_clause(child) for child in wc.clauses),
|
|
170
|
+
)
|
|
171
|
+
return wc
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
SelectExpression = str | Column | Raw
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class QueryError(ValueError, RuntimeError):
|
|
179
|
+
"""Raised when constructing or executing an invalid or unguarded SQL query."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def escape_like(value: str) -> str:
|
|
183
|
+
"""Escape SQL LIKE/ILIKE wildcard characters (\\, %, _) for literal substring matching."""
|
|
184
|
+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class QueryBuilder:
|
|
188
|
+
@classmethod
|
|
189
|
+
def table(cls, table_name: str) -> QueryBuilder:
|
|
190
|
+
return cls(table_name)
|
|
191
|
+
|
|
192
|
+
def __init__(self, table_name: str) -> None:
|
|
193
|
+
self._table = validate_identifier(table_name, allow_dotted=True)
|
|
194
|
+
self._columns: list[SelectExpression] = ["*"]
|
|
195
|
+
self._wheres: list[tuple[str, str, Any]] = []
|
|
196
|
+
self._where_clauses: list[_WhereClauseType] = []
|
|
197
|
+
self._joins: list[tuple[str, str, str, str, str]] = []
|
|
198
|
+
self._raw_wheres: list[Raw] = []
|
|
199
|
+
self._order_column: str | None = None
|
|
200
|
+
self._order_direction: str = "ASC"
|
|
201
|
+
self._order_random: bool = False
|
|
202
|
+
self._limit_value: int | None = None
|
|
203
|
+
self._offset_value: int | None = None
|
|
204
|
+
self._groups: list[str] = []
|
|
205
|
+
self._havings: list[tuple[str, str, str, Any]] = []
|
|
206
|
+
self._raw_havings: list[tuple[str, Raw]] = []
|
|
207
|
+
self._select_aliases: dict[str, str] = {}
|
|
208
|
+
self._distinct: bool = False
|
|
209
|
+
self._distinct_columns: list[str] = []
|
|
210
|
+
self._allow_all: bool = False
|
|
211
|
+
|
|
212
|
+
def clone(self) -> QueryBuilder:
|
|
213
|
+
"""Create an independent copy of this QueryBuilder so terminal operations do not mutate it."""
|
|
214
|
+
cloned = QueryBuilder(self._table)
|
|
215
|
+
cloned._columns = [
|
|
216
|
+
Raw(col.sql, list(col.bindings)) if isinstance(col, Raw) else col
|
|
217
|
+
for col in self._columns
|
|
218
|
+
]
|
|
219
|
+
cloned._wheres = list(self._wheres)
|
|
220
|
+
cloned._where_clauses = [_clone_where_clause(wc) for wc in self._where_clauses]
|
|
221
|
+
cloned._joins = list(self._joins)
|
|
222
|
+
cloned._raw_wheres = [
|
|
223
|
+
Raw(rw.sql, list(rw.bindings)) for rw in self._raw_wheres
|
|
224
|
+
]
|
|
225
|
+
cloned._order_column = self._order_column
|
|
226
|
+
cloned._order_direction = self._order_direction
|
|
227
|
+
cloned._order_random = self._order_random
|
|
228
|
+
cloned._limit_value = self._limit_value
|
|
229
|
+
cloned._offset_value = self._offset_value
|
|
230
|
+
cloned._groups = list(self._groups)
|
|
231
|
+
cloned._havings = list(self._havings)
|
|
232
|
+
cloned._raw_havings = [
|
|
233
|
+
(b, Raw(rh.sql, list(rh.bindings))) for b, rh in self._raw_havings
|
|
234
|
+
]
|
|
235
|
+
cloned._select_aliases = dict(self._select_aliases)
|
|
236
|
+
cloned._distinct = self._distinct
|
|
237
|
+
cloned._distinct_columns = list(self._distinct_columns)
|
|
238
|
+
cloned._allow_all = self._allow_all
|
|
239
|
+
return cloned
|
|
240
|
+
|
|
241
|
+
def allow_all(self) -> QueryBuilder:
|
|
242
|
+
"""Explicitly permit mass UPDATE or DELETE queries without a WHERE clause (Decision D-10)."""
|
|
243
|
+
self._allow_all = True
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def select(self, *columns: SelectExpression) -> QueryBuilder:
|
|
247
|
+
|
|
248
|
+
if not columns:
|
|
249
|
+
self._columns = ["*"]
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
selected: list[SelectExpression] = []
|
|
253
|
+
|
|
254
|
+
for column in columns:
|
|
255
|
+
if isinstance(column, (Column, Raw)):
|
|
256
|
+
selected.append(column)
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
selected.append(validate_column_identifier(column))
|
|
260
|
+
|
|
261
|
+
self._columns = selected
|
|
262
|
+
|
|
263
|
+
return self
|
|
264
|
+
|
|
265
|
+
def distinct(self, *columns: str) -> QueryBuilder:
|
|
266
|
+
"""
|
|
267
|
+
Apply DISTINCT or PostgreSQL DISTINCT ON (...) to the query projection.
|
|
268
|
+
Calling distinct() with no arguments compiles to SELECT DISTINCT.
|
|
269
|
+
Calling distinct("col1", "col2") compiles to SELECT DISTINCT ON ("col1", "col2").
|
|
270
|
+
"""
|
|
271
|
+
self._distinct = True
|
|
272
|
+
validated_cols: list[str] = []
|
|
273
|
+
for col in columns:
|
|
274
|
+
validated_cols.append(
|
|
275
|
+
validate_identifier(col, allow_dotted=True, allow_wildcard=False)
|
|
276
|
+
)
|
|
277
|
+
self._distinct_columns = validated_cols
|
|
278
|
+
return self
|
|
279
|
+
|
|
280
|
+
def select_raw(
|
|
281
|
+
self, sql: str, bindings: Sequence[Any] | None = None
|
|
282
|
+
) -> QueryBuilder:
|
|
283
|
+
"""
|
|
284
|
+
Add a raw SQL expression to the SELECT projection.
|
|
285
|
+
|
|
286
|
+
SECURITY CONTRACT:
|
|
287
|
+
- The `sql` parameter is trusted, application-authored SQL. Never interpolate
|
|
288
|
+
or concatenate untrusted user input directly into `sql`.
|
|
289
|
+
- All dynamic, user-supplied values MUST be passed as parameterized positional
|
|
290
|
+
placeholders via `bindings` (using `?` in `sql`), which are safely compiled into
|
|
291
|
+
numbered PostgreSQL placeholders (`$1, $2, ...`).
|
|
292
|
+
"""
|
|
293
|
+
if not isinstance(sql, str):
|
|
294
|
+
raise TypeError("select_raw SQL expression must be a string.")
|
|
295
|
+
bindings_list = _normalize_raw_bindings(sql, bindings, method="select_raw")
|
|
296
|
+
if self._columns == ["*"]:
|
|
297
|
+
self._columns = []
|
|
298
|
+
self._columns.append(Raw(sql, bindings_list))
|
|
299
|
+
return self
|
|
300
|
+
|
|
301
|
+
def select_count(
|
|
302
|
+
self, column: str = "*", alias: str | None = None
|
|
303
|
+
) -> QueryBuilder:
|
|
304
|
+
"""Add a COUNT aggregate expression to the SELECT projection."""
|
|
305
|
+
if column != "*":
|
|
306
|
+
safe_col = quote_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
307
|
+
expr = f"COUNT({safe_col})"
|
|
308
|
+
else:
|
|
309
|
+
expr = "COUNT(*)"
|
|
310
|
+
|
|
311
|
+
if alias is not None:
|
|
312
|
+
safe_alias = validate_identifier(alias, allow_dotted=False, allow_wildcard=False)
|
|
313
|
+
self._select_aliases[safe_alias] = expr
|
|
314
|
+
sql_expr = f'{expr} AS "{safe_alias}"'
|
|
315
|
+
else:
|
|
316
|
+
sql_expr = expr
|
|
317
|
+
|
|
318
|
+
if self._columns == ["*"]:
|
|
319
|
+
self._columns = []
|
|
320
|
+
self._columns.append(Raw(sql_expr))
|
|
321
|
+
return self
|
|
322
|
+
|
|
323
|
+
def select_sum(
|
|
324
|
+
self, column: str, alias: str | None = None
|
|
325
|
+
) -> QueryBuilder:
|
|
326
|
+
"""Add a SUM aggregate expression to the SELECT projection."""
|
|
327
|
+
safe_col = quote_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
328
|
+
expr = f"SUM({safe_col})"
|
|
329
|
+
|
|
330
|
+
if alias is not None:
|
|
331
|
+
safe_alias = validate_identifier(alias, allow_dotted=False, allow_wildcard=False)
|
|
332
|
+
self._select_aliases[safe_alias] = expr
|
|
333
|
+
sql_expr = f'{expr} AS "{safe_alias}"'
|
|
334
|
+
else:
|
|
335
|
+
sql_expr = expr
|
|
336
|
+
|
|
337
|
+
if self._columns == ["*"]:
|
|
338
|
+
self._columns = []
|
|
339
|
+
self._columns.append(Raw(sql_expr))
|
|
340
|
+
return self
|
|
341
|
+
|
|
342
|
+
def select_avg(
|
|
343
|
+
self, column: str, alias: str | None = None
|
|
344
|
+
) -> QueryBuilder:
|
|
345
|
+
"""Add an AVG aggregate expression to the SELECT projection."""
|
|
346
|
+
safe_col = quote_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
347
|
+
expr = f"AVG({safe_col})"
|
|
348
|
+
|
|
349
|
+
if alias is not None:
|
|
350
|
+
safe_alias = validate_identifier(alias, allow_dotted=False, allow_wildcard=False)
|
|
351
|
+
self._select_aliases[safe_alias] = expr
|
|
352
|
+
sql_expr = f'{expr} AS "{safe_alias}"'
|
|
353
|
+
else:
|
|
354
|
+
sql_expr = expr
|
|
355
|
+
|
|
356
|
+
if self._columns == ["*"]:
|
|
357
|
+
self._columns = []
|
|
358
|
+
self._columns.append(Raw(sql_expr))
|
|
359
|
+
return self
|
|
360
|
+
|
|
361
|
+
def select_min(
|
|
362
|
+
self, column: str, alias: str | None = None
|
|
363
|
+
) -> QueryBuilder:
|
|
364
|
+
"""Add a MIN aggregate expression to the SELECT projection."""
|
|
365
|
+
safe_col = quote_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
366
|
+
expr = f"MIN({safe_col})"
|
|
367
|
+
|
|
368
|
+
if alias is not None:
|
|
369
|
+
safe_alias = validate_identifier(alias, allow_dotted=False, allow_wildcard=False)
|
|
370
|
+
self._select_aliases[safe_alias] = expr
|
|
371
|
+
sql_expr = f'{expr} AS "{safe_alias}"'
|
|
372
|
+
else:
|
|
373
|
+
sql_expr = expr
|
|
374
|
+
|
|
375
|
+
if self._columns == ["*"]:
|
|
376
|
+
self._columns = []
|
|
377
|
+
self._columns.append(Raw(sql_expr))
|
|
378
|
+
return self
|
|
379
|
+
|
|
380
|
+
def select_max(
|
|
381
|
+
self, column: str, alias: str | None = None
|
|
382
|
+
) -> QueryBuilder:
|
|
383
|
+
"""Add a MAX aggregate expression to the SELECT projection."""
|
|
384
|
+
safe_col = quote_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
385
|
+
expr = f"MAX({safe_col})"
|
|
386
|
+
|
|
387
|
+
if alias is not None:
|
|
388
|
+
safe_alias = validate_identifier(alias, allow_dotted=False, allow_wildcard=False)
|
|
389
|
+
self._select_aliases[safe_alias] = expr
|
|
390
|
+
sql_expr = f'{expr} AS "{safe_alias}"'
|
|
391
|
+
else:
|
|
392
|
+
sql_expr = expr
|
|
393
|
+
|
|
394
|
+
if self._columns == ["*"]:
|
|
395
|
+
self._columns = []
|
|
396
|
+
self._columns.append(Raw(sql_expr))
|
|
397
|
+
return self
|
|
398
|
+
|
|
399
|
+
def where(
|
|
400
|
+
self,
|
|
401
|
+
column: str,
|
|
402
|
+
operator_or_value: Any,
|
|
403
|
+
value: Any = _MISSING,
|
|
404
|
+
*,
|
|
405
|
+
boolean: str = "AND",
|
|
406
|
+
) -> QueryBuilder:
|
|
407
|
+
column = validate_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
408
|
+
bool_op = _validate_boolean(boolean, context="WHERE")
|
|
409
|
+
|
|
410
|
+
if value is _MISSING:
|
|
411
|
+
operator = "="
|
|
412
|
+
value = operator_or_value
|
|
413
|
+
|
|
414
|
+
else:
|
|
415
|
+
operator = " ".join(str(operator_or_value).upper().split())
|
|
416
|
+
|
|
417
|
+
allowed = {
|
|
418
|
+
"=",
|
|
419
|
+
"!=",
|
|
420
|
+
"<>",
|
|
421
|
+
"<",
|
|
422
|
+
">",
|
|
423
|
+
"<=",
|
|
424
|
+
">=",
|
|
425
|
+
"LIKE",
|
|
426
|
+
"ILIKE",
|
|
427
|
+
"IN",
|
|
428
|
+
"NOT IN",
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if operator not in allowed:
|
|
432
|
+
raise QueryError(f"Unsupported operator: {operator}")
|
|
433
|
+
|
|
434
|
+
if value is None:
|
|
435
|
+
if operator == "=":
|
|
436
|
+
self._wheres.append((column, "IS", None))
|
|
437
|
+
self._where_clauses.append(_WhereClause(bool_op, column, "IS", None))
|
|
438
|
+
return self
|
|
439
|
+
|
|
440
|
+
if operator in {"!=", "<>"}:
|
|
441
|
+
self._wheres.append((column, "IS NOT", None))
|
|
442
|
+
self._where_clauses.append(_WhereClause(bool_op, column, "IS NOT", None))
|
|
443
|
+
return self
|
|
444
|
+
|
|
445
|
+
raise QueryError(
|
|
446
|
+
f"Cannot compare NULL using operator '{operator}'. Use '=' / '!=' or where_null() / where_not_null()."
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
if operator in {"IN", "NOT IN"} and not isinstance(value, (list, tuple, QueryBuilder)):
|
|
450
|
+
raise QueryError(
|
|
451
|
+
f"Operator '{operator}' requires a list, tuple, or QueryBuilder subquery, got {type(value).__name__}."
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
self._wheres.append((column, operator, value))
|
|
455
|
+
self._where_clauses.append(_WhereClause(bool_op, column, operator, value))
|
|
456
|
+
|
|
457
|
+
return self
|
|
458
|
+
|
|
459
|
+
def or_where(
|
|
460
|
+
self,
|
|
461
|
+
column: str,
|
|
462
|
+
operator_or_value: Any,
|
|
463
|
+
value: Any = _MISSING,
|
|
464
|
+
) -> QueryBuilder:
|
|
465
|
+
"""Add an OR WHERE clause to the query."""
|
|
466
|
+
return self.where(column, operator_or_value, value, boolean="OR")
|
|
467
|
+
|
|
468
|
+
def where_in(
|
|
469
|
+
self,
|
|
470
|
+
column: str,
|
|
471
|
+
values: list[Any] | tuple[Any, ...] | QueryBuilder,
|
|
472
|
+
*,
|
|
473
|
+
boolean: str = "AND",
|
|
474
|
+
) -> QueryBuilder:
|
|
475
|
+
"""Add a WHERE column IN (...) clause."""
|
|
476
|
+
return self.where(column, "IN", values, boolean=boolean)
|
|
477
|
+
|
|
478
|
+
def or_where_in(
|
|
479
|
+
self,
|
|
480
|
+
column: str,
|
|
481
|
+
values: list[Any] | tuple[Any, ...] | QueryBuilder,
|
|
482
|
+
) -> QueryBuilder:
|
|
483
|
+
"""Add an OR WHERE column IN (...) clause."""
|
|
484
|
+
return self.where_in(column, values, boolean="OR")
|
|
485
|
+
|
|
486
|
+
def where_not_in(
|
|
487
|
+
self,
|
|
488
|
+
column: str,
|
|
489
|
+
values: list[Any] | tuple[Any, ...] | QueryBuilder,
|
|
490
|
+
*,
|
|
491
|
+
boolean: str = "AND",
|
|
492
|
+
) -> QueryBuilder:
|
|
493
|
+
"""Add a WHERE column NOT IN (...) clause."""
|
|
494
|
+
return self.where(column, "NOT IN", values, boolean=boolean)
|
|
495
|
+
|
|
496
|
+
def or_where_not_in(
|
|
497
|
+
self,
|
|
498
|
+
column: str,
|
|
499
|
+
values: list[Any] | tuple[Any, ...] | QueryBuilder,
|
|
500
|
+
) -> QueryBuilder:
|
|
501
|
+
"""Add an OR WHERE column NOT IN (...) clause."""
|
|
502
|
+
return self.where_not_in(column, values, boolean="OR")
|
|
503
|
+
|
|
504
|
+
def where_like(
|
|
505
|
+
self,
|
|
506
|
+
column: str,
|
|
507
|
+
value: str,
|
|
508
|
+
*,
|
|
509
|
+
case_insensitive: bool = False,
|
|
510
|
+
match: str = "contains",
|
|
511
|
+
boolean: str = "AND",
|
|
512
|
+
) -> QueryBuilder:
|
|
513
|
+
"""
|
|
514
|
+
Add a safe LIKE / ILIKE clause that escapes %, _, and \\ wildcards in user input
|
|
515
|
+
and emits an explicit ESCAPE '\\' clause.
|
|
516
|
+
"""
|
|
517
|
+
escaped = escape_like(str(value))
|
|
518
|
+
match_mode = match.lower()
|
|
519
|
+
if match_mode == "contains":
|
|
520
|
+
pattern = f"%{escaped}%"
|
|
521
|
+
elif match_mode in {"starts_with", "prefix"}:
|
|
522
|
+
pattern = f"{escaped}%"
|
|
523
|
+
elif match_mode in {"ends_with", "suffix"}:
|
|
524
|
+
pattern = f"%{escaped}"
|
|
525
|
+
elif match_mode == "exact":
|
|
526
|
+
pattern = escaped
|
|
527
|
+
else:
|
|
528
|
+
raise QueryError(f"Unsupported LIKE match mode: {match}")
|
|
529
|
+
|
|
530
|
+
op = "ILIKE" if case_insensitive else "LIKE"
|
|
531
|
+
return self.where(column, op, pattern, boolean=boolean)
|
|
532
|
+
|
|
533
|
+
def where_ilike(
|
|
534
|
+
self,
|
|
535
|
+
column: str,
|
|
536
|
+
value: str,
|
|
537
|
+
*,
|
|
538
|
+
match: str = "contains",
|
|
539
|
+
boolean: str = "AND",
|
|
540
|
+
) -> QueryBuilder:
|
|
541
|
+
"""Add a safe case-insensitive ILIKE clause with escaped wildcards."""
|
|
542
|
+
return self.where_like(column, value, case_insensitive=True, match=match, boolean=boolean)
|
|
543
|
+
|
|
544
|
+
def or_where_like(
|
|
545
|
+
self,
|
|
546
|
+
column: str,
|
|
547
|
+
value: str,
|
|
548
|
+
*,
|
|
549
|
+
case_insensitive: bool = False,
|
|
550
|
+
match: str = "contains",
|
|
551
|
+
) -> QueryBuilder:
|
|
552
|
+
"""Add an OR WHERE LIKE / ILIKE clause with escaped wildcards."""
|
|
553
|
+
return self.where_like(
|
|
554
|
+
column,
|
|
555
|
+
value,
|
|
556
|
+
case_insensitive=case_insensitive,
|
|
557
|
+
match=match,
|
|
558
|
+
boolean="OR",
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def or_where_ilike(
|
|
562
|
+
self,
|
|
563
|
+
column: str,
|
|
564
|
+
value: str,
|
|
565
|
+
*,
|
|
566
|
+
match: str = "contains",
|
|
567
|
+
) -> QueryBuilder:
|
|
568
|
+
"""Add a safe OR case-insensitive ILIKE clause with escaped wildcards."""
|
|
569
|
+
return self.where_like(
|
|
570
|
+
column,
|
|
571
|
+
value,
|
|
572
|
+
case_insensitive=True,
|
|
573
|
+
match=match,
|
|
574
|
+
boolean="OR",
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def where_between(
|
|
578
|
+
self,
|
|
579
|
+
column: str,
|
|
580
|
+
values: Sequence[Any],
|
|
581
|
+
*,
|
|
582
|
+
boolean: str = "AND",
|
|
583
|
+
) -> QueryBuilder:
|
|
584
|
+
"""Add a WHERE column BETWEEN min AND max clause."""
|
|
585
|
+
if isinstance(values, (str, bytes)) or len(values) != 2:
|
|
586
|
+
raise QueryError("where_between expects a sequence of exactly two elements (min, max).")
|
|
587
|
+
col = validate_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
588
|
+
bool_op = _validate_boolean(boolean, context="WHERE")
|
|
589
|
+
self._where_clauses.append(
|
|
590
|
+
_WhereBetweenClause(bool_op, col, (values[0], values[1]), negated=False)
|
|
591
|
+
)
|
|
592
|
+
return self
|
|
593
|
+
|
|
594
|
+
def where_not_between(
|
|
595
|
+
self,
|
|
596
|
+
column: str,
|
|
597
|
+
values: Sequence[Any],
|
|
598
|
+
*,
|
|
599
|
+
boolean: str = "AND",
|
|
600
|
+
) -> QueryBuilder:
|
|
601
|
+
"""Add a WHERE column NOT BETWEEN min AND max clause."""
|
|
602
|
+
if isinstance(values, (str, bytes)) or len(values) != 2:
|
|
603
|
+
raise QueryError("where_not_between expects a sequence of exactly two elements (min, max).")
|
|
604
|
+
col = validate_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
605
|
+
bool_op = _validate_boolean(boolean, context="WHERE")
|
|
606
|
+
self._where_clauses.append(
|
|
607
|
+
_WhereBetweenClause(bool_op, col, (values[0], values[1]), negated=True)
|
|
608
|
+
)
|
|
609
|
+
return self
|
|
610
|
+
|
|
611
|
+
def or_where_between(
|
|
612
|
+
self,
|
|
613
|
+
column: str,
|
|
614
|
+
values: Sequence[Any],
|
|
615
|
+
) -> QueryBuilder:
|
|
616
|
+
"""Add an OR WHERE column BETWEEN min AND max clause."""
|
|
617
|
+
return self.where_between(column, values, boolean="OR")
|
|
618
|
+
|
|
619
|
+
def or_where_not_between(
|
|
620
|
+
self,
|
|
621
|
+
column: str,
|
|
622
|
+
values: Sequence[Any],
|
|
623
|
+
) -> QueryBuilder:
|
|
624
|
+
"""Add an OR WHERE column NOT BETWEEN min AND max clause."""
|
|
625
|
+
return self.where_not_between(column, values, boolean="OR")
|
|
626
|
+
|
|
627
|
+
def where_group(
|
|
628
|
+
self,
|
|
629
|
+
callback: Callable[[QueryBuilder], Any],
|
|
630
|
+
*,
|
|
631
|
+
boolean: str = "AND",
|
|
632
|
+
) -> QueryBuilder:
|
|
633
|
+
"""
|
|
634
|
+
Add a nested WHERE group with explicit boolean grouping (parentheses).
|
|
635
|
+
|
|
636
|
+
Example:
|
|
637
|
+
Query.table("users").where("status", "active").where_group(
|
|
638
|
+
lambda q: q.where("role", "admin").or_where("role", "moderator")
|
|
639
|
+
).where("age", ">=", 18)
|
|
640
|
+
Compiles to:
|
|
641
|
+
WHERE "status" = $1 AND ("role" = $2 OR "role" = $3) AND "age" >= $4
|
|
642
|
+
"""
|
|
643
|
+
bool_op = _validate_boolean(boolean, context="WHERE")
|
|
644
|
+
sub_builder = QueryBuilder(self._table)
|
|
645
|
+
callback(sub_builder)
|
|
646
|
+
if sub_builder._where_clauses:
|
|
647
|
+
self._where_clauses.append(
|
|
648
|
+
_WhereGroupClause(bool_op, tuple(sub_builder._where_clauses))
|
|
649
|
+
)
|
|
650
|
+
return self
|
|
651
|
+
|
|
652
|
+
def or_where_group(
|
|
653
|
+
self,
|
|
654
|
+
callback: Callable[[QueryBuilder], Any],
|
|
655
|
+
) -> QueryBuilder:
|
|
656
|
+
"""Add an OR nested WHERE group."""
|
|
657
|
+
return self.where_group(callback, boolean="OR")
|
|
658
|
+
|
|
659
|
+
def where_raw(
|
|
660
|
+
self,
|
|
661
|
+
sql: str,
|
|
662
|
+
bindings: Sequence[Any] | None = None,
|
|
663
|
+
*,
|
|
664
|
+
boolean: str = "AND",
|
|
665
|
+
) -> QueryBuilder:
|
|
666
|
+
"""
|
|
667
|
+
Add a trusted raw WHERE expression.
|
|
668
|
+
|
|
669
|
+
SECURITY CONTRACT:
|
|
670
|
+
- The `sql` parameter is trusted, application-controlled SQL. Never interpolate
|
|
671
|
+
or concatenate untrusted user input directly into `sql`.
|
|
672
|
+
- All dynamic, user-supplied values MUST be passed as parameterized positional
|
|
673
|
+
placeholders via `bindings` (using `?` in `sql`), which are safely compiled into
|
|
674
|
+
numbered PostgreSQL placeholders (`$1, $2, ...`).
|
|
675
|
+
"""
|
|
676
|
+
if not isinstance(sql, str):
|
|
677
|
+
raise TypeError("where_raw SQL expression must be a string.")
|
|
678
|
+
bool_op = _validate_boolean(boolean, context="WHERE")
|
|
679
|
+
bindings_list = _normalize_raw_bindings(sql, bindings, method="where_raw")
|
|
680
|
+
raw = Raw(sql, bindings_list)
|
|
681
|
+
self._raw_wheres.append(raw)
|
|
682
|
+
self._where_clauses.append(_WhereRawClause(bool_op, raw))
|
|
683
|
+
return self
|
|
684
|
+
|
|
685
|
+
def or_where_raw(
|
|
686
|
+
self,
|
|
687
|
+
sql: str,
|
|
688
|
+
bindings: Sequence[Any] | None = None,
|
|
689
|
+
) -> QueryBuilder:
|
|
690
|
+
"""Add an OR WHERE raw expression."""
|
|
691
|
+
return self.where_raw(sql, bindings, boolean="OR")
|
|
692
|
+
|
|
693
|
+
def where_null(self, column: str, *, boolean: str = "AND") -> QueryBuilder:
|
|
694
|
+
"""Add an 'IS NULL' clause for the specified column."""
|
|
695
|
+
bool_op = _validate_boolean(boolean, context="WHERE")
|
|
696
|
+
safe_col = validate_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
697
|
+
raw = Raw(f"{quote_identifier(safe_col, allow_wildcard=False)} IS NULL")
|
|
698
|
+
self._raw_wheres.append(raw)
|
|
699
|
+
self._where_clauses.append(_WhereRawClause(bool_op, raw))
|
|
700
|
+
return self
|
|
701
|
+
|
|
702
|
+
def or_where_null(self, column: str) -> QueryBuilder:
|
|
703
|
+
"""Add an 'OR ... IS NULL' clause for the specified column."""
|
|
704
|
+
return self.where_null(column, boolean="OR")
|
|
705
|
+
|
|
706
|
+
def where_not_null(self, column: str, *, boolean: str = "AND") -> QueryBuilder:
|
|
707
|
+
"""Add an 'IS NOT NULL' clause for the specified column."""
|
|
708
|
+
bool_op = _validate_boolean(boolean, context="WHERE")
|
|
709
|
+
safe_col = validate_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
710
|
+
raw = Raw(f"{quote_identifier(safe_col, allow_wildcard=False)} IS NOT NULL")
|
|
711
|
+
self._raw_wheres.append(raw)
|
|
712
|
+
self._where_clauses.append(_WhereRawClause(bool_op, raw))
|
|
713
|
+
return self
|
|
714
|
+
|
|
715
|
+
def or_where_not_null(self, column: str) -> QueryBuilder:
|
|
716
|
+
"""Add an 'OR ... IS NOT NULL' clause for the specified column."""
|
|
717
|
+
return self.where_not_null(column, boolean="OR")
|
|
718
|
+
|
|
719
|
+
def join(
|
|
720
|
+
self, table: str, first: str, operator: str, second: str, type_: str = "INNER"
|
|
721
|
+
) -> QueryBuilder:
|
|
722
|
+
if operator not in {"=", "!=", "<", ">", "<=", ">="}:
|
|
723
|
+
raise ValueError(f"Unsupported join operator: {operator}")
|
|
724
|
+
if type_.upper() not in {"INNER", "LEFT", "RIGHT", "FULL"}:
|
|
725
|
+
raise ValueError(f"Unsupported join type: {type_}")
|
|
726
|
+
|
|
727
|
+
self._joins.append(
|
|
728
|
+
(
|
|
729
|
+
type_.upper(),
|
|
730
|
+
quote_identifier(table, allow_wildcard=False),
|
|
731
|
+
quote_identifier(first, allow_wildcard=False),
|
|
732
|
+
operator,
|
|
733
|
+
quote_identifier(second, allow_wildcard=False),
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
return self
|
|
737
|
+
|
|
738
|
+
def left_join(
|
|
739
|
+
self, table: str, first: str, operator: str, second: str
|
|
740
|
+
) -> QueryBuilder:
|
|
741
|
+
return self.join(table, first, operator, second, "LEFT")
|
|
742
|
+
|
|
743
|
+
def right_join(
|
|
744
|
+
self, table: str, first: str, operator: str, second: str
|
|
745
|
+
) -> QueryBuilder:
|
|
746
|
+
return self.join(table, first, operator, second, "RIGHT")
|
|
747
|
+
|
|
748
|
+
def order_by(self, column: str, direction: str = "ASC") -> QueryBuilder:
|
|
749
|
+
|
|
750
|
+
direction = direction.upper()
|
|
751
|
+
|
|
752
|
+
if direction not in {"ASC", "DESC"}:
|
|
753
|
+
raise ValueError("Direction must be ASC or DESC")
|
|
754
|
+
|
|
755
|
+
self._order_random = False
|
|
756
|
+
self._order_column = quote_identifier(column, allow_wildcard=False)
|
|
757
|
+
self._order_direction = direction
|
|
758
|
+
|
|
759
|
+
return self
|
|
760
|
+
|
|
761
|
+
def latest(self, column: str = "created_at") -> QueryBuilder:
|
|
762
|
+
"""Order records descending by the specified column (default: created_at)."""
|
|
763
|
+
return self.order_by(column, "DESC")
|
|
764
|
+
|
|
765
|
+
def oldest(self, column: str = "created_at") -> QueryBuilder:
|
|
766
|
+
"""Order records ascending by the specified column (default: created_at)."""
|
|
767
|
+
return self.order_by(column, "ASC")
|
|
768
|
+
|
|
769
|
+
def in_random_order(self) -> QueryBuilder:
|
|
770
|
+
"""Order records randomly using PostgreSQL RANDOM()."""
|
|
771
|
+
self._order_random = True
|
|
772
|
+
return self
|
|
773
|
+
|
|
774
|
+
def when(
|
|
775
|
+
self,
|
|
776
|
+
condition: Any,
|
|
777
|
+
callback: Callable[[QueryBuilder, Any], Any],
|
|
778
|
+
default: Callable[[QueryBuilder, Any], Any] | None = None,
|
|
779
|
+
) -> QueryBuilder:
|
|
780
|
+
"""Apply the callback if condition is truthy; otherwise apply default callback if provided."""
|
|
781
|
+
if condition:
|
|
782
|
+
res = callback(self, condition)
|
|
783
|
+
return res if isinstance(res, QueryBuilder) else self
|
|
784
|
+
elif default is not None:
|
|
785
|
+
res = default(self, condition)
|
|
786
|
+
return res if isinstance(res, QueryBuilder) else self
|
|
787
|
+
return self
|
|
788
|
+
|
|
789
|
+
def unless(
|
|
790
|
+
self,
|
|
791
|
+
condition: Any,
|
|
792
|
+
callback: Callable[[QueryBuilder, Any], Any],
|
|
793
|
+
default: Callable[[QueryBuilder, Any], Any] | None = None,
|
|
794
|
+
) -> QueryBuilder:
|
|
795
|
+
"""Apply the callback if condition is falsy; otherwise apply default callback if provided."""
|
|
796
|
+
return self.when(not condition, callback, default)
|
|
797
|
+
|
|
798
|
+
def limit(self, value: int) -> QueryBuilder:
|
|
799
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
800
|
+
raise TypeError("LIMIT must be an integer.")
|
|
801
|
+
if value < 0:
|
|
802
|
+
raise ValueError("LIMIT cannot be negative.")
|
|
803
|
+
self._limit_value = value
|
|
804
|
+
return self
|
|
805
|
+
|
|
806
|
+
def offset(self, value: int) -> QueryBuilder:
|
|
807
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
808
|
+
raise TypeError("OFFSET must be an integer.")
|
|
809
|
+
if value < 0:
|
|
810
|
+
raise ValueError("OFFSET cannot be negative.")
|
|
811
|
+
self._offset_value = value
|
|
812
|
+
return self
|
|
813
|
+
|
|
814
|
+
def take(self, value: int) -> QueryBuilder:
|
|
815
|
+
"""Alias for limit() matching Laravel query conventions."""
|
|
816
|
+
return self.limit(value)
|
|
817
|
+
|
|
818
|
+
def skip(self, value: int) -> QueryBuilder:
|
|
819
|
+
"""Alias for offset() matching Laravel query conventions."""
|
|
820
|
+
return self.offset(value)
|
|
821
|
+
|
|
822
|
+
def group_by(self, *columns: str | Sequence[str]) -> QueryBuilder:
|
|
823
|
+
"""Add one or more column expressions to the GROUP BY clause."""
|
|
824
|
+
if not columns:
|
|
825
|
+
return self
|
|
826
|
+
|
|
827
|
+
flat_cols: list[str] = []
|
|
828
|
+
for item in columns:
|
|
829
|
+
if isinstance(item, (list, tuple)):
|
|
830
|
+
for sub in item:
|
|
831
|
+
if isinstance(sub, bool) or not isinstance(sub, str):
|
|
832
|
+
raise TypeError(
|
|
833
|
+
f"Group by column must be a str, got {type(sub).__name__}."
|
|
834
|
+
)
|
|
835
|
+
flat_cols.append(sub)
|
|
836
|
+
elif isinstance(item, str):
|
|
837
|
+
flat_cols.append(item)
|
|
838
|
+
else:
|
|
839
|
+
raise TypeError(
|
|
840
|
+
f"Group by column must be a str, got {type(item).__name__}."
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
for col in flat_cols:
|
|
844
|
+
safe_col = quote_identifier(col, allow_dotted=True, allow_wildcard=False)
|
|
845
|
+
if safe_col not in self._groups:
|
|
846
|
+
self._groups.append(safe_col)
|
|
847
|
+
|
|
848
|
+
return self
|
|
849
|
+
|
|
850
|
+
def having(
|
|
851
|
+
self,
|
|
852
|
+
column: str,
|
|
853
|
+
operator_or_value: Any,
|
|
854
|
+
value: Any = _MISSING,
|
|
855
|
+
boolean: str = "AND",
|
|
856
|
+
) -> QueryBuilder:
|
|
857
|
+
"""Add a condition to the HAVING clause filtering grouped aggregates."""
|
|
858
|
+
if not isinstance(column, str) or not column.strip():
|
|
859
|
+
raise TypeError("HAVING column or expression must be a non-empty string.")
|
|
860
|
+
|
|
861
|
+
if value is _MISSING:
|
|
862
|
+
operator = "="
|
|
863
|
+
val = operator_or_value
|
|
864
|
+
else:
|
|
865
|
+
operator = " ".join(str(operator_or_value).upper().split())
|
|
866
|
+
val = value
|
|
867
|
+
|
|
868
|
+
allowed_operators = {
|
|
869
|
+
"=",
|
|
870
|
+
"!=",
|
|
871
|
+
"<>",
|
|
872
|
+
"<",
|
|
873
|
+
">",
|
|
874
|
+
"<=",
|
|
875
|
+
">=",
|
|
876
|
+
"LIKE",
|
|
877
|
+
"ILIKE",
|
|
878
|
+
"NOT LIKE",
|
|
879
|
+
"NOT ILIKE",
|
|
880
|
+
"IS",
|
|
881
|
+
"IS NOT",
|
|
882
|
+
}
|
|
883
|
+
if operator not in allowed_operators:
|
|
884
|
+
raise ValueError(f"Operator '{operator}' is not allowed in HAVING clause.")
|
|
885
|
+
|
|
886
|
+
col = column.strip()
|
|
887
|
+
if col not in self._select_aliases and _parse_having_aggregate(col) is None:
|
|
888
|
+
validate_identifier(col, allow_dotted=True, allow_wildcard=False)
|
|
889
|
+
|
|
890
|
+
bool_op = _validate_boolean(boolean, context="HAVING")
|
|
891
|
+
|
|
892
|
+
self._havings.append((bool_op, col, operator, val))
|
|
893
|
+
return self
|
|
894
|
+
|
|
895
|
+
def or_having(
|
|
896
|
+
self,
|
|
897
|
+
column: str,
|
|
898
|
+
operator_or_value: Any,
|
|
899
|
+
value: Any = _MISSING,
|
|
900
|
+
) -> QueryBuilder:
|
|
901
|
+
"""Add an OR condition to the HAVING clause filtering grouped aggregates."""
|
|
902
|
+
return self.having(column, operator_or_value, value, boolean="OR")
|
|
903
|
+
|
|
904
|
+
def having_raw(
|
|
905
|
+
self,
|
|
906
|
+
sql: str,
|
|
907
|
+
bindings: Sequence[Any] | None = None,
|
|
908
|
+
boolean: str = "AND",
|
|
909
|
+
) -> QueryBuilder:
|
|
910
|
+
"""
|
|
911
|
+
Add a raw SQL condition to the HAVING clause with parameter bindings.
|
|
912
|
+
|
|
913
|
+
SECURITY CONTRACT:
|
|
914
|
+
- The `sql` parameter is trusted, application-authored SQL. Never interpolate
|
|
915
|
+
or concatenate untrusted user input directly into `sql`.
|
|
916
|
+
- All dynamic, user-supplied values MUST be passed via `bindings` (using `?` in `sql`),
|
|
917
|
+
which are safely converted into numbered PostgreSQL placeholders (`$1, $2, ...`).
|
|
918
|
+
"""
|
|
919
|
+
if not isinstance(sql, str):
|
|
920
|
+
raise TypeError("HAVING raw expression must be a string.")
|
|
921
|
+
|
|
922
|
+
bindings_list = _normalize_raw_bindings(sql, bindings, method="having_raw")
|
|
923
|
+
bool_op = _validate_boolean(boolean, context="HAVING")
|
|
924
|
+
self._raw_havings.append((bool_op, Raw(sql, bindings_list)))
|
|
925
|
+
return self
|
|
926
|
+
|
|
927
|
+
def or_having_raw(
|
|
928
|
+
self, sql: str, bindings: Sequence[Any] | None = None
|
|
929
|
+
) -> QueryBuilder:
|
|
930
|
+
"""Add an OR raw SQL condition to the HAVING clause with parameter bindings."""
|
|
931
|
+
return self.having_raw(sql, bindings, boolean="OR")
|
|
932
|
+
|
|
933
|
+
@staticmethod
|
|
934
|
+
def _get_pool() -> asyncpg.Pool:
|
|
935
|
+
container = get_container()
|
|
936
|
+
db_pool = container.get_singleton(DatabasePool)
|
|
937
|
+
if db_pool is None:
|
|
938
|
+
db_pool = container.resolve(DatabasePool)
|
|
939
|
+
return db_pool.get_pool()
|
|
940
|
+
|
|
941
|
+
async def _connection(self) -> asyncpg.Connection:
|
|
942
|
+
tx_conn = get_active_connection()
|
|
943
|
+
|
|
944
|
+
if tx_conn is not None:
|
|
945
|
+
return tx_conn
|
|
946
|
+
|
|
947
|
+
pool = self._get_pool()
|
|
948
|
+
return await pool.acquire()
|
|
949
|
+
|
|
950
|
+
async def _release(self, conn: asyncpg.Connection) -> None:
|
|
951
|
+
if get_active_connection() is not None:
|
|
952
|
+
return
|
|
953
|
+
|
|
954
|
+
pool = self._get_pool()
|
|
955
|
+
await pool.release(conn)
|
|
956
|
+
|
|
957
|
+
def _compile_select(self, start_idx: int = 1) -> tuple[str, list[Any], int]:
|
|
958
|
+
bindings: list[Any] = []
|
|
959
|
+
select_parts: list[str] = []
|
|
960
|
+
idx = start_idx
|
|
961
|
+
|
|
962
|
+
for column in self._columns:
|
|
963
|
+
if isinstance(column, Column):
|
|
964
|
+
select_parts.append(column.to_sql())
|
|
965
|
+
continue
|
|
966
|
+
|
|
967
|
+
if isinstance(column, Raw):
|
|
968
|
+
sql, raw_bindings, idx = _compile_raw_sql(column, idx)
|
|
969
|
+
bindings.extend(raw_bindings)
|
|
970
|
+
select_parts.append(sql)
|
|
971
|
+
continue
|
|
972
|
+
|
|
973
|
+
select_parts.append(quote_identifier(column))
|
|
974
|
+
|
|
975
|
+
select_str = ", ".join(select_parts)
|
|
976
|
+
if self._distinct:
|
|
977
|
+
if self._distinct_columns:
|
|
978
|
+
distinct_on = ", ".join(
|
|
979
|
+
quote_identifier(c) for c in self._distinct_columns
|
|
980
|
+
)
|
|
981
|
+
select_str = f"DISTINCT ON ({distinct_on}) {select_str}"
|
|
982
|
+
else:
|
|
983
|
+
select_str = f"DISTINCT {select_str}"
|
|
984
|
+
|
|
985
|
+
return select_str, bindings, idx
|
|
986
|
+
|
|
987
|
+
def _compile_joins(self) -> str:
|
|
988
|
+
if not self._joins:
|
|
989
|
+
return ""
|
|
990
|
+
|
|
991
|
+
clauses = []
|
|
992
|
+
for type_, table, first, operator, second in self._joins:
|
|
993
|
+
clauses.append(f"{type_} JOIN {table} ON {first} {operator} {second}")
|
|
994
|
+
return " " + " ".join(clauses)
|
|
995
|
+
|
|
996
|
+
@staticmethod
|
|
997
|
+
def _compile_simple_where(
|
|
998
|
+
boolean: str,
|
|
999
|
+
column: str,
|
|
1000
|
+
operator: str,
|
|
1001
|
+
value: Any,
|
|
1002
|
+
idx: int,
|
|
1003
|
+
) -> tuple[tuple[str, str], list[Any], int]:
|
|
1004
|
+
col_quoted = quote_identifier(column)
|
|
1005
|
+
if isinstance(value, QueryBuilder):
|
|
1006
|
+
sub_sql, sub_bindings = value.to_sql(idx)
|
|
1007
|
+
return (
|
|
1008
|
+
(boolean, f"{col_quoted} {operator} ({sub_sql})"),
|
|
1009
|
+
sub_bindings,
|
|
1010
|
+
idx + len(sub_bindings),
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
if operator in {"IN", "NOT IN"} and isinstance(value, (list, tuple)):
|
|
1014
|
+
if not value:
|
|
1015
|
+
return (boolean, "FALSE" if operator == "IN" else "TRUE"), [], idx
|
|
1016
|
+
placeholders = [f"${idx + i}" for i in range(len(value))]
|
|
1017
|
+
return (
|
|
1018
|
+
(boolean, f"{col_quoted} {operator} ({', '.join(placeholders)})"),
|
|
1019
|
+
list(value),
|
|
1020
|
+
idx + len(value),
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
if value is None and operator in {"IS", "IS NOT"}:
|
|
1024
|
+
return (boolean, f"{col_quoted} {operator} NULL"), [], idx
|
|
1025
|
+
|
|
1026
|
+
if operator in {"LIKE", "ILIKE"}:
|
|
1027
|
+
return (
|
|
1028
|
+
(boolean, f"{col_quoted} {operator} ${idx} ESCAPE '\\'"),
|
|
1029
|
+
[value],
|
|
1030
|
+
idx + 1,
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
return (boolean, f"{col_quoted} {operator} ${idx}"), [value], idx + 1
|
|
1034
|
+
|
|
1035
|
+
@staticmethod
|
|
1036
|
+
def _compile_clauses(
|
|
1037
|
+
clause_list: Sequence[_WhereClauseType], start_idx: int
|
|
1038
|
+
) -> tuple[list[tuple[str, str]], list[Any], int]:
|
|
1039
|
+
clauses: list[tuple[str, str]] = []
|
|
1040
|
+
bindings: list[Any] = []
|
|
1041
|
+
idx = start_idx
|
|
1042
|
+
|
|
1043
|
+
for clause in clause_list:
|
|
1044
|
+
if isinstance(clause, _WhereBetweenClause):
|
|
1045
|
+
col_quoted = quote_identifier(clause.column)
|
|
1046
|
+
op_str = "NOT BETWEEN" if clause.negated else "BETWEEN"
|
|
1047
|
+
clauses.append(
|
|
1048
|
+
(clause.boolean, f"{col_quoted} {op_str} ${idx} AND ${idx + 1}")
|
|
1049
|
+
)
|
|
1050
|
+
bindings.extend(clause.values)
|
|
1051
|
+
idx += 2
|
|
1052
|
+
continue
|
|
1053
|
+
|
|
1054
|
+
if isinstance(clause, _WhereRawClause):
|
|
1055
|
+
sql, raw_bindings, idx = _compile_raw_sql(clause.raw, idx)
|
|
1056
|
+
bindings.extend(raw_bindings)
|
|
1057
|
+
clauses.append((clause.boolean, sql))
|
|
1058
|
+
continue
|
|
1059
|
+
|
|
1060
|
+
if isinstance(clause, _WhereGroupClause):
|
|
1061
|
+
inner_clauses, inner_bindings, next_idx = QueryBuilder._compile_clauses(
|
|
1062
|
+
clause.clauses, idx
|
|
1063
|
+
)
|
|
1064
|
+
if not inner_clauses:
|
|
1065
|
+
continue
|
|
1066
|
+
inner_parts: list[str] = [inner_clauses[0][1]]
|
|
1067
|
+
for bool_op, clause_str in inner_clauses[1:]:
|
|
1068
|
+
inner_parts.append(f"{bool_op} {clause_str}")
|
|
1069
|
+
inner_sql = f"({' '.join(inner_parts)})"
|
|
1070
|
+
clauses.append((clause.boolean, inner_sql))
|
|
1071
|
+
bindings.extend(inner_bindings)
|
|
1072
|
+
idx = next_idx
|
|
1073
|
+
continue
|
|
1074
|
+
|
|
1075
|
+
if isinstance(clause, _WhereClause):
|
|
1076
|
+
compiled_clause, clause_bindings, idx = QueryBuilder._compile_simple_where(
|
|
1077
|
+
clause.boolean, clause.column, clause.operator, clause.value, idx
|
|
1078
|
+
)
|
|
1079
|
+
clauses.append(compiled_clause)
|
|
1080
|
+
bindings.extend(clause_bindings)
|
|
1081
|
+
continue
|
|
1082
|
+
|
|
1083
|
+
return clauses, bindings, idx
|
|
1084
|
+
|
|
1085
|
+
def _compile_where(self, start_idx: int = 1) -> tuple[str, list[Any], int]:
|
|
1086
|
+
if not self._where_clauses and not self._wheres and not self._raw_wheres:
|
|
1087
|
+
return "", [], start_idx
|
|
1088
|
+
|
|
1089
|
+
if self._where_clauses:
|
|
1090
|
+
clauses, bindings, idx = self._compile_clauses(self._where_clauses, start_idx)
|
|
1091
|
+
else:
|
|
1092
|
+
clauses = []
|
|
1093
|
+
bindings = []
|
|
1094
|
+
idx = start_idx
|
|
1095
|
+
for column, operator, value in self._wheres:
|
|
1096
|
+
compiled_clause, clause_bindings, idx = QueryBuilder._compile_simple_where(
|
|
1097
|
+
"AND", column, operator, value, idx
|
|
1098
|
+
)
|
|
1099
|
+
clauses.append(compiled_clause)
|
|
1100
|
+
bindings.extend(clause_bindings)
|
|
1101
|
+
|
|
1102
|
+
for raw in self._raw_wheres:
|
|
1103
|
+
sql, raw_bindings, idx = _compile_raw_sql(raw, idx)
|
|
1104
|
+
bindings.extend(raw_bindings)
|
|
1105
|
+
clauses.append(("AND", sql))
|
|
1106
|
+
|
|
1107
|
+
if not clauses:
|
|
1108
|
+
return "", [], idx
|
|
1109
|
+
|
|
1110
|
+
sql_parts: list[str] = [clauses[0][1]]
|
|
1111
|
+
for bool_op, clause_str in clauses[1:]:
|
|
1112
|
+
sql_parts.append(f"{bool_op} {clause_str}")
|
|
1113
|
+
|
|
1114
|
+
return " WHERE " + " ".join(sql_parts), bindings, idx
|
|
1115
|
+
|
|
1116
|
+
def _compile_groups(self) -> str:
|
|
1117
|
+
if not self._groups:
|
|
1118
|
+
return ""
|
|
1119
|
+
return " GROUP BY " + ", ".join(self._groups)
|
|
1120
|
+
|
|
1121
|
+
def _compile_having(self, start_idx: int = 1) -> tuple[str, list[Any], int]:
|
|
1122
|
+
clauses: list[tuple[str, str]] = []
|
|
1123
|
+
bindings: list[Any] = []
|
|
1124
|
+
idx = start_idx
|
|
1125
|
+
|
|
1126
|
+
for bool_op, column, operator, value in self._havings:
|
|
1127
|
+
if column in self._select_aliases:
|
|
1128
|
+
expr = self._select_aliases[column]
|
|
1129
|
+
elif (agg_expr := _parse_having_aggregate(column)) is not None:
|
|
1130
|
+
expr = agg_expr
|
|
1131
|
+
else:
|
|
1132
|
+
expr = quote_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
1133
|
+
|
|
1134
|
+
if operator in {"IS", "IS NOT"}:
|
|
1135
|
+
if value is not None:
|
|
1136
|
+
raise ValueError(
|
|
1137
|
+
f"Operator '{operator}' in HAVING clause only supports None/NULL value."
|
|
1138
|
+
)
|
|
1139
|
+
clauses.append((bool_op, f"{expr} {operator} NULL"))
|
|
1140
|
+
continue
|
|
1141
|
+
|
|
1142
|
+
clauses.append((bool_op, f"{expr} {operator} ${idx}"))
|
|
1143
|
+
bindings.append(value)
|
|
1144
|
+
idx += 1
|
|
1145
|
+
|
|
1146
|
+
for bool_op, raw in self._raw_havings:
|
|
1147
|
+
sql, raw_bindings, idx = _compile_raw_sql(raw, idx)
|
|
1148
|
+
bindings.extend(raw_bindings)
|
|
1149
|
+
clauses.append((bool_op, sql))
|
|
1150
|
+
|
|
1151
|
+
if not clauses:
|
|
1152
|
+
return "", [], idx
|
|
1153
|
+
|
|
1154
|
+
sql_parts: list[str] = [clauses[0][1]]
|
|
1155
|
+
for bool_op, clause_str in clauses[1:]:
|
|
1156
|
+
sql_parts.append(f"{bool_op} {clause_str}")
|
|
1157
|
+
|
|
1158
|
+
return " HAVING " + " ".join(sql_parts), bindings, idx
|
|
1159
|
+
|
|
1160
|
+
def to_sql(self, start_idx: int = 1) -> tuple[str, list[Any]]:
|
|
1161
|
+
"""Compiles the entire query to raw SQL and bindings."""
|
|
1162
|
+
select_sql, select_bindings, idx = self._compile_select(start_idx)
|
|
1163
|
+
join_sql = self._compile_joins()
|
|
1164
|
+
where_sql, where_bindings, idx = self._compile_where(idx)
|
|
1165
|
+
group_sql = self._compile_groups()
|
|
1166
|
+
having_sql, having_bindings, idx = self._compile_having(idx)
|
|
1167
|
+
|
|
1168
|
+
bindings = select_bindings + where_bindings + having_bindings
|
|
1169
|
+
sql = f"SELECT {select_sql} FROM {quote_identifier(self._table)}{join_sql}{where_sql}{group_sql}{having_sql}"
|
|
1170
|
+
|
|
1171
|
+
if self._order_random:
|
|
1172
|
+
sql += " ORDER BY RANDOM()"
|
|
1173
|
+
elif self._order_column:
|
|
1174
|
+
sql += f" ORDER BY {self._order_column} {self._order_direction}"
|
|
1175
|
+
|
|
1176
|
+
if self._limit_value is not None:
|
|
1177
|
+
sql += f" LIMIT ${idx}"
|
|
1178
|
+
bindings.append(self._limit_value)
|
|
1179
|
+
idx += 1
|
|
1180
|
+
|
|
1181
|
+
if self._offset_value is not None:
|
|
1182
|
+
sql += f" OFFSET ${idx}"
|
|
1183
|
+
bindings.append(self._offset_value)
|
|
1184
|
+
idx += 1
|
|
1185
|
+
|
|
1186
|
+
return sql, bindings
|
|
1187
|
+
|
|
1188
|
+
async def get(self) -> list[dict[str, Any]]:
|
|
1189
|
+
sql, bindings = self.to_sql()
|
|
1190
|
+
conn = await self._connection()
|
|
1191
|
+
try:
|
|
1192
|
+
rows = await conn.fetch(sql, *bindings)
|
|
1193
|
+
return [dict(row) for row in rows]
|
|
1194
|
+
finally:
|
|
1195
|
+
await self._release(conn)
|
|
1196
|
+
|
|
1197
|
+
async def first(self) -> dict[str, Any] | None:
|
|
1198
|
+
rows = await self.clone().limit(1).get()
|
|
1199
|
+
return rows[0] if rows else None
|
|
1200
|
+
|
|
1201
|
+
async def first_or_fail(self) -> dict[str, Any]:
|
|
1202
|
+
"""Fetch the first record or raise RecordNotFoundError if no record matches."""
|
|
1203
|
+
row = await self.first()
|
|
1204
|
+
if row is None:
|
|
1205
|
+
raise RecordNotFoundError(f"No query results for table [{self._table}].")
|
|
1206
|
+
return row
|
|
1207
|
+
|
|
1208
|
+
async def value(self, column: str) -> Any | None:
|
|
1209
|
+
"""Fetch the value of a single column from the first matching row."""
|
|
1210
|
+
col = validate_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
1211
|
+
clone = self.clone().select(col).limit(1)
|
|
1212
|
+
row = await clone.first()
|
|
1213
|
+
if row is None:
|
|
1214
|
+
return None
|
|
1215
|
+
col_name = col.split(".")[-1]
|
|
1216
|
+
return row.get(col_name, row.get(col))
|
|
1217
|
+
|
|
1218
|
+
@overload
|
|
1219
|
+
async def pluck(self, column: str, key: None = None) -> list[Any]: ...
|
|
1220
|
+
|
|
1221
|
+
@overload
|
|
1222
|
+
async def pluck(self, column: str, key: str) -> dict[Any, Any]: ...
|
|
1223
|
+
|
|
1224
|
+
async def pluck(
|
|
1225
|
+
self, column: str, key: str | None = None
|
|
1226
|
+
) -> list[Any] | dict[Any, Any]:
|
|
1227
|
+
"""
|
|
1228
|
+
Retrieve an array or dictionary of values for the given column.
|
|
1229
|
+
If key is specified, returns a dictionary mapping key column values to target column values.
|
|
1230
|
+
"""
|
|
1231
|
+
col = validate_identifier(column, allow_dotted=True, allow_wildcard=False)
|
|
1232
|
+
col_name = col.split(".")[-1]
|
|
1233
|
+
if key is not None:
|
|
1234
|
+
k_col = validate_identifier(key, allow_dotted=True, allow_wildcard=False)
|
|
1235
|
+
k_name = k_col.split(".")[-1]
|
|
1236
|
+
clone = self.clone().select(col, k_col)
|
|
1237
|
+
rows = await clone.get()
|
|
1238
|
+
return {
|
|
1239
|
+
r.get(k_name, r.get(k_col)): r.get(col_name, r.get(col))
|
|
1240
|
+
for r in rows
|
|
1241
|
+
}
|
|
1242
|
+
clone = self.clone().select(col)
|
|
1243
|
+
rows = await clone.get()
|
|
1244
|
+
return [r.get(col_name, r.get(col)) for r in rows]
|
|
1245
|
+
|
|
1246
|
+
async def exists(self) -> bool:
|
|
1247
|
+
"""Return True if at least one row matches the current query."""
|
|
1248
|
+
cloned = self.clone()
|
|
1249
|
+
cloned._columns = [Raw("1")]
|
|
1250
|
+
cloned._order_column = None
|
|
1251
|
+
row = await cloned.first()
|
|
1252
|
+
return row is not None
|
|
1253
|
+
|
|
1254
|
+
async def _aggregate(self, function: str, column: str) -> Any:
|
|
1255
|
+
col_safe = (
|
|
1256
|
+
quote_identifier(column, allow_wildcard=False) if column != "*" else "*"
|
|
1257
|
+
)
|
|
1258
|
+
cloned = self.clone()
|
|
1259
|
+
cloned._columns = [Raw(f"{function}({col_safe}) AS agg_result")]
|
|
1260
|
+
cloned._order_column = None
|
|
1261
|
+
cloned._limit_value = None
|
|
1262
|
+
cloned._offset_value = None
|
|
1263
|
+
rows = await cloned.get()
|
|
1264
|
+
return rows[0]["agg_result"] if rows else None
|
|
1265
|
+
|
|
1266
|
+
async def count(self, column: str = "*") -> int:
|
|
1267
|
+
return cast(int, await self._aggregate("COUNT", column))
|
|
1268
|
+
|
|
1269
|
+
async def sum(self, column: str) -> Any:
|
|
1270
|
+
return await self._aggregate("SUM", column)
|
|
1271
|
+
|
|
1272
|
+
async def max(self, column: str) -> Any:
|
|
1273
|
+
return await self._aggregate("MAX", column)
|
|
1274
|
+
|
|
1275
|
+
async def min(self, column: str) -> Any:
|
|
1276
|
+
return await self._aggregate("MIN", column)
|
|
1277
|
+
|
|
1278
|
+
async def avg(self, column: str) -> Any:
|
|
1279
|
+
return await self._aggregate("AVG", column)
|
|
1280
|
+
|
|
1281
|
+
async def paginate(
|
|
1282
|
+
self, per_page: int = 15, page: int = 1
|
|
1283
|
+
) -> Paginator[dict[str, Any]]:
|
|
1284
|
+
"""
|
|
1285
|
+
Execute the query with length-aware pagination, counting total matching records or groups.
|
|
1286
|
+
"""
|
|
1287
|
+
if isinstance(page, bool) or not isinstance(page, int):
|
|
1288
|
+
raise TypeError("page must be an integer.")
|
|
1289
|
+
if page < 1:
|
|
1290
|
+
raise ValueError("page must be >= 1.")
|
|
1291
|
+
if isinstance(per_page, bool) or not isinstance(per_page, int):
|
|
1292
|
+
raise TypeError("per_page must be an integer.")
|
|
1293
|
+
if per_page < 1:
|
|
1294
|
+
raise ValueError("per_page must be >= 1.")
|
|
1295
|
+
|
|
1296
|
+
offset = (page - 1) * per_page
|
|
1297
|
+
|
|
1298
|
+
count_builder = self.clone()
|
|
1299
|
+
count_builder._limit_value = None
|
|
1300
|
+
count_builder._offset_value = None
|
|
1301
|
+
count_builder._order_column = None
|
|
1302
|
+
|
|
1303
|
+
if (
|
|
1304
|
+
count_builder._groups
|
|
1305
|
+
or count_builder._havings
|
|
1306
|
+
or count_builder._raw_havings
|
|
1307
|
+
):
|
|
1308
|
+
if count_builder._columns == ["*"] and count_builder._groups:
|
|
1309
|
+
count_builder._columns = list(count_builder._groups)
|
|
1310
|
+
|
|
1311
|
+
inner_sql, inner_bindings = count_builder.to_sql(1)
|
|
1312
|
+
count_sql = f"SELECT COUNT(*) AS total FROM ({inner_sql}) AS grouped_results"
|
|
1313
|
+
conn = await count_builder._connection()
|
|
1314
|
+
try:
|
|
1315
|
+
val = await conn.fetchval(count_sql, *inner_bindings)
|
|
1316
|
+
total = int(val or 0)
|
|
1317
|
+
finally:
|
|
1318
|
+
await count_builder._release(conn)
|
|
1319
|
+
else:
|
|
1320
|
+
total = await count_builder.count()
|
|
1321
|
+
|
|
1322
|
+
data_builder = self.clone()
|
|
1323
|
+
data_builder.limit(per_page).offset(offset)
|
|
1324
|
+
items = await data_builder.get()
|
|
1325
|
+
|
|
1326
|
+
last_page = max(1, math.ceil(total / per_page))
|
|
1327
|
+
return Paginator(
|
|
1328
|
+
items=items,
|
|
1329
|
+
total=total,
|
|
1330
|
+
per_page=per_page,
|
|
1331
|
+
current_page=page,
|
|
1332
|
+
last_page=last_page,
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
async def simple_paginate(
|
|
1336
|
+
self, per_page: int = 15, page: int = 1
|
|
1337
|
+
) -> SimplePaginator[dict[str, Any]]:
|
|
1338
|
+
"""
|
|
1339
|
+
Execute the query with lightweight pagination without counting total records.
|
|
1340
|
+
"""
|
|
1341
|
+
if isinstance(page, bool) or not isinstance(page, int):
|
|
1342
|
+
raise TypeError("page must be an integer.")
|
|
1343
|
+
if page < 1:
|
|
1344
|
+
raise ValueError("page must be >= 1.")
|
|
1345
|
+
if isinstance(per_page, bool) or not isinstance(per_page, int):
|
|
1346
|
+
raise TypeError("per_page must be an integer.")
|
|
1347
|
+
if per_page < 1:
|
|
1348
|
+
raise ValueError("per_page must be >= 1.")
|
|
1349
|
+
|
|
1350
|
+
offset = (page - 1) * per_page
|
|
1351
|
+
|
|
1352
|
+
data_builder = self.clone()
|
|
1353
|
+
data_builder.limit(per_page + 1).offset(offset)
|
|
1354
|
+
raw_items = await data_builder.get()
|
|
1355
|
+
|
|
1356
|
+
has_more = len(raw_items) > per_page
|
|
1357
|
+
items = raw_items[:per_page] if has_more else raw_items
|
|
1358
|
+
|
|
1359
|
+
return SimplePaginator(
|
|
1360
|
+
items=items,
|
|
1361
|
+
per_page=per_page,
|
|
1362
|
+
current_page=page,
|
|
1363
|
+
has_more=has_more,
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
async def insert_returning(
|
|
1367
|
+
self,
|
|
1368
|
+
data: dict[str, Any],
|
|
1369
|
+
*,
|
|
1370
|
+
returning: Sequence[str] | str | None = None,
|
|
1371
|
+
allow_default_values: bool = False,
|
|
1372
|
+
) -> dict[str, Any]:
|
|
1373
|
+
"""Insert a row and return the RETURNING row as a dictionary."""
|
|
1374
|
+
if not isinstance(data, dict) or (not data and not allow_default_values):
|
|
1375
|
+
raise QueryError("Cannot execute INSERT with empty data dictionary.")
|
|
1376
|
+
|
|
1377
|
+
if returning is None or returning == "*":
|
|
1378
|
+
returning_clause = "*"
|
|
1379
|
+
elif isinstance(returning, str):
|
|
1380
|
+
returning_clause = quote_identifier(
|
|
1381
|
+
returning, allow_dotted=False, allow_wildcard=False
|
|
1382
|
+
)
|
|
1383
|
+
else:
|
|
1384
|
+
returning_cols = [
|
|
1385
|
+
quote_identifier(col, allow_dotted=False, allow_wildcard=False)
|
|
1386
|
+
for col in returning
|
|
1387
|
+
]
|
|
1388
|
+
if not returning_cols:
|
|
1389
|
+
raise ValueError("returning list cannot be empty")
|
|
1390
|
+
returning_clause = ", ".join(returning_cols)
|
|
1391
|
+
|
|
1392
|
+
if not data:
|
|
1393
|
+
values: list[Any] = []
|
|
1394
|
+
sql = (
|
|
1395
|
+
f"INSERT INTO {quote_identifier(self._table)} "
|
|
1396
|
+
f"DEFAULT VALUES "
|
|
1397
|
+
f"RETURNING {returning_clause}"
|
|
1398
|
+
)
|
|
1399
|
+
else:
|
|
1400
|
+
columns = [
|
|
1401
|
+
quote_identifier(column, allow_dotted=False, allow_wildcard=False)
|
|
1402
|
+
for column in data
|
|
1403
|
+
]
|
|
1404
|
+
values = list(data.values())
|
|
1405
|
+
placeholders = [f"${i}" for i in range(1, len(values) + 1)]
|
|
1406
|
+
sql = (
|
|
1407
|
+
f"INSERT INTO {quote_identifier(self._table)} "
|
|
1408
|
+
f"({', '.join(columns)}) "
|
|
1409
|
+
f"VALUES ({', '.join(placeholders)}) "
|
|
1410
|
+
f"RETURNING {returning_clause}"
|
|
1411
|
+
)
|
|
1412
|
+
|
|
1413
|
+
conn = await self._connection()
|
|
1414
|
+
|
|
1415
|
+
try:
|
|
1416
|
+
row = await conn.fetchrow(sql, *values)
|
|
1417
|
+
|
|
1418
|
+
if row is None:
|
|
1419
|
+
raise RuntimeError("Insert failed")
|
|
1420
|
+
|
|
1421
|
+
return dict(row)
|
|
1422
|
+
|
|
1423
|
+
finally:
|
|
1424
|
+
await self._release(conn)
|
|
1425
|
+
|
|
1426
|
+
@overload
|
|
1427
|
+
async def insert(self, data: dict[str, Any], *, returning: None = None) -> int: ...
|
|
1428
|
+
|
|
1429
|
+
@overload
|
|
1430
|
+
async def insert(
|
|
1431
|
+
self, data: dict[str, Any], *, returning: Sequence[str] | str
|
|
1432
|
+
) -> Any: ...
|
|
1433
|
+
|
|
1434
|
+
async def insert(
|
|
1435
|
+
self,
|
|
1436
|
+
data: dict[str, Any],
|
|
1437
|
+
*,
|
|
1438
|
+
returning: Sequence[str] | str | None = None,
|
|
1439
|
+
) -> Any:
|
|
1440
|
+
row = await self.insert_returning(data, returning=returning)
|
|
1441
|
+
|
|
1442
|
+
if returning is not None:
|
|
1443
|
+
if isinstance(returning, str) and returning != "*":
|
|
1444
|
+
return row.get(returning)
|
|
1445
|
+
if not isinstance(returning, str) and len(returning) == 1:
|
|
1446
|
+
return row.get(returning[0])
|
|
1447
|
+
return row
|
|
1448
|
+
|
|
1449
|
+
if "id" in row and row["id"] is not None:
|
|
1450
|
+
try:
|
|
1451
|
+
return int(row["id"])
|
|
1452
|
+
except (ValueError, TypeError):
|
|
1453
|
+
return 0
|
|
1454
|
+
|
|
1455
|
+
return 0
|
|
1456
|
+
|
|
1457
|
+
async def update(self, data: dict[str, Any]) -> str:
|
|
1458
|
+
if not isinstance(data, dict) or not data:
|
|
1459
|
+
raise QueryError("Cannot execute UPDATE with empty data dictionary.")
|
|
1460
|
+
|
|
1461
|
+
if not self._where_clauses and not self._wheres and not self._raw_wheres and not self._allow_all:
|
|
1462
|
+
raise QueryError(
|
|
1463
|
+
"Refusing to execute UPDATE without a WHERE clause. Call .allow_all() to update all rows explicitly."
|
|
1464
|
+
)
|
|
1465
|
+
|
|
1466
|
+
bindings: list[Any] = []
|
|
1467
|
+
|
|
1468
|
+
sets: list[str] = []
|
|
1469
|
+
|
|
1470
|
+
idx = 1
|
|
1471
|
+
|
|
1472
|
+
for column, value in data.items():
|
|
1473
|
+
safe_column = quote_identifier(
|
|
1474
|
+
column, allow_dotted=False, allow_wildcard=False
|
|
1475
|
+
)
|
|
1476
|
+
sets.append(f"{safe_column} = ${idx}")
|
|
1477
|
+
bindings.append(value)
|
|
1478
|
+
idx += 1
|
|
1479
|
+
|
|
1480
|
+
where_sql, where_bindings, _ = self._compile_where(idx)
|
|
1481
|
+
|
|
1482
|
+
bindings.extend(where_bindings)
|
|
1483
|
+
|
|
1484
|
+
sql = f"UPDATE {quote_identifier(self._table)} SET {', '.join(sets)}{where_sql}"
|
|
1485
|
+
|
|
1486
|
+
conn = await self._connection()
|
|
1487
|
+
|
|
1488
|
+
try:
|
|
1489
|
+
result = await conn.execute(sql, *bindings)
|
|
1490
|
+
return cast(str, result)
|
|
1491
|
+
|
|
1492
|
+
finally:
|
|
1493
|
+
await self._release(conn)
|
|
1494
|
+
|
|
1495
|
+
async def delete(self) -> str:
|
|
1496
|
+
if not self._where_clauses and not self._wheres and not self._raw_wheres and not self._allow_all:
|
|
1497
|
+
raise QueryError(
|
|
1498
|
+
"Refusing to execute DELETE without a WHERE clause. Call .allow_all() to delete all rows explicitly."
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
where_sql, bindings, _ = self._compile_where()
|
|
1502
|
+
|
|
1503
|
+
sql = f"DELETE FROM {quote_identifier(self._table)}{where_sql}"
|
|
1504
|
+
|
|
1505
|
+
conn = await self._connection()
|
|
1506
|
+
|
|
1507
|
+
try:
|
|
1508
|
+
result = await conn.execute(sql, *bindings)
|
|
1509
|
+
return cast(str, result)
|
|
1510
|
+
|
|
1511
|
+
finally:
|
|
1512
|
+
await self._release(conn)
|
|
1513
|
+
|
|
1514
|
+
async def increment(
|
|
1515
|
+
self,
|
|
1516
|
+
column: str,
|
|
1517
|
+
amount: float = 1,
|
|
1518
|
+
extra: dict[str, Any] | None = None,
|
|
1519
|
+
) -> str:
|
|
1520
|
+
"""Atomically increment a column's value by amount."""
|
|
1521
|
+
if isinstance(amount, bool) or not isinstance(amount, (int, float)):
|
|
1522
|
+
raise TypeError(
|
|
1523
|
+
f"Amount must be a numeric int or float, got {type(amount).__name__}."
|
|
1524
|
+
)
|
|
1525
|
+
if math.isnan(amount) or math.isinf(amount):
|
|
1526
|
+
raise ValueError("Increment amount cannot be NaN or Infinity.")
|
|
1527
|
+
if amount < 0:
|
|
1528
|
+
raise ValueError("Increment amount must be non-negative. Use decrement() instead.")
|
|
1529
|
+
col = validate_identifier(column, allow_dotted=False, allow_wildcard=False)
|
|
1530
|
+
col_quoted = quote_identifier(col)
|
|
1531
|
+
|
|
1532
|
+
if extra and column in extra:
|
|
1533
|
+
raise QueryError(
|
|
1534
|
+
f"Column '{column}' cannot be passed in extra while being incremented."
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
if not self._where_clauses and not self._wheres and not self._raw_wheres and not self._allow_all:
|
|
1538
|
+
raise QueryError(
|
|
1539
|
+
"Refusing to execute increment without a WHERE clause. Call .allow_all() to update all rows explicitly."
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
sets: list[str] = [f"{col_quoted} = {col_quoted} + $1"]
|
|
1543
|
+
bindings: list[Any] = [amount]
|
|
1544
|
+
idx = 2
|
|
1545
|
+
|
|
1546
|
+
if extra:
|
|
1547
|
+
for extra_col, extra_val in extra.items():
|
|
1548
|
+
safe_col = quote_identifier(
|
|
1549
|
+
validate_identifier(extra_col, allow_dotted=False, allow_wildcard=False)
|
|
1550
|
+
)
|
|
1551
|
+
sets.append(f"{safe_col} = ${idx}")
|
|
1552
|
+
bindings.append(extra_val)
|
|
1553
|
+
idx += 1
|
|
1554
|
+
|
|
1555
|
+
where_sql, where_bindings, _ = self._compile_where(idx)
|
|
1556
|
+
bindings.extend(where_bindings)
|
|
1557
|
+
|
|
1558
|
+
sql = f"UPDATE {quote_identifier(self._table)} SET {', '.join(sets)}{where_sql}"
|
|
1559
|
+
conn = await self._connection()
|
|
1560
|
+
try:
|
|
1561
|
+
result = await conn.execute(sql, *bindings)
|
|
1562
|
+
return cast(str, result)
|
|
1563
|
+
finally:
|
|
1564
|
+
await self._release(conn)
|
|
1565
|
+
|
|
1566
|
+
async def decrement(
|
|
1567
|
+
self,
|
|
1568
|
+
column: str,
|
|
1569
|
+
amount: float = 1,
|
|
1570
|
+
extra: dict[str, Any] | None = None,
|
|
1571
|
+
) -> str:
|
|
1572
|
+
"""Atomically decrement a column's value by amount."""
|
|
1573
|
+
if isinstance(amount, bool) or not isinstance(amount, (int, float)):
|
|
1574
|
+
raise TypeError(
|
|
1575
|
+
f"Amount must be a numeric int or float, got {type(amount).__name__}."
|
|
1576
|
+
)
|
|
1577
|
+
if math.isnan(amount) or math.isinf(amount):
|
|
1578
|
+
raise ValueError("Decrement amount cannot be NaN or Infinity.")
|
|
1579
|
+
if amount < 0:
|
|
1580
|
+
raise ValueError("Decrement amount must be non-negative. Use increment() instead.")
|
|
1581
|
+
col = validate_identifier(column, allow_dotted=False, allow_wildcard=False)
|
|
1582
|
+
col_quoted = quote_identifier(col)
|
|
1583
|
+
|
|
1584
|
+
if extra and column in extra:
|
|
1585
|
+
raise QueryError(
|
|
1586
|
+
f"Column '{column}' cannot be passed in extra while being decremented."
|
|
1587
|
+
)
|
|
1588
|
+
|
|
1589
|
+
if not self._where_clauses and not self._wheres and not self._raw_wheres and not self._allow_all:
|
|
1590
|
+
raise QueryError(
|
|
1591
|
+
"Refusing to execute decrement without a WHERE clause. Call .allow_all() to update all rows explicitly."
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
sets: list[str] = [f"{col_quoted} = {col_quoted} - $1"]
|
|
1595
|
+
bindings: list[Any] = [amount]
|
|
1596
|
+
idx = 2
|
|
1597
|
+
|
|
1598
|
+
if extra:
|
|
1599
|
+
for extra_col, extra_val in extra.items():
|
|
1600
|
+
safe_col = quote_identifier(
|
|
1601
|
+
validate_identifier(extra_col, allow_dotted=False, allow_wildcard=False)
|
|
1602
|
+
)
|
|
1603
|
+
sets.append(f"{safe_col} = ${idx}")
|
|
1604
|
+
bindings.append(extra_val)
|
|
1605
|
+
idx += 1
|
|
1606
|
+
|
|
1607
|
+
where_sql, where_bindings, _ = self._compile_where(idx)
|
|
1608
|
+
bindings.extend(where_bindings)
|
|
1609
|
+
|
|
1610
|
+
sql = f"UPDATE {quote_identifier(self._table)} SET {', '.join(sets)}{where_sql}"
|
|
1611
|
+
conn = await self._connection()
|
|
1612
|
+
try:
|
|
1613
|
+
result = await conn.execute(sql, *bindings)
|
|
1614
|
+
return cast(str, result)
|
|
1615
|
+
finally:
|
|
1616
|
+
await self._release(conn)
|
|
1617
|
+
|
|
1618
|
+
async def chunk(
|
|
1619
|
+
self,
|
|
1620
|
+
count: int,
|
|
1621
|
+
callback: Callable[[list[dict[str, Any]]], Awaitable[bool | None] | bool | None],
|
|
1622
|
+
) -> bool:
|
|
1623
|
+
"""
|
|
1624
|
+
Process records in batches of size count via limit and offset.
|
|
1625
|
+
Returning False from callback terminates iteration early.
|
|
1626
|
+
"""
|
|
1627
|
+
if isinstance(count, bool) or not isinstance(count, int) or count < 1:
|
|
1628
|
+
raise ValueError("Chunk count must be an integer >= 1.")
|
|
1629
|
+
|
|
1630
|
+
base_query = self.clone()
|
|
1631
|
+
base_query._limit_value = None
|
|
1632
|
+
base_query._offset_value = None
|
|
1633
|
+
|
|
1634
|
+
page = 1
|
|
1635
|
+
while True:
|
|
1636
|
+
results = await base_query.clone().offset((page - 1) * count).limit(count).get()
|
|
1637
|
+
if not results:
|
|
1638
|
+
break
|
|
1639
|
+
|
|
1640
|
+
callback_res = callback(results)
|
|
1641
|
+
if inspect.isawaitable(callback_res):
|
|
1642
|
+
callback_res = await callback_res
|
|
1643
|
+
|
|
1644
|
+
if callback_res is False:
|
|
1645
|
+
return False
|
|
1646
|
+
|
|
1647
|
+
if len(results) < count:
|
|
1648
|
+
break
|
|
1649
|
+
page += 1
|
|
1650
|
+
|
|
1651
|
+
return True
|
|
1652
|
+
|
|
1653
|
+
async def chunk_by_id(
|
|
1654
|
+
self,
|
|
1655
|
+
count: int,
|
|
1656
|
+
callback: Callable[[list[dict[str, Any]]], Awaitable[bool | None] | bool | None],
|
|
1657
|
+
column: str = "id",
|
|
1658
|
+
) -> bool:
|
|
1659
|
+
"""
|
|
1660
|
+
Process records in batches ordered by ID, advancing past the last observed ID.
|
|
1661
|
+
Safe against record updates/deletions during iteration.
|
|
1662
|
+
"""
|
|
1663
|
+
if isinstance(count, bool) or not isinstance(count, int) or count < 1:
|
|
1664
|
+
raise ValueError("Chunk count must be an integer >= 1.")
|
|
1665
|
+
|
|
1666
|
+
col = validate_identifier(column, allow_dotted=False, allow_wildcard=False)
|
|
1667
|
+
last_id: Any = None
|
|
1668
|
+
base_query = self.clone()
|
|
1669
|
+
base_query._limit_value = None
|
|
1670
|
+
base_query._offset_value = None
|
|
1671
|
+
|
|
1672
|
+
while True:
|
|
1673
|
+
query = base_query.clone().order_by(col, "ASC").limit(count)
|
|
1674
|
+
if last_id is not None:
|
|
1675
|
+
query = query.where(col, ">", last_id)
|
|
1676
|
+
|
|
1677
|
+
results = await query.get()
|
|
1678
|
+
if not results:
|
|
1679
|
+
break
|
|
1680
|
+
|
|
1681
|
+
last_record = results[-1]
|
|
1682
|
+
if col not in last_record:
|
|
1683
|
+
raise QueryError(
|
|
1684
|
+
f"Column '{col}' not present in query projection for chunk_by_id cursor advancement."
|
|
1685
|
+
)
|
|
1686
|
+
last_id = last_record.get(col)
|
|
1687
|
+
|
|
1688
|
+
callback_res = callback(results)
|
|
1689
|
+
if inspect.isawaitable(callback_res):
|
|
1690
|
+
callback_res = await callback_res
|
|
1691
|
+
|
|
1692
|
+
if callback_res is False:
|
|
1693
|
+
return False
|
|
1694
|
+
|
|
1695
|
+
if len(results) < count:
|
|
1696
|
+
break
|
|
1697
|
+
|
|
1698
|
+
return True
|
|
1699
|
+
|
|
1700
|
+
async def upsert(
|
|
1701
|
+
self,
|
|
1702
|
+
values: list[dict[str, Any]],
|
|
1703
|
+
unique_by: list[str] | str,
|
|
1704
|
+
update_columns: list[str] | None = None,
|
|
1705
|
+
) -> int:
|
|
1706
|
+
"""
|
|
1707
|
+
Perform a PostgreSQL INSERT ... ON CONFLICT (unique_by) DO UPDATE / DO NOTHING.
|
|
1708
|
+
Returns the number of affected rows.
|
|
1709
|
+
"""
|
|
1710
|
+
if not values or not isinstance(values, list):
|
|
1711
|
+
raise QueryError("upsert() requires a non-empty list of dictionary rows.")
|
|
1712
|
+
|
|
1713
|
+
first_keys = set(values[0].keys()) if isinstance(values[0], dict) else set()
|
|
1714
|
+
for i, row in enumerate(values):
|
|
1715
|
+
if not isinstance(row, dict) or not row:
|
|
1716
|
+
raise QueryError("Each row in values must be a non-empty dictionary.")
|
|
1717
|
+
if set(row.keys()) != first_keys:
|
|
1718
|
+
raise QueryError(
|
|
1719
|
+
f"Row {i} in upsert batch has mismatched keys: expected {sorted(first_keys)}, "
|
|
1720
|
+
f"got {sorted(row.keys())}."
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
unique_cols = [unique_by] if isinstance(unique_by, str) else list(unique_by)
|
|
1724
|
+
if not unique_cols:
|
|
1725
|
+
raise QueryError("unique_by columns must not be empty.")
|
|
1726
|
+
|
|
1727
|
+
for col in unique_cols:
|
|
1728
|
+
if col not in first_keys:
|
|
1729
|
+
raise QueryError(f"Conflict key column '{col}' is not present in row data.")
|
|
1730
|
+
|
|
1731
|
+
# Detect duplicate conflict keys in input batch upfront (prevents PostgreSQL cardinality_violation)
|
|
1732
|
+
seen_conflict_keys: set[tuple[Any, ...]] = set()
|
|
1733
|
+
for i, row in enumerate(values):
|
|
1734
|
+
conflict_key = tuple(row.get(col) for col in unique_cols)
|
|
1735
|
+
if conflict_key in seen_conflict_keys:
|
|
1736
|
+
raise QueryError(
|
|
1737
|
+
f"Duplicate conflict key {conflict_key} detected at row {i} in upsert batch. "
|
|
1738
|
+
"PostgreSQL raises cardinality_violation if multiple rows in the same batch target the same conflicting record."
|
|
1739
|
+
)
|
|
1740
|
+
seen_conflict_keys.add(conflict_key)
|
|
1741
|
+
|
|
1742
|
+
safe_unique = [
|
|
1743
|
+
quote_identifier(validate_identifier(c, allow_dotted=False, allow_wildcard=False))
|
|
1744
|
+
for c in unique_cols
|
|
1745
|
+
]
|
|
1746
|
+
|
|
1747
|
+
columns = list(values[0].keys())
|
|
1748
|
+
safe_columns = [
|
|
1749
|
+
quote_identifier(validate_identifier(c, allow_dotted=False, allow_wildcard=False))
|
|
1750
|
+
for c in columns
|
|
1751
|
+
]
|
|
1752
|
+
|
|
1753
|
+
if update_columns is None:
|
|
1754
|
+
update_cols = [c for c in columns if c not in unique_cols]
|
|
1755
|
+
else:
|
|
1756
|
+
update_cols = update_columns
|
|
1757
|
+
|
|
1758
|
+
safe_update_cols = [
|
|
1759
|
+
validate_identifier(c, allow_dotted=False, allow_wildcard=False)
|
|
1760
|
+
for c in update_cols
|
|
1761
|
+
]
|
|
1762
|
+
|
|
1763
|
+
if safe_update_cols:
|
|
1764
|
+
update_assignments = [
|
|
1765
|
+
f"{quote_identifier(c)} = EXCLUDED.{quote_identifier(c)}"
|
|
1766
|
+
for c in safe_update_cols
|
|
1767
|
+
]
|
|
1768
|
+
conflict_clause = f"ON CONFLICT ({', '.join(safe_unique)}) DO UPDATE SET {', '.join(update_assignments)}"
|
|
1769
|
+
else:
|
|
1770
|
+
conflict_clause = f"ON CONFLICT ({', '.join(safe_unique)}) DO NOTHING"
|
|
1771
|
+
|
|
1772
|
+
bindings: list[Any] = []
|
|
1773
|
+
rows_sql: list[str] = []
|
|
1774
|
+
idx = 1
|
|
1775
|
+
|
|
1776
|
+
for row in values:
|
|
1777
|
+
placeholders: list[str] = []
|
|
1778
|
+
for col_name in columns:
|
|
1779
|
+
placeholders.append(f"${idx}")
|
|
1780
|
+
bindings.append(row.get(col_name))
|
|
1781
|
+
idx += 1
|
|
1782
|
+
rows_sql.append(f"({', '.join(placeholders)})")
|
|
1783
|
+
|
|
1784
|
+
sql = (
|
|
1785
|
+
f"INSERT INTO {quote_identifier(self._table)} ({', '.join(safe_columns)}) "
|
|
1786
|
+
f"VALUES {', '.join(rows_sql)} {conflict_clause}"
|
|
1787
|
+
)
|
|
1788
|
+
|
|
1789
|
+
conn = await self._connection()
|
|
1790
|
+
try:
|
|
1791
|
+
status = await conn.execute(sql, *bindings)
|
|
1792
|
+
parts = status.split()
|
|
1793
|
+
if len(parts) >= 3 and parts[-1].isdigit():
|
|
1794
|
+
return int(parts[-1])
|
|
1795
|
+
return len(values)
|
|
1796
|
+
finally:
|
|
1797
|
+
await self._release(conn)
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
class Query:
|
|
1801
|
+
@staticmethod
|
|
1802
|
+
def table(table_name: str) -> QueryBuilder:
|
|
1803
|
+
return QueryBuilder(table_name)
|