snake-orm 0.1.0b1__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 (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. snakeorm/times.py +276 -0
@@ -0,0 +1,810 @@
1
+ """Typed expression AST: values (SnakeValue) and conditions (SnakeCondition).
2
+
3
+ The nodes do NOT execute: they describe SQL; `sql/` translates them. Hierarchy of values:
4
+
5
+ SnakeValue[T] base: it compares and operates; it has NO `.path`.
6
+ ├── SnakeExpr[T] a column (the only one with `.path`).
7
+ └── SnakeArith[T] an arithmetic operation.
8
+
9
+ `SnakeValue.__eq__` returns a comparison (not `bool`), so the nodes use identity equality
10
+ (`eq=False`) and are not hashable by value.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from decimal import Decimal
17
+
18
+ from collections.abc import Iterable
19
+ from dataclasses import dataclass
20
+ from enum import Enum
21
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload
22
+
23
+ from snakeorm.core.exceptions import SnakeUnsupportedFeature, SnakeValueError
24
+
25
+ if TYPE_CHECKING:
26
+ # For the checker only: `window` imports this module, so at runtime it would be a cycle.
27
+ # With `from __future__ import annotations` the annotation is a string and is never evaluated.
28
+ from snakeorm.expressions.window import SnakeFrame, SnakeWindow
29
+
30
+ T = TypeVar("T")
31
+ V = TypeVar("V")
32
+
33
+
34
+ class SnakeOp(Enum):
35
+ """Comparison operators supported inside a condition."""
36
+
37
+ EQ = "="
38
+ NE = "<>"
39
+ LT = "<"
40
+ LE = "<="
41
+ GT = ">"
42
+ GE = ">="
43
+
44
+
45
+ class SnakeArithOp(Enum):
46
+ """Arithmetic operators supported inside a value expression."""
47
+
48
+ ADD = "+"
49
+ SUB = "-"
50
+ MUL = "*"
51
+ DIV = "/"
52
+
53
+
54
+ class SnakeNulls(Enum):
55
+ """Where the NULLs go when ordering. Agnostic: `NULLS FIRST`/`NULLS LAST` is standard SQL."""
56
+
57
+ FIRST = "FIRST"
58
+ LAST = "LAST"
59
+
60
+
61
+ class SnakeLock(Enum):
62
+ """What to do if the row to be locked is already locked.
63
+
64
+ `WAIT` waits (the default of `FOR UPDATE`), `NOWAIT` fails, `SKIP_LOCKED` ignores it. The last
65
+ two are opposites: asking for both is an error.
66
+ """
67
+
68
+ WAIT = "wait"
69
+ NOWAIT = "nowait"
70
+ SKIP_LOCKED = "skip_locked"
71
+
72
+
73
+ class SnakeAggFunc(Enum):
74
+ """Supported aggregation functions (standard SQL, engine-agnostic)."""
75
+
76
+ COUNT = "COUNT"
77
+ SUM = "SUM"
78
+ AVG = "AVG"
79
+ MIN = "MIN"
80
+ MAX = "MAX"
81
+
82
+
83
+ class SnakeCondition:
84
+ """Boolean node of the query AST (WHERE, JOIN ON...).
85
+
86
+ It combines with `&` (AND), `|` (OR) and is negated with `~` (NOT).
87
+ """
88
+
89
+ __slots__ = ()
90
+
91
+ def __and__(self, other: SnakeCondition) -> SnakeAnd:
92
+ return SnakeAnd(parts=(self, other))
93
+
94
+ def __or__(self, other: SnakeCondition) -> SnakeOr:
95
+ return SnakeOr(parts=(self, other))
96
+
97
+ def __invert__(self) -> SnakeNot:
98
+ return SnakeNot(operand=self)
99
+
100
+
101
+ @dataclass(frozen=True, slots=True, eq=False)
102
+ class SnakeComparison(SnakeCondition):
103
+ """Comparison `value OP value` (e.g. `username = 'Ana'`)."""
104
+
105
+ left: SnakeValue[Any]
106
+ op: SnakeOp
107
+ right: object
108
+
109
+
110
+ @dataclass(frozen=True, slots=True, eq=False)
111
+ class SnakeAnd(SnakeCondition):
112
+ """Conjunction of conditions (all of them must hold)."""
113
+
114
+ parts: tuple[SnakeCondition, ...]
115
+
116
+
117
+ @dataclass(frozen=True, slots=True, eq=False)
118
+ class SnakeOr(SnakeCondition):
119
+ """Disjunction of conditions (at least one of them must hold)."""
120
+
121
+ parts: tuple[SnakeCondition, ...]
122
+
123
+
124
+ @dataclass(frozen=True, slots=True, eq=False)
125
+ class SnakeInList(SnakeCondition):
126
+ """Membership in a set: `value IN (v1, v2, ...)`."""
127
+
128
+ left: SnakeValue[Any]
129
+ values: tuple[object, ...]
130
+
131
+
132
+ @dataclass(frozen=True, slots=True, eq=False)
133
+ class SnakeTupleIn(SnakeCondition):
134
+ """Row constructor: `(c1, c2) IN ((v1a, v2a), ...)` — the N-dimensional version of `SnakeInList`.
135
+
136
+ It is produced by the select-in of a to-many with a composite FK. Postgres emits it as is; a
137
+ dialect without a row constructor translates it into the disjunction `(a=.. AND b=..) OR (...)`. Parametrised values.
138
+ """
139
+
140
+ columns: tuple[SnakeValue[Any], ...]
141
+ rows: tuple[tuple[object, ...], ...]
142
+
143
+
144
+ @dataclass(frozen=True, slots=True, eq=False)
145
+ class SnakeIsNull(SnakeCondition):
146
+ """Nullity check: `value IS NULL`."""
147
+
148
+ left: SnakeValue[Any]
149
+
150
+
151
+ @dataclass(frozen=True, slots=True, eq=False)
152
+ class SnakeIsNotNull(SnakeCondition):
153
+ """Non-nullity check: `value IS NOT NULL`."""
154
+
155
+ left: SnakeValue[Any]
156
+
157
+
158
+ def _escape_like(value: str) -> str:
159
+ """Escapes the LIKE wildcards inside a VALUE, so they are data and not pattern.
160
+
161
+ `startswith("100%")` must search for a literal "100%": without this, the filter would silently
162
+ bring back TOO MUCH. The backslash goes first, or it would escape the escapes just put in.
163
+ """
164
+ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
165
+
166
+
167
+ @dataclass(frozen=True, slots=True, eq=False)
168
+ class SnakeLike(SnakeCondition):
169
+ """Pattern match: `value LIKE pattern` (or `ILIKE` if it ignores case).
170
+
171
+ The pattern ALWAYS travels parametrised. `case_insensitive` is a flag (it does not compose well
172
+ with the existing nodes).
173
+ """
174
+
175
+ left: SnakeValue[Any]
176
+ pattern: str
177
+ case_insensitive: bool = False
178
+ # True when the wildcards of the VALUE were escaped (startswith/contains/endswith): the emission
179
+ # adds `ESCAPE '\'` or SQLite treats the `\` as a character and filters wrong. A raw `.like()` goes with False.
180
+ escaped: bool = False
181
+
182
+
183
+ @dataclass(frozen=True, slots=True, eq=False)
184
+ class SnakeNot(SnakeCondition):
185
+ """Negation of a condition: `NOT (...)`."""
186
+
187
+ operand: SnakeCondition
188
+
189
+
190
+ @dataclass(frozen=True, slots=True)
191
+ class SnakeExistsJoin:
192
+ """A navigation JOIN INSIDE the subquery of an EXISTS (a to-one relationship of the child).
193
+
194
+ When the condition of `.any()` navigates (`Maker.nation.name`), it joins the target table inside
195
+ the EXISTS. Already resolved against the graph (the emission only assigns aliases). `prefix` is
196
+ the relationships accumulated from the child (`prefix[:-1]` reaches the parent, `()` = the
197
+ child); `pairs` are the to-one FK pairs (`parent.local = alias.remote`, a composite FK ANDs
198
+ them). Primitives only: no `SnakeTableInfo`.
199
+ """
200
+
201
+ prefix: tuple[str, ...]
202
+ schema: str
203
+ name: str
204
+ pairs: tuple[tuple[str, str], ...]
205
+
206
+
207
+ @dataclass(frozen=True, slots=True, eq=False)
208
+ class SnakeExists(SnakeCondition):
209
+ """Correlated EXISTS: does the parent have at least one child [satisfying `condition`]?
210
+
211
+ It is produced by class access to a to-many (`Nation.makers.any(...)`). It changes the
212
+ cardinality, which is why it is explicit; it inherits `& | ~`. It stores the child table and the
213
+ correlation pairs (`(child_column, parent_column)`) so it can emit `child.fk = parent.pk`. The
214
+ `condition` (optional) filters the child (paths relative to the child, re-anchored at emission).
215
+ `joins` are the to-one navigation JOINs of the child. Primitives only, no `SnakeTableInfo`.
216
+ """
217
+
218
+ child_schema: str
219
+ child_name: str
220
+ pairs: tuple[tuple[str, str], ...]
221
+ condition: SnakeCondition | None = None
222
+ joins: tuple[SnakeExistsJoin, ...] = ()
223
+
224
+
225
+ @dataclass(frozen=True, slots=True, eq=False)
226
+ class SnakeOrder:
227
+ """Ordering key: a value, its direction and where the NULLs go.
228
+
229
+ `nulls=None` leaves the engine default, which has a trap in it: Postgres puts the NULLs last in
230
+ ASC and first in DESC, so changing the direction moves the gaps around.
231
+ """
232
+
233
+ expr: SnakeValue[Any]
234
+ descending: bool
235
+ nulls: SnakeNulls | None = None
236
+
237
+ def nulls_first(self) -> SnakeOrder:
238
+ """The same key, with the NULLs first."""
239
+ return SnakeOrder(self.expr, self.descending, SnakeNulls.FIRST)
240
+
241
+ def nulls_last(self) -> SnakeOrder:
242
+ """The same key, with the NULLs last."""
243
+ return SnakeOrder(self.expr, self.descending, SnakeNulls.LAST)
244
+
245
+
246
+ class SnakeValue(Generic[T]):
247
+ """Base of every VALUE expression: it knows how to compare itself and operate arithmetically.
248
+
249
+ With no SQL identity of its own (the subclasses provide that). The comparators produce a
250
+ `SnakeCondition`; the arithmetic ones another `SnakeValue` (`SnakeArith`), chainable.
251
+ """
252
+
253
+ __slots__ = ()
254
+
255
+ def __eq__(self, other: object) -> SnakeCondition: # type: ignore[override]
256
+ if (
257
+ other is None
258
+ ): # `col == None` -> IS NULL (not `= NULL`, which in SQL is always false)
259
+ return SnakeIsNull(self)
260
+ return SnakeComparison(self, SnakeOp.EQ, other)
261
+
262
+ def __ne__(self, other: object) -> SnakeCondition: # type: ignore[override]
263
+ if other is None: # `col != None` -> IS NOT NULL
264
+ return SnakeIsNotNull(self)
265
+ return SnakeComparison(self, SnakeOp.NE, other)
266
+
267
+ # A COLUMN is accepted on the right, and it costs no parameter: `quantity > reserved` emits
268
+ # `"quantity" > "reserved"`. It is the same rule arithmetic always used: an operand that is a
269
+ # `SnakeValue` is a reference, anything else is a literal.
270
+ #
271
+ # The pair stays TYPED: `SnakeValue` is invariant in `T`, so `SnakeExpr[str] > SnakeExpr[int]`
272
+ # is still rejected. Comparing a column against a column of another type is the mistake this
273
+ # opening must not let through.
274
+ #
275
+ # An `int` is accepted on a `Decimal` column, and ONLY there. `Decimal("1") >= 0` is valid
276
+ # Python, `NUMERIC >= 0` is valid SQL, and this ORM emits the SAME SQL either way — measured
277
+ # byte for byte on the three dialects. The promotion is EXACT: an integer has no fractional
278
+ # part, so `0` and `Decimal(0)` are the same number and nothing rounds. `float` is NOT opened
279
+ # here and does not need to be: PEP 484's numeric tower already says an `int` goes wherever a
280
+ # `float` is expected. `str` stays shut, as it is in Python.
281
+ @overload
282
+ def __lt__(
283
+ self: SnakeValue[Decimal],
284
+ other: Decimal | int | SnakeValue[Decimal] | SnakeValue[int],
285
+ ) -> SnakeComparison: ...
286
+
287
+ @overload
288
+ def __lt__(self, other: SnakeValue[T] | T) -> SnakeComparison: ...
289
+
290
+ def __lt__(self, other: object) -> SnakeComparison:
291
+ return SnakeComparison(self, SnakeOp.LT, other)
292
+
293
+ @overload
294
+ def __le__(
295
+ self: SnakeValue[Decimal],
296
+ other: Decimal | int | SnakeValue[Decimal] | SnakeValue[int],
297
+ ) -> SnakeComparison: ...
298
+
299
+ @overload
300
+ def __le__(self, other: SnakeValue[T] | T) -> SnakeComparison: ...
301
+
302
+ def __le__(self, other: object) -> SnakeComparison:
303
+ return SnakeComparison(self, SnakeOp.LE, other)
304
+
305
+ @overload
306
+ def __gt__(
307
+ self: SnakeValue[Decimal],
308
+ other: Decimal | int | SnakeValue[Decimal] | SnakeValue[int],
309
+ ) -> SnakeComparison: ...
310
+
311
+ @overload
312
+ def __gt__(self, other: SnakeValue[T] | T) -> SnakeComparison: ...
313
+
314
+ def __gt__(self, other: object) -> SnakeComparison:
315
+ return SnakeComparison(self, SnakeOp.GT, other)
316
+
317
+ @overload
318
+ def __ge__(
319
+ self: SnakeValue[Decimal],
320
+ other: Decimal | int | SnakeValue[Decimal] | SnakeValue[int],
321
+ ) -> SnakeComparison: ...
322
+
323
+ @overload
324
+ def __ge__(self, other: SnakeValue[T] | T) -> SnakeComparison: ...
325
+
326
+ def __ge__(self, other: object) -> SnakeComparison:
327
+ return SnakeComparison(self, SnakeOp.GE, other)
328
+
329
+ # Not hashable: `__eq__` returns a condition, not a bool. A RUNTIME guard (`hash()` raises
330
+ # TypeError); statically it cannot be closed off, hence the `# type: ignore[assignment]`.
331
+ __hash__ = None # type: ignore[assignment]
332
+
333
+ # THE SECOND OVERLOAD OF EACH OPERATOR PROPAGATES NULLABILITY, and the line it draws is one
334
+ # question: does the type CHANGE what the engine computes, or finally DESCRIBE it?
335
+ #
336
+ # int * 1.0 changes it — integer division or decimal is a decision, and it stays the
337
+ # caller's, made explicit with `snake_cast`.
338
+ # a + NULL changes nothing — it is NULL on the three engines, always, no choice in it.
339
+ #
340
+ # Without it, `snake_nullif` declaring the `None` it introduces would make
341
+ # `x / snake_nullif(y, 0)` impossible to write — the one expression it exists for.
342
+ #
343
+ # The non-nullable overload goes FIRST: with a nullable operand it cannot match (`SnakeValue[T]`
344
+ # is invariant, so `SnakeValue[int | None]` is not a `SnakeValue[int]`) and resolution falls
345
+ # through to the second. Reversing them would make every arithmetic nullable.
346
+
347
+ @overload
348
+ def __add__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
349
+ @overload
350
+ def __add__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
351
+ def __add__(self, other: object) -> SnakeArith[Any]:
352
+ return SnakeArith(self, SnakeArithOp.ADD, other)
353
+
354
+ @overload
355
+ def __sub__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
356
+ @overload
357
+ def __sub__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
358
+ def __sub__(self, other: object) -> SnakeArith[Any]:
359
+ return SnakeArith(self, SnakeArithOp.SUB, other)
360
+
361
+ @overload
362
+ def __mul__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
363
+ @overload
364
+ def __mul__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
365
+ def __mul__(self, other: object) -> SnakeArith[Any]:
366
+ return SnakeArith(self, SnakeArithOp.MUL, other)
367
+
368
+ @overload
369
+ def __truediv__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
370
+ @overload
371
+ def __truediv__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
372
+ def __truediv__(self, other: object) -> SnakeArith[Any]:
373
+ return SnakeArith(self, SnakeArithOp.DIV, other)
374
+
375
+ # Reflected: the foreign operand ends up on the LEFT (the order matters for `-` and `/`).
376
+
377
+ @overload
378
+ def __radd__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
379
+ @overload
380
+ def __radd__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
381
+ def __radd__(self, other: object) -> SnakeArith[Any]:
382
+ return SnakeArith(other, SnakeArithOp.ADD, self)
383
+
384
+ @overload
385
+ def __rsub__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
386
+ @overload
387
+ def __rsub__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
388
+ def __rsub__(self, other: object) -> SnakeArith[Any]:
389
+ return SnakeArith(other, SnakeArithOp.SUB, self)
390
+
391
+ @overload
392
+ def __rmul__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
393
+ @overload
394
+ def __rmul__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
395
+ def __rmul__(self, other: object) -> SnakeArith[Any]:
396
+ return SnakeArith(other, SnakeArithOp.MUL, self)
397
+
398
+ @overload
399
+ def __rtruediv__(self, other: SnakeValue[T] | T) -> SnakeArith[T]: ...
400
+ @overload
401
+ def __rtruediv__(self, other: SnakeValue[T | None]) -> SnakeArith[T | None]: ...
402
+ def __rtruediv__(self, other: object) -> SnakeArith[Any]:
403
+ return SnakeArith(other, SnakeArithOp.DIV, self)
404
+
405
+ def json_get(self, *key_path: str, as_type: type[V]) -> SnakeJsonGet[V]:
406
+ """Reads a key INSIDE a JSON column, as the declared type: `meta.json_get("size", as_type=int)`.
407
+
408
+ `as_type` is required and it is not ceremony. What the engines give back from a document is
409
+ text, so without a declared type the comparison below would be a TEXT comparison and
410
+ `'9' > '100'` would be true — the same trap the capability catalogue documents for a
411
+ `Decimal` ordered as text. The type is what makes the ORM emit the cast.
412
+
413
+ Several keys walk a nested path in ONE access (`json_get("owner", "name")`), because every
414
+ engine takes a path and two accesses would be two trips through the document.
415
+
416
+ The key is validated rather than parametrised: it is emitted inside a literal, where no
417
+ engine accepts a placeholder, so a key that is not a plain identifier is refused here.
418
+ """
419
+ if as_type not in JSON_CASTABLE:
420
+ raise SnakeUnsupportedFeature(
421
+ f"json_get(as_type={getattr(as_type, '__name__', as_type)!r}) is not supported: a "
422
+ f"document is read back as one of {', '.join(t.__name__ for t in JSON_CASTABLE)}. "
423
+ f"Read it as str and convert it yourself if the engine has no cast for that type."
424
+ )
425
+ if not key_path:
426
+ raise SnakeValueError("json_get() needs at least one key to read")
427
+ for key in key_path:
428
+ if not _JSON_KEY.match(key):
429
+ raise SnakeValueError(
430
+ f"json_get() key {key!r} is not a plain identifier. The key is emitted INSIDE "
431
+ f"the statement (no engine takes a placeholder in a JSON path), so anything "
432
+ f"else would be an injection rather than a lookup."
433
+ )
434
+ return SnakeJsonGet(source=self, key_path=key_path, as_type=as_type)
435
+
436
+ @overload
437
+ def in_(self, values: SnakeSubquery[T]) -> SnakeInSubquery: ...
438
+ @overload
439
+ def in_(self, values: Iterable[T]) -> SnakeInList: ...
440
+ def in_(
441
+ self, values: Iterable[T] | SnakeSubquery[T]
442
+ ) -> SnakeInList | SnakeInSubquery:
443
+ """`value IN (...)`: a set of values or a scalar SUBQUERY.
444
+
445
+ An iterable -> values typed to `T`, `SnakeInList`. A `SnakeSubquery[T]` (same `T`) -> `value
446
+ IN (SELECT ...)`, `SnakeInSubquery` (the checker rejects a subquery of another type).
447
+ """
448
+ if isinstance(values, SnakeSubquery):
449
+ return SnakeInSubquery(left=self, subquery=values)
450
+ return SnakeInList(self, tuple(values))
451
+
452
+ def is_null(self) -> SnakeIsNull:
453
+ """`value IS NULL`. Valid for any expression."""
454
+ return SnakeIsNull(self)
455
+
456
+ def is_not_null(self) -> SnakeIsNotNull:
457
+ """`value IS NOT NULL`. Valid for any expression."""
458
+ return SnakeIsNotNull(self)
459
+
460
+ def like(self: SnakeValue[str], pattern: str) -> SnakeLike:
461
+ """`value LIKE pattern`. Only over text expressions (self: SnakeValue[str])."""
462
+ return SnakeLike(self, pattern)
463
+
464
+ def ilike(self: SnakeValue[str], pattern: str) -> SnakeLike:
465
+ """`value ILIKE pattern`: like `like`, but ignoring upper and lower case."""
466
+ return SnakeLike(self, pattern, case_insensitive=True)
467
+
468
+ def not_in(self, values: Iterable[T]) -> SnakeNot:
469
+ """`NOT (value IN (...))`: the NEGATION of the IN, not a new node (one more node would be one more place to forget something)."""
470
+ return SnakeNot(operand=SnakeInList(self, tuple(values)))
471
+
472
+ def between(self, low: T, high: T) -> SnakeAnd:
473
+ """`value BETWEEN low AND high` (inclusive), as an AND of two comparisons (no node of its own: equivalent SQL)."""
474
+ try:
475
+ inverted = low > high # type: ignore[operator]
476
+ except TypeError:
477
+ # `T` carries no bound and cannot: bounding it would break `SnakeColumn[dict]`, which is
478
+ # a real column type. So the comparison can fail, and Python's own message —"'>' not
479
+ # supported between instances of 'dict' and 'dict'"— is about Python's operators in the
480
+ # middle of building a query, leaving the reader to work out which column it meant.
481
+ # `json_get()` twelve lines above already translates its own refusal; this is that move.
482
+ raise SnakeUnsupportedFeature(
483
+ f"between() needs values that can be ORDERED, and {type(low).__name__} cannot be. "
484
+ f"A column of that type has no range to sit inside: filter it with an equality, or "
485
+ f"with json_get() if what you want is a value inside the document."
486
+ ) from None
487
+ if inverted:
488
+ raise SnakeValueError(
489
+ f"The between range is inverted ({low!r} > {high!r}): there is no value between "
490
+ f"those two, so the query would return nothing. Did you swap them round?"
491
+ )
492
+ return SnakeAnd(
493
+ parts=(
494
+ SnakeComparison(self, SnakeOp.GE, low),
495
+ SnakeComparison(self, SnakeOp.LE, high),
496
+ )
497
+ )
498
+
499
+ def startswith(self: SnakeValue[str], value: str) -> SnakeLike:
500
+ """`LIKE 'value%'`, with the wildcards of the VALUE escaped."""
501
+ return SnakeLike(self, f"{_escape_like(value)}%", escaped=True)
502
+
503
+ def istartswith(self: SnakeValue[str], value: str) -> SnakeLike:
504
+ """Like `startswith`, but ignoring case."""
505
+ return SnakeLike(
506
+ self, f"{_escape_like(value)}%", case_insensitive=True, escaped=True
507
+ )
508
+
509
+ def endswith(self: SnakeValue[str], value: str) -> SnakeLike:
510
+ """`LIKE '%value'`, with the wildcards of the VALUE escaped."""
511
+ return SnakeLike(self, f"%{_escape_like(value)}", escaped=True)
512
+
513
+ def iendswith(self: SnakeValue[str], value: str) -> SnakeLike:
514
+ """Like `endswith`, but ignoring case."""
515
+ return SnakeLike(
516
+ self, f"%{_escape_like(value)}", case_insensitive=True, escaped=True
517
+ )
518
+
519
+ def contains(self: SnakeValue[str], value: str) -> SnakeLike:
520
+ """`LIKE '%value%'`, with the wildcards of the VALUE escaped."""
521
+ return SnakeLike(self, f"%{_escape_like(value)}%", escaped=True)
522
+
523
+ def icontains(self: SnakeValue[str], value: str) -> SnakeLike:
524
+ """Like `contains`, but ignoring case."""
525
+ return SnakeLike(
526
+ self, f"%{_escape_like(value)}%", case_insensitive=True, escaped=True
527
+ )
528
+
529
+ def asc(self) -> SnakeOrder:
530
+ """Ascending order key by this expression."""
531
+ return SnakeOrder(self, descending=False)
532
+
533
+ def desc(self) -> SnakeOrder:
534
+ """Descending order key by this expression."""
535
+ return SnakeOrder(self, descending=True)
536
+
537
+ def paths(self) -> tuple[tuple[str, ...], ...]:
538
+ """Column paths contained in this node. Each concrete subclass defines it."""
539
+ raise NotImplementedError
540
+
541
+
542
+ class SnakeExpr(SnakeValue[T]):
543
+ """Typed expression over ONE column.
544
+
545
+ `path` is the navigation down to the column (the last element is the column, the earlier ones
546
+ relationships: `("car", "brand", "name")`). The only column leaf node.
547
+
548
+ `python_type` is the COMPILED type of the column, carried here so the emitter can reason about
549
+ it: the generic `T` is erased at runtime, and without this the SQL layer cannot tell an integer
550
+ division from a decimal one. It is `None` when nobody stamped it — a hand-built node in a test,
551
+ or a path the compiler never saw — and everything that reads it treats `None` as "no proof" and
552
+ changes nothing.
553
+ """
554
+
555
+ __slots__ = ("path", "python_type")
556
+
557
+ def __init__(self, path: tuple[str, ...], python_type: type | None = None) -> None:
558
+ self.path = path
559
+ self.python_type = python_type
560
+
561
+ def paths(self) -> tuple[tuple[str, ...], ...]:
562
+ """A column contributes exactly its own path."""
563
+ return (self.path,)
564
+
565
+
566
+ @dataclass(frozen=True, slots=True, eq=False)
567
+ class SnakeArith(SnakeValue[T]):
568
+ """Arithmetic operation between two values: `left OP right`.
569
+
570
+ Each operand is another `SnakeValue` or a literal `T`. Chainable: `(a + 1) > 3`.
571
+ """
572
+
573
+ left: SnakeValue[T] | T
574
+ op: SnakeArithOp
575
+ right: SnakeValue[T] | T
576
+
577
+ def paths(self) -> tuple[tuple[str, ...], ...]:
578
+ """Recursively collects the paths of the operands that are expressions."""
579
+ collected: tuple[tuple[str, ...], ...] = ()
580
+ if isinstance(self.left, SnakeValue):
581
+ collected += self.left.paths()
582
+ if isinstance(self.right, SnakeValue):
583
+ collected += self.right.paths()
584
+ return collected
585
+
586
+
587
+ # What a declared type becomes inside the document access. It is a WHITELIST and not a mapping with a
588
+ # fallback: a type nobody has written a cast for must be refused by name, because the alternative is
589
+ # emitting SQL the engine rejects and letting the driver explain a decision this ORM made.
590
+ JSON_CASTABLE: tuple[type, ...] = (str, int, float, bool)
591
+
592
+ # What a JSON key may be. Emitted INSIDE a literal (`'$.a'`, `'{a,b}'`) because no engine takes a
593
+ # placeholder there — which is exactly the shape where an unchecked string becomes an injection.
594
+ _JSON_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
595
+
596
+
597
+ @dataclass(frozen=True, slots=True, eq=False)
598
+ class SnakeJsonGet(SnakeValue[T]):
599
+ """A read INSIDE a JSON document: `meta ->> 'size'`, cast to the type that was declared.
600
+
601
+ The type is DECLARED rather than inferred, and that is the decision the whole feature rests on.
602
+ A key access could have been `str` always —which is what `->>` returns— and it would have let
603
+ `meta.json_get("size") > 100` compare TEXT, where `'9' > '100'` is true. The cast is what makes
604
+ the comparison mean what it looks like.
605
+
606
+ What is inside the document is not something the ORM can know: if it does not hold what was
607
+ declared, the ENGINE complains, and that is the right place for it.
608
+ """
609
+
610
+ source: SnakeValue[Any]
611
+ key_path: tuple[str, ...]
612
+ as_type: type
613
+
614
+ def paths(self) -> tuple[tuple[str, ...], ...]:
615
+ """The column being read into: the access adds no navigation of its own."""
616
+ return self.source.paths()
617
+
618
+
619
+ # What an explicit cast may target. A WHITELIST for the same reason `JSON_CASTABLE` is one, and NOT
620
+ # the same list: `str` belongs there because `->>` already returns text, and does NOT belong here,
621
+ # where accepting it would mean emitting the source unchanged and handing an integer to somebody who
622
+ # asked for text. These three are exactly what the dialects' cast tables can spell.
623
+ CASTABLE: tuple[type, ...] = (int, float, bool)
624
+
625
+
626
+ @dataclass(frozen=True, slots=True, eq=False)
627
+ class SnakeCast(SnakeValue[T]):
628
+ """An EXPLICIT conversion: `CAST(<source> AS <type>)`, with the type named at the call site.
629
+
630
+ It exists because the arithmetic operators are `SnakeValue[T] | T -> SnakeArith[T]` — one single
631
+ `T` — and that is right: promoting `int` to `float` behind the user's back would be the ORM
632
+ deciding. But strictness only holds up with an explicit door, and there was none: `column * 1.0`
633
+ does not type-check, so a real division between two integer columns could not be written at all.
634
+
635
+ The TYPE NAME is the dialect's, not this node's. Measured: `CAST(x AS NUMERIC)` answers `0.9` on
636
+ PostgreSQL and `0` on SQLite, whose NUMERIC affinity collapses an integral value back to an
637
+ integer. One spelling would be right on two engines and silently wrong on the third.
638
+ """
639
+
640
+ source: SnakeValue[Any]
641
+ as_type: type
642
+
643
+ def paths(self) -> tuple[tuple[str, ...], ...]:
644
+ """What it wraps: a cast adds no navigation, and swallowing these would drop the JOIN."""
645
+ return self.source.paths()
646
+
647
+
648
+ @dataclass(frozen=True, slots=True, eq=False)
649
+ class SnakeStringAgg(SnakeValue[T]):
650
+ """`STRING_AGG(arg, sep ORDER BY ...)`: a group's values joined into ONE string.
651
+
652
+ It gets its own node instead of a member of `SnakeAggFunc` because it is not shaped like the
653
+ others. `SnakeAggregate` emits `FUNC(arg)` uniformly, and this one has a second argument whose
654
+ POSITION differs per engine — and on MySQL is not an argument at all but the `SEPARATOR` keyword.
655
+ Squeezing it into the uniform node would mean the node knowing about engines, which is the one
656
+ thing the graph must never do.
657
+
658
+ `order_by` is not decoration: without it the concatenation comes back in whatever order the
659
+ engine chose, so a value somebody READS changes between runs.
660
+ """
661
+
662
+ arg: SnakeValue[Any]
663
+ separator: str
664
+ order_by: tuple[SnakeOrder, ...] = ()
665
+
666
+ def paths(self) -> tuple[tuple[str, ...], ...]:
667
+ """The argument's and the order's: both of them plan JOINs."""
668
+ paths = list(self.arg.paths())
669
+ for key in self.order_by:
670
+ paths.extend(key.expr.paths())
671
+ return tuple(paths)
672
+
673
+
674
+ def static_type(value: object) -> type | None:
675
+ """The type of an operand when it can be PROVEN, and `None` when it cannot.
676
+
677
+ The generic `T` is erased at runtime, so emission cannot read it. What survives is what somebody
678
+ wrote down: a column's compiled type, a cast's declared target, the class of a literal. This
679
+ walks those, and it is deliberately a PROVER rather than an inferrer — the answer is either a
680
+ type somebody stated or nothing at all.
681
+
682
+ `None` is not a failure to be papered over. Its only caller uses it to decide whether an engine
683
+ needs a different operator, and reading `None` as a guess would silently turn a decimal division
684
+ into an integer one. Absence of proof leaves the SQL exactly as it was.
685
+ """
686
+ if isinstance(value, SnakeExpr):
687
+ return value.python_type
688
+ if isinstance(value, (SnakeCast, SnakeJsonGet)):
689
+ return value.as_type
690
+ if isinstance(value, SnakeArith):
691
+ # An arithmetic node keeps its operands' type only when BOTH agree. Mixed or unprovable
692
+ # operands make the whole node unprovable, which is what makes nesting safe.
693
+ left = static_type(value.left)
694
+ return left if left is not None and left is static_type(value.right) else None
695
+ if isinstance(value, SnakeValue):
696
+ # Every other node (aggregate, window, function call...) pins its type in the CONSTRUCTOR
697
+ # and does not carry it. Claiming otherwise here would be inventing proof.
698
+ return None
699
+ return type(value)
700
+
701
+
702
+ @dataclass(frozen=True, slots=True, eq=False)
703
+ class SnakeAggregate(SnakeValue[T]):
704
+ """Aggregate over a value: `COUNT(*)`, `SUM(col)`, `MIN/MAX(col)`...
705
+
706
+ It is a `SnakeValue` (comparable -> HAVING, projectable). `arg` is the aggregated value (`None` =
707
+ `COUNT(*)`); `distinct` marks `COUNT(DISTINCT ...)`. The TYPE is pinned down by the constructors in `functions.py`.
708
+ """
709
+
710
+ func: SnakeAggFunc
711
+ arg: SnakeValue[Any] | None = None
712
+ distinct: bool = False
713
+
714
+ def paths(self) -> tuple[tuple[str, ...], ...]:
715
+ """Propagates the argument's paths (empty in `COUNT(*)`): that is how the aggregate plans its JOINs."""
716
+ return self.arg.paths() if self.arg is not None else ()
717
+
718
+ def over(
719
+ self,
720
+ *,
721
+ partition_by: tuple[SnakeValue[Any], ...] | list[SnakeValue[Any]] = (),
722
+ order_by: tuple[SnakeOrder, ...] | list[SnakeOrder] = (),
723
+ frame: SnakeFrame | None = None,
724
+ ) -> SnakeWindow[T]:
725
+ """The same aggregate as a WINDOW: `SUM(x)` -> `SUM(x) OVER (...)`.
726
+
727
+ With `GROUP BY` it returns one row per group; as a window, every row with its running total.
728
+ `frame` is what makes that total MOVING instead of cumulative, and it is forwarded whole so
729
+ this door and `SnakeWindow.over` cannot drift apart — including the guard that refuses a
730
+ frame with no order to measure it against.
731
+
732
+ The import of `as_window` is local because `window` imports this module and at module level
733
+ that would be a cycle. The ANNOTATIONS are a different matter and used to be lumped in with
734
+ it: `frame` was `Any` and the docstring blamed the same cycle. It never applied — the
735
+ `TYPE_CHECKING` block above already imports `SnakeWindow`, this method's own return type,
736
+ from that very module, and `from __future__ import annotations` means neither name is
737
+ evaluated at runtime. `Any` there switched off the door's type for the checker and crossed
738
+ the strict gate without a word, in a project whose rule is zero `Any`.
739
+ """
740
+ from snakeorm.expressions.window import as_window
741
+
742
+ return as_window(self).over(
743
+ partition_by=partition_by, order_by=order_by, frame=frame
744
+ )
745
+
746
+
747
+ class SnakeSubqueryAggregate(SnakeValue[T]):
748
+ """Scalar aggregation subquery `(SELECT FUNC(arg) FROM child AS alias WHERE correlation)`.
749
+
750
+ It is produced by `collection.count()/.sum_()/...`. It does NOT change the cardinality: it is a
751
+ comparable scalar VALUE. The TYPE is pinned down by the constructors of `SnakeCollection`. `arg`
752
+ is the column OF THE CHILD (`None` = `COUNT(*)`), re-anchored to the alias at emission like the
753
+ condition of `.any()`. It contributes no paths to the outer query (empty `paths()`): the
754
+ correlation is resolved at emission. It stores the child table and the correlation pairs.
755
+ """
756
+
757
+ __slots__ = ("func", "arg", "child_schema", "child_name", "pairs")
758
+
759
+ def __init__(
760
+ self,
761
+ func: SnakeAggFunc,
762
+ arg: SnakeExpr[Any] | None,
763
+ child_schema: str,
764
+ child_name: str,
765
+ pairs: tuple[tuple[str, str], ...],
766
+ ) -> None:
767
+ self.func = func
768
+ self.arg = arg
769
+ self.child_schema = child_schema
770
+ self.child_name = child_name
771
+ self.pairs = pairs
772
+
773
+ def paths(self) -> tuple[tuple[str, ...], ...]:
774
+ """A correlated aggregate references no columns of the outer query: no paths."""
775
+ return ()
776
+
777
+
778
+ class SnakeSubquery(SnakeValue[T]):
779
+ """Scalar subquery used as a VALUE: `(SELECT <column> FROM <table> [WHERE ...])`.
780
+
781
+ It is produced by `SnakeQuery.as_scalar(column)`. Primitives only (agnostic; it does not import
782
+ `SnakeQuery`, which avoids a cycle). It has its own FROM: it contributes no paths and does not
783
+ correlate. It is used in `.in_(...)`; its params are threaded into the outer numbering at emission.
784
+ """
785
+
786
+ __slots__ = ("schema", "name", "column", "where")
787
+
788
+ def __init__(
789
+ self, schema: str, name: str, column: str, where: SnakeCondition | None = None
790
+ ) -> None:
791
+ self.schema = schema
792
+ self.name = name
793
+ self.column = column
794
+ self.where = where
795
+
796
+ def paths(self) -> tuple[tuple[str, ...], ...]:
797
+ """A subquery has its own FROM: it references no columns of the outer query."""
798
+ return ()
799
+
800
+
801
+ @dataclass(frozen=True, slots=True, eq=False)
802
+ class SnakeInSubquery(SnakeCondition):
803
+ """Membership in the result of a scalar subquery: `<value> IN (SELECT ...)`.
804
+
805
+ It is produced by `SnakeValue.in_(subquery)`. `left` is the value from the outer query;
806
+ `subquery` is the subquery. It inherits `& | ~`.
807
+ """
808
+
809
+ left: SnakeValue[Any]
810
+ subquery: SnakeSubquery[Any]