swiftpy-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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)