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,815 @@
1
+ """What has to be done, WITHOUT doing it: the colourless plan both sessions share.
2
+
3
+ Here lives the single copy of the decisions — which SQL to emit and how to interpret the rows — and
4
+ the synchronous and asynchronous sessions confine themselves to EXECUTING it. Copying the session
5
+ (>1000 lines) with an `await` in front would create two places to fix each bug; this branch has
6
+ already caught three that way.
7
+
8
+ The way back (row -> object) lives here too, which used to be in `session.py` with lazy imports: the
9
+ extracted core depended on one of its own consumers. No function uses the session, so bringing them
10
+ over left the dependency running one way only. Every function returns `(sql, params, apply)`; none
11
+ of them touches a driver.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from snakeorm.decorators.result import snake_result_info
17
+ from snakeorm.decorators.row import snake_row_info
18
+ from snakeorm.core.exceptions import SnakeEmitError
19
+ from snakeorm.model import attach_aggregates
20
+ from snakeorm.registry import SnakeRegistry, registry_of
21
+ from snakeorm.session.coercion import coerce
22
+ from snakeorm.sql import emit_insert
23
+ from snakeorm.sql import emit_select
24
+ from snakeorm.sql.condition import emit_condition
25
+
26
+ import re
27
+ from typing import TYPE_CHECKING, cast
28
+
29
+ from snakeorm.decorators.result import SnakeResultInfo
30
+ from snakeorm.debug.collector import timed_mapping
31
+ from snakeorm.fields import SnakePrefetchHop
32
+ from snakeorm.fields.relationship import attach_relationship
33
+ from snakeorm.metadata import SnakeColumnInfo
34
+ from snakeorm.core.exceptions import SnakeUnknownColumn, SnakeValueError
35
+ from snakeorm.expressions import (
36
+ SnakeAggFunc,
37
+ SnakeAggregate,
38
+ SnakeAnd,
39
+ SnakeCondition,
40
+ SnakeExpr,
41
+ SnakeTupleIn,
42
+ SnakeValue,
43
+ )
44
+ from snakeorm.session.mapper import (
45
+ _Dispatch,
46
+ _hydrate_with_plan,
47
+ _Instruction,
48
+ dispatch_for,
49
+ hydrate,
50
+ pk_positions,
51
+ plan_for,
52
+ )
53
+
54
+ from collections.abc import Callable, Sequence
55
+ from dataclasses import dataclass
56
+ from typing import Any, Generic, TypeVar
57
+
58
+ from snakeorm.dialects import SnakeDialect
59
+ from snakeorm.metadata import SnakeRelationshipKind, SnakeTableInfo
60
+ from snakeorm.sql.refs import qualified
61
+
62
+ if TYPE_CHECKING:
63
+ # For the checker only. `query/` does not import `session/`, so this would not be a cycle at
64
+ # runtime either; it stays here because nothing in this module CALLS a query, it only describes
65
+ # the shape it receives, and an import paid at start-up for an annotation is an import too many.
66
+ from snakeorm.query import SnakeJoinedQuery, SnakeQuery
67
+
68
+ T = TypeVar("T")
69
+ R = TypeVar("R")
70
+
71
+ Rows = list[tuple[object, ...]]
72
+ """What a driver returns: raw rows, uninterpreted."""
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class Plan(Generic[R]):
77
+ """A parametrised SQL and what to do with whatever comes back.
78
+
79
+ `needs_rows` tells EXECUTING apart from QUERYING: whoever built the plan knows it, it is not
80
+ guessed from the string.
81
+ """
82
+
83
+ sql: str
84
+ params: tuple[object, ...]
85
+ apply: Callable[[Rows], R]
86
+ needs_rows: bool = True
87
+
88
+
89
+ def plan_insert(
90
+ instance: object,
91
+ table: SnakeTableInfo,
92
+ dialect: SnakeDialect,
93
+ values: dict[str, object],
94
+ apply_returned: Callable[[object, SnakeTableInfo, tuple[object, ...]], None],
95
+ ) -> Plan[object]:
96
+ """Plan of an INSERT: the SQL and what to do with the row the `RETURNING` gives back.
97
+
98
+ With `RETURNING`, ALL the columns come back (server defaults, trigger columns) into the in-memory
99
+ object; without it there is nothing to apply.
100
+ """
101
+
102
+ # The discriminator is put in by the CLASS, not by the user: trusting them to remember
103
+ # `Dog(kind="dog")` would store a row that is later read as a generic `Animal` and with no
104
+ # error. It gets overwritten.
105
+ if table.is_polymorphic_child:
106
+ assert table.polymorphic is not None
107
+ values = {**values, table.polymorphic.column: table.polymorphic.value}
108
+
109
+ sql, params = emit_insert(table, dialect, values)
110
+
111
+ def apply(rows: Rows) -> object:
112
+ """Copies back whatever the server returned."""
113
+ if rows:
114
+ apply_returned(instance, table, rows[0])
115
+ return instance
116
+
117
+ return Plan(
118
+ sql=sql, params=params, apply=apply, needs_rows=dialect.supports_returning
119
+ )
120
+
121
+
122
+ def plan_scalar(
123
+ sql: str, params: tuple[object, ...], cast_to: Callable[[object], Any]
124
+ ) -> Plan[Any]:
125
+ """Plan of a query that returns ONE value (a `COUNT(*)`, an `EXISTS`)."""
126
+ return Plan(sql=sql, params=params, apply=lambda rows: cast_to(rows[0][0]))
127
+
128
+
129
+ @timed_mapping
130
+ def project_rows(
131
+ query: SnakeQuery[Any] | SnakeJoinedQuery[Any, Any],
132
+ columns: Sequence[SnakeValue[Any]],
133
+ rows: Rows,
134
+ ) -> list[tuple[Any, ...]]:
135
+ """Coerces each projected value to the `python_type` that can be determined.
136
+
137
+ Colourless: which type each column has does not depend on whether the driver awaited. Both
138
+ sessions use it.
139
+
140
+ Timed as mapping too: a `.join()` gives back tuples rather than models, but the work is the same
141
+ work — driver values turned into the Python the user declared — and leaving it out would send it
142
+ to `app_ms`, where the panel says the ORM is not.
143
+ """
144
+
145
+ reg = registry_of(query.model)
146
+ root = reg.table_of(query.model)
147
+ types = [_projected_python_type(column, root, reg) for column in columns]
148
+ return [
149
+ tuple(
150
+ coerce(value, declared) if declared is not None else value
151
+ for value, declared in zip(row, types, strict=True)
152
+ )
153
+ for row in rows
154
+ ]
155
+
156
+
157
+ def plan_annotate(
158
+ query: SnakeQuery[Any],
159
+ result: type[Any],
160
+ dialect: SnakeDialect,
161
+ aggregates: dict[str, SnakeValue[Any]],
162
+ ) -> tuple[str, tuple[object, ...], Callable[[Rows], list[Any]]]:
163
+ """SQL of an `annotate` and how to build the `@snake_result`s with the rows that come back.
164
+
165
+ `result` is `type[Any]` and not `type[SnakeResult[Any]]` because it gets CALLED with keywords a
166
+ few lines down: the `__init__` that takes them is synthesised by `@dataclass_transform` on the
167
+ concrete subclass, and the marker base does not carry it. The lock that rejects a class which is
168
+ not a `@snake_result` is the `R: SnakeResult` bound of both sessions, and `snake_result_info`
169
+ behind it — not this annotation.
170
+ """
171
+
172
+ info = snake_result_info(result)
173
+ if query.model is not info.base_model:
174
+ raise SnakeEmitError(
175
+ f"The base model of {result.__name__} is {info.base_model.__name__}, but the query is "
176
+ f"over {query.model.__name__}: they do not match. Query the same model that the "
177
+ f"@snake_result declares."
178
+ )
179
+ _validate_aggregate_names(result, info, aggregates)
180
+ base_table = registry_of(info.base_model).table_of(info.base_model)
181
+ assert base_table is not None
182
+ ordered = tuple(aggregates[name] for name, _ in info.scalars)
183
+ sql, params = query.to_annotate_sql(dialect, ordered)
184
+ width = len(base_table.columns)
185
+
186
+ @timed_mapping
187
+ def build(rows: Rows) -> list[Any]:
188
+ """Hydrates the base instance and hooks the aggregates onto it."""
189
+ output: list[Any] = []
190
+ for row in rows:
191
+ base: object = _instantiate(info.base_model, base_table, row[:width])
192
+ values = {
193
+ name: coerce(row[width + index], declared)
194
+ for index, (name, declared) in enumerate(info.scalars)
195
+ }
196
+ attach_aggregates(base, values)
197
+ output.append(result(**{info.base_field: base, **values}))
198
+ return output
199
+
200
+ return sql, params, build
201
+
202
+
203
+ _ROUTINE_PART = re.compile(r"[^\W\d][\w$]*")
204
+ """One dot-separated part of a routine name: a letter or `_`, then letters, digits, `_` or `$`.
205
+
206
+ Deliberately the UNQUOTED identifier of the three engines and nothing else. What it leaves out is
207
+ the point: a quote, a parenthesis, a semicolon, a space, a newline and a comment marker are all
208
+ outside it, so an accepted name cannot end a statement or begin another one.
209
+ """
210
+
211
+
212
+ def routine_name(name: str) -> str:
213
+ """The identifier of a routine, checked and returned as written. The one thing not a parameter.
214
+
215
+ Every value in this ORM travels parametrised; this one cannot, because no engine takes a
216
+ placeholder where an identifier goes. `call` and `execute_procedure` are the only doors where
217
+ something from outside reaches the SQL string, so the SHAPE is checked here — the same move
218
+ `SnakeValue.json_get` makes with its key path.
219
+
220
+ IT IS NOT QUOTED, and that is measured rather than assumed. On PostgreSQL a routine created with
221
+ a bare `CREATE FUNCTION CalculatePayroll` lands in the catalogue folded to `calculatepayroll`,
222
+ so `SELECT * FROM "CalculatePayroll"()` answers `function CalculatePayroll() does not exist`.
223
+ Quoting here would break every mixed-case routine that works today, and break it at the DRIVER.
224
+ The tables and columns the emitter quotes are the opposite case: those names come from the
225
+ metadata graph, and the ORM is what created them.
226
+
227
+ Qualified names are checked PART BY PART (`analytics.monthly_sales`), because the dot is the
228
+ separator and not a character of either name.
229
+ """
230
+ if not all(_ROUTINE_PART.fullmatch(part) for part in name.split(".")):
231
+ raise SnakeValueError(
232
+ f"'{name}' is not a valid routine name. The arguments travel parametrised, but the name "
233
+ f"cannot — no engine takes a placeholder where an identifier goes — so it reaches the "
234
+ f"SQL as written and every dot-separated part has to be a plain identifier: a letter or "
235
+ f"'_', then letters, digits, '_' or '$'. It is not quoted for you either, on purpose: an "
236
+ f"unquoted CREATE FUNCTION CalculatePayroll lands in PostgreSQL's catalogue as "
237
+ f"'calculatepayroll', so quoting the call would stop finding it. For a name that "
238
+ f"genuinely needs quotes, write the statement yourself with raw(...)."
239
+ )
240
+ return name
241
+
242
+
243
+ def plan_call(
244
+ name: str, args: Sequence[object], into: type[Any], dialect: SnakeDialect
245
+ ) -> tuple[str, tuple[object, ...], Callable[[Rows], list[Any]]]:
246
+ """SQL of a call to a database function (`SELECT * FROM f(...)`) and how to hydrate its rows.
247
+
248
+ The ARGS travel parametrised (user data); the NAME is an identifier, so it cannot — it is
249
+ checked by `routine_name` and emitted as written. `into` is `type[Any]` for the same reason
250
+ `plan_annotate`'s `result` is: it gets called with keywords, and the `Row: SnakeRow` bound of
251
+ both sessions is what rejects a class that is not a `@snake_row`.
252
+ """
253
+ checked = routine_name(name)
254
+ placeholders = ", ".join(
255
+ dialect.placeholder(index + 1) for index in range(len(args))
256
+ )
257
+ return (
258
+ f"SELECT * FROM {checked}({placeholders})",
259
+ tuple(args),
260
+ plan_raw(into, source=f"Routine '{checked}'"),
261
+ )
262
+
263
+
264
+ def plan_raw(into: type[Any], source: str = "The query") -> Callable[[Rows], list[Any]]:
265
+ """How to hydrate raw rows into a `@snake_row`, coercing each column to its declared type.
266
+
267
+ POSITIONAL mapping, with no shape check against the catalogue: you declare, I hydrate.
268
+ """
269
+
270
+ info = snake_row_info(into)
271
+
272
+ @timed_mapping
273
+ def hydrate_rows(rows: Rows) -> list[Any]:
274
+ """Builds one `into` per row, checking the width row by row."""
275
+ output: list[Any] = []
276
+ for row in rows:
277
+ if len(row) != len(info.columns):
278
+ raise SnakeEmitError(
279
+ f"{source} returns {len(row)} column(s) and {into.__name__} declares "
280
+ f"{len(info.columns)}: the mapping is positional, so they have to match."
281
+ )
282
+ output.append(
283
+ into(
284
+ **{
285
+ name: coerce(value, declared)
286
+ for (name, declared), value in zip(
287
+ info.columns, row, strict=True
288
+ )
289
+ }
290
+ )
291
+ )
292
+ return output
293
+
294
+ return hydrate_rows
295
+
296
+
297
+ def plan_to_many_level(
298
+ parents: Sequence[object], hop: SnakePrefetchHop, dialect: SnakeDialect
299
+ ) -> tuple[str, tuple[object, ...], Callable[[Rows], list[object]]] | None:
300
+ """Plan of ONE to-many level by select-in, or `None` if there is nothing to query.
301
+
302
+ `None` when no parent can have children; in that case it has ALREADY assigned the empty list to
303
+ each parent, otherwise the anti-N+1 lock would fire on touching the relationship saying it was
304
+ not loaded.
305
+ """
306
+ from collections import defaultdict
307
+
308
+ relationship = hop.relationship
309
+ pairs = relationship.foreign_key.pairs
310
+ child_columns = tuple(child_column for child_column, _ in pairs)
311
+ parent_pk_attrs = tuple(
312
+ _attr_for_column(hop.parent_table, parent_col) for _, parent_col in pairs
313
+ )
314
+ child_fk_attrs = tuple(
315
+ _attr_for_column(hop.child_table, child_col) for child_col in child_columns
316
+ )
317
+
318
+ parent_keys = [
319
+ key
320
+ for parent in parents
321
+ if None not in (key := tuple(getattr(parent, attr) for attr in parent_pk_attrs))
322
+ ]
323
+ if not parents or not parent_keys:
324
+ for parent in parents:
325
+ attach_relationship(parent, relationship.name, [])
326
+ return None
327
+
328
+ condition = _with_prefetch_filter(
329
+ _select_in_condition(child_columns, parent_keys), hop
330
+ )
331
+ sql, params = emit_select(hop.child_table, dialect, where=condition)
332
+
333
+ @timed_mapping
334
+ def attach(rows: Rows) -> list[object]:
335
+ """Groups the children by their FK and assigns the list to each parent (empty if it has none)."""
336
+ children: list[object] = [
337
+ _instantiate(hop.child_model, hop.child_table, row) for row in rows
338
+ ]
339
+ groups: dict[tuple[object, ...], list[object]] = defaultdict(list)
340
+ for child in children:
341
+ groups[tuple(getattr(child, attr) for attr in child_fk_attrs)].append(child)
342
+ for parent in parents:
343
+ key = tuple(getattr(parent, attr) for attr in parent_pk_attrs)
344
+ attach_relationship(parent, relationship.name, groups.get(key, []))
345
+ return children
346
+
347
+ return sql, params, attach
348
+
349
+
350
+ def plan_to_one_level(
351
+ parents: Sequence[object], hop: SnakePrefetchHop, dialect: SnakeDialect
352
+ ) -> tuple[str, tuple[object, ...], Callable[[Rows], list[object]]] | None:
353
+ """Plan of ONE to-one level inside a prefetch, or `None` if there is nothing to query.
354
+
355
+ The pairs of a to-one are (parent_local_column, child_referenced_column), so the FK lives in the
356
+ PARENT and the keys of the `IN` are values of its own.
357
+ """
358
+
359
+ relationship = hop.relationship
360
+ pairs = relationship.foreign_key.pairs
361
+ parent_fk_attrs = tuple(
362
+ _attr_for_column(hop.parent_table, local_col) for local_col, _ in pairs
363
+ )
364
+ child_ref_columns = tuple(remote_col for _, remote_col in pairs)
365
+ child_ref_attrs = tuple(
366
+ _attr_for_column(hop.child_table, remote_col)
367
+ for remote_col in child_ref_columns
368
+ )
369
+
370
+ # ONE key per DISTINCT value, not per parent row: in a to-one the foreign key lives on the
371
+ # parent and it REPEATS. Measured, 50,000 trucks over 50 makers binds 50,000 placeholders and
372
+ # ~150 KB of SQL to ask what 50 placeholders ask; counted over repetitions the batch also
373
+ # crosses SQLite's 32,766-placeholder ceiling, so `parents_per_batch` splits the read into TWO
374
+ # statements where one was enough.
375
+ #
376
+ # `dict.fromkeys` and not a `set`: the order of the parents is the order of the placeholders,
377
+ # and an unordered one would make the emitted SQL differ between runs for no reason.
378
+ #
379
+ # `attach` is unaffected: it indexes the children by key and looks each parent up in that map.
380
+ parent_keys = list(
381
+ dict.fromkeys(
382
+ key
383
+ for parent in parents
384
+ if None
385
+ not in (key := tuple(getattr(parent, attr) for attr in parent_fk_attrs))
386
+ )
387
+ )
388
+ if not parents or not parent_keys:
389
+ for parent in parents:
390
+ attach_relationship(parent, relationship.name, None)
391
+ return None
392
+
393
+ condition = _with_prefetch_filter(
394
+ _select_in_condition(child_ref_columns, parent_keys), hop
395
+ )
396
+ sql, params = emit_select(hop.child_table, dialect, where=condition)
397
+
398
+ @timed_mapping
399
+ def attach(rows: Rows) -> list[object]:
400
+ """Indexes the children by their referenced key and assigns them to whichever parent is due."""
401
+ children: list[object] = [
402
+ _instantiate(hop.child_model, hop.child_table, row) for row in rows
403
+ ]
404
+ by_key: dict[tuple[object, ...], object] = {
405
+ tuple(getattr(child, attr) for attr in child_ref_attrs): child
406
+ for child in children
407
+ }
408
+ for parent in parents:
409
+ key = tuple(getattr(parent, attr) for attr in parent_fk_attrs)
410
+ attach_relationship(parent, relationship.name, by_key.get(key))
411
+ return list(by_key.values())
412
+
413
+ return sql, params, attach
414
+
415
+
416
+ def plan_through_level(
417
+ parents: Sequence[object], hop: SnakePrefetchHop, dialect: SnakeDialect
418
+ ) -> tuple[str, tuple[object, ...], Callable[[Rows], list[object]]] | None:
419
+ """Plan of a MANY-TO-MANY level: one query with a JOIN against the bridge (one query per level).
420
+
421
+ It emits `SELECT target.*, bridge.<parent_fk> FROM target JOIN bridge ... WHERE
422
+ bridge.<parent_fk> IN (...)`. The bridge's extra column travels at the end so it can GROUP by
423
+ parent without a second query (which would be an N+1). `None` — after leaving the empty list on
424
+ each parent — if there are no keys.
425
+ """
426
+ from collections import defaultdict
427
+
428
+ relationship = hop.relationship
429
+ bridge = relationship.through
430
+ assert bridge is not None, "plan_through_level only applies to a to_many_through"
431
+
432
+ parent_attrs = tuple(
433
+ _attr_for_column(hop.parent_table, parent_col)
434
+ for _, parent_col in bridge.to_parent
435
+ )
436
+ parent_keys = [
437
+ key
438
+ for parent in parents
439
+ if None not in (key := tuple(getattr(parent, attr) for attr in parent_attrs))
440
+ ]
441
+ if not parents or not parent_keys:
442
+ for parent in parents:
443
+ attach_relationship(parent, relationship.name, [])
444
+ return None
445
+
446
+ quote = dialect.quote_ident
447
+ target = qualified(hop.child_table.schema, hop.child_table.name, dialect)
448
+ bridge_schema, _, bridge_name = bridge.table.rpartition(".")
449
+ bridge_table = qualified(bridge_schema, bridge_name, dialect)
450
+
451
+ columns = ", ".join(f"d.{quote(c.name)}" for c in hop.child_table.columns)
452
+ link = " AND ".join(
453
+ f"p.{quote(bridge_col)} = d.{quote(target_col)}"
454
+ for bridge_col, target_col in bridge.to_target
455
+ )
456
+ bridge_keys = [quote(col) for col, _ in bridge.to_parent]
457
+ filter_cols = ", ".join(f"p.{col}" for col in bridge_keys)
458
+
459
+ params: list[object] = []
460
+ sql_groups: list[str] = []
461
+ for key in parent_keys:
462
+ placeholders = ", ".join(
463
+ dialect.placeholder(len(params) + i + 1) for i in range(len(key))
464
+ )
465
+ params.extend(key)
466
+ sql_groups.append(f"({placeholders})" if len(key) > 1 else placeholders)
467
+ items = ", ".join(sql_groups)
468
+ left_side = f"({filter_cols})" if len(bridge_keys) > 1 else filter_cols
469
+
470
+ sql = (
471
+ f"SELECT {columns}, {filter_cols} FROM {target} AS d "
472
+ f"JOIN {bridge_table} AS p ON {link} WHERE {left_side} IN ({items})"
473
+ )
474
+ width = len(hop.child_table.columns)
475
+
476
+ @timed_mapping
477
+ def attach(rows: Rows) -> list[object]:
478
+ """Groups by the parent key that travels at the end of each row."""
479
+ groups: dict[tuple[object, ...], list[object]] = defaultdict(list)
480
+ children: list[object] = []
481
+ for row in rows:
482
+ child = _instantiate(hop.child_model, hop.child_table, row[:width])
483
+ children.append(child)
484
+ groups[tuple(row[width:])].append(child)
485
+ for parent in parents:
486
+ key = tuple(getattr(parent, attr) for attr in parent_attrs)
487
+ attach_relationship(parent, relationship.name, groups.get(key, []))
488
+ return children
489
+
490
+ return sql, tuple(params), attach
491
+
492
+
493
+ def parents_per_batch(hop: SnakePrefetchHop, dialect: SnakeDialect) -> int:
494
+ """How many parents fit in ONE select-in without overshooting the engine's placeholder ceiling.
495
+
496
+ The read side used to bind one placeholder per parent and emit a single statement, while
497
+ `add_all` had been slicing by `max_bind_params` since it existed. Past 65,535 parents on Postgres
498
+ (32,766 on SQLite) the driver simply rejected it. There was no decision behind the asymmetry, so
499
+ the fix is the loop that was already there, not a new one.
500
+
501
+ Measured in PLACEHOLDERS, not in parents: a composite FK costs one per column, so a two-column
502
+ key halves the batch. And the prefetch filter binds its own out of the same budget, which is why
503
+ they are counted rather than guessed at with a fixed margin — a margin an `in_([...5000 ids])`
504
+ inside the filter walks straight through. Counting them costs one emission per LEVEL (pure string
505
+ building, no database), and levels come in twos and threes.
506
+ """
507
+ # `foreign_key` is a required field of `SnakeRelationshipInfo`, so it is read and not `getattr`'d
508
+ # with a default: the guard that used to be here only looked like caution once the hop was typed.
509
+ per_parent = max(1, len(hop.relationship.foreign_key.pairs))
510
+ spent = 0
511
+ if hop.child_filter is not None:
512
+ _, filter_params = emit_condition(hop.child_filter, dialect)
513
+ spent = len(filter_params)
514
+ return max(1, (dialect.max_bind_params - spent) // per_parent)
515
+
516
+
517
+ def plan_level(
518
+ parents: Sequence[object], hop: SnakePrefetchHop, dialect: SnakeDialect
519
+ ) -> tuple[str, tuple[object, ...], Callable[[Rows], list[object]]] | None:
520
+ """The plan of whichever level is due, according to the kind of relationship. A single place
521
+ decides which of the three it is, so as not to have two copies of the dispatch table (the second
522
+ one ends up forgetting a row)."""
523
+ if hop.kind is SnakeRelationshipKind.TO_MANY_THROUGH:
524
+ return plan_through_level(parents, hop, dialect)
525
+ if hop.kind is SnakeRelationshipKind.TO_MANY:
526
+ return plan_to_many_level(parents, hop, dialect)
527
+ return plan_to_one_level(parents, hop, dialect)
528
+
529
+
530
+ # -- From row to object: the other half of the plan ---------------------------------------------
531
+ # These functions used to live in `session.py` and were asked back for with lazy imports (the
532
+ # extracted core depended on its consumer). None of them uses the session: they are ROW logic, and
533
+ # this is where they belong.
534
+
535
+
536
+ def _validate_aggregate_names(
537
+ result: type, info: SnakeResultInfo, aggregates: dict[str, SnakeValue[Any]]
538
+ ) -> None:
539
+ """Checks that the aggregates match the `@snake_result`'s scalar fields EXACTLY, before emitting
540
+ any SQL (a `SnakeEmitError` saying which ones are spare/missing)."""
541
+ expected = {name for name, _ in info.scalars}
542
+ provided = set(aggregates)
543
+ missing = expected - provided
544
+ extra = provided - expected
545
+ if not missing and not extra:
546
+ return
547
+ parts: list[str] = []
548
+ if missing:
549
+ parts.append(f"missing {sorted(missing)}")
550
+ if extra:
551
+ parts.append(f"extra {sorted(extra)}")
552
+ raise SnakeEmitError(
553
+ f"The aggregates do not match the scalar fields of {result.__name__} "
554
+ f"({sorted(expected)}): {'; '.join(parts)}."
555
+ )
556
+
557
+
558
+ def _projected_python_type(
559
+ value: SnakeValue[Any], root: SnakeTableInfo | None, reg: SnakeRegistry
560
+ ) -> type | None:
561
+ """python_type of a value projected in `select()`, or None if it cannot be determined.
562
+
563
+ A column -> the type of its path; an aggregate -> its return type. The rest (arithmetic,
564
+ subqueries) has no declared type: None -> it passes through uncoerced (no type gets invented).
565
+ """
566
+ if root is None:
567
+ return None
568
+ if isinstance(value, SnakeAggregate):
569
+ return _aggregate_python_type(value, root, reg)
570
+ if isinstance(value, SnakeExpr):
571
+ column = _resolve_column(value.path, root, reg)
572
+ return column.python_type if column is not None else None
573
+ return None
574
+
575
+
576
+ def _instantiate(model: type[T], table: SnakeTableInfo, row: Sequence[object]) -> T:
577
+ """Builds an instance of the model with the row's values, in column order.
578
+
579
+ It delegates to `mapper.hydrate` (a plan compiled ONCE per table). It is also the ONLY point
580
+ where the concrete class of a polymorphic hierarchy gets decided (the row carries its
581
+ discriminator), and that is why `all`/`first`/`get`/`include` inherit it without noticing. The
582
+ dispatch is two lookups (an already-computed position + a `dict.get`): everything else is fixed
583
+ per table and lives in the plan.
584
+ """
585
+ dispatch = dispatch_for(model, table)
586
+ if dispatch is not None:
587
+ position, by_value = dispatch
588
+ # `isinstance` (not `cast`): the compiler guarantees that the COLUMN is `str`, not that the
589
+ # driver returns one. If it is not, it hydrates as the base, just like an unknown discriminator.
590
+ value = row[position]
591
+ concrete_model = by_value.get(value) if isinstance(value, str) else None
592
+ if concrete_model is not None and concrete_model is not model:
593
+ return cast("T", hydrate(concrete_model, table, row, owner=model))
594
+ return hydrate(model, table, row)
595
+
596
+
597
+ def _instantiate_all(
598
+ model: type[T], table: SnakeTableInfo, rows: Sequence[Sequence[object]]
599
+ ) -> list[T]:
600
+ """Instantiates ALL the rows resolving dispatch and plan ONCE, not per row.
601
+
602
+ For the common case (non-polymorphic) the plan is resolved once and the loop only writes
603
+ attributes. Polymorphism needs to decide the subclass per row, so it falls back to
604
+ `_instantiate`'s path.
605
+ """
606
+ if dispatch_for(model, table) is not None:
607
+ return [_instantiate(model, table, row) for row in rows]
608
+ plan = plan_for(model, table, model)
609
+ return [_hydrate_with_plan(model, plan, row) for row in rows]
610
+
611
+
612
+ @timed_mapping
613
+ def instantiate_rows_with_includes(
614
+ segments: Sequence[tuple[tuple[str, ...], type, SnakeTableInfo]],
615
+ rows: Sequence[Sequence[object]],
616
+ ) -> list[object]:
617
+ """Rebuilds EVERY root of a wide result set (a SELECT with includes), rows already in hand.
618
+
619
+ Written once here rather than as the same comprehension in each session: the two sessions are
620
+ twins, and a decision written on one of two twins is a promise that the other will forget it.
621
+ """
622
+ # Every plan is resolved ONCE per segment, before the row loop starts. `_instantiate` and
623
+ # `_pk_is_null` each go through `mapper._entry`, so calling them inside the loop cost three
624
+ # lookups per segment per ROW — 250 of them for 50 rows with one include. The flat path had
625
+ # already solved this (`_instantiate_all` hoists) and `mapper` promises it in writing: "Whoever
626
+ # maps N rows resolves the plan once". The include was the caller that did not keep it.
627
+ #
628
+ # Hoisting stops HERE, at the call. `mapper` invalidates by the table's REFERENCE, so caching
629
+ # these above the call would be a second cache with its own rule — which `_entry`'s docstring
630
+ # forbids, and which would be a worse bug than the one being fixed.
631
+ return [
632
+ _instantiate_with_compiled(compiled, row)
633
+ for compiled in (compile_segments(segments),)
634
+ for row in rows
635
+ ]
636
+
637
+
638
+ def compile_segments(
639
+ segments: Sequence[tuple[tuple[str, ...], type, SnakeTableInfo]],
640
+ ) -> tuple[_CompiledSegment, ...]:
641
+ """Resolves every segment's plan ONCE. Call it OUTSIDE the row loop; that is the whole point.
642
+
643
+ Public within the package because the STREAMING paths need it too: they hydrate row by row from
644
+ a generator, so a helper that compiles internally would resolve per row all over again — which
645
+ is the failure this exists to remove, hidden one layer deeper where a `.all()` test cannot see it.
646
+ """
647
+ return tuple(
648
+ _CompiledSegment(
649
+ prefix=prefix,
650
+ model=model,
651
+ table=table,
652
+ width=len(table.columns),
653
+ dispatch=dispatch_for(model, table),
654
+ plan=plan_for(model, table, model),
655
+ pks=pk_positions(model, table),
656
+ )
657
+ for prefix, model, table in segments
658
+ )
659
+
660
+
661
+ @dataclass(frozen=True, slots=True)
662
+ class _CompiledSegment:
663
+ """One segment of a wide row with everything about it already resolved.
664
+
665
+ Named `_CompiledSegment` and not `_IncludeSegment`: `query.py` already exports an
666
+ `IncludeSegment`, which is this one's INPUT, and two names one underscore apart for a thing and
667
+ its precompiled form is how a reader ends up debugging the wrong one.
668
+ """
669
+
670
+ prefix: tuple[str, ...]
671
+ model: type
672
+ table: SnakeTableInfo
673
+ width: int
674
+ dispatch: _Dispatch
675
+ plan: tuple[_Instruction, ...]
676
+ pks: tuple[int, ...]
677
+
678
+
679
+ def _instantiate_with_compiled(
680
+ segments: Sequence[_CompiledSegment], row: Sequence[object]
681
+ ) -> object:
682
+ """Rebuilds the root object with its relatives, with every plan already in hand.
683
+
684
+ The loop only slices the row and writes: no lookups, no resolution. A relationship with no match
685
+ (a LEFT JOIN with a null PK) is left as None, not as a phantom object.
686
+ """
687
+ objects: dict[tuple[str, ...], object | None] = {}
688
+ offset = 0
689
+ for segment in segments:
690
+ chunk = row[offset : offset + segment.width]
691
+ offset += segment.width
692
+ if segment.prefix and all(chunk[pos] is None for pos in segment.pks):
693
+ objects[segment.prefix] = None
694
+ elif segment.dispatch is not None:
695
+ # A polymorphic segment decides its class per ROW, so it goes the long way round —
696
+ # which is the same exception `_instantiate_all` makes on the flat path.
697
+ objects[segment.prefix] = _instantiate(segment.model, segment.table, chunk)
698
+ else:
699
+ objects[segment.prefix] = _hydrate_with_plan(
700
+ segment.model, segment.plan, chunk
701
+ )
702
+ for segment in segments:
703
+ parent = objects[segment.prefix[:-1]] if segment.prefix else None
704
+ if segment.prefix and parent is not None:
705
+ attach_relationship(parent, segment.prefix[-1], objects[segment.prefix])
706
+ return objects[()]
707
+
708
+
709
+ def _instantiate_with_includes(
710
+ segments: Sequence[tuple[tuple[str, ...], type, SnakeTableInfo]],
711
+ row: Sequence[object],
712
+ ) -> object:
713
+ """Rebuilds the root object with its relatives from a wide row (a SELECT with includes).
714
+
715
+ It slices the row up by segment, instantiates each one and hooks it onto its parent. A
716
+ relationship with no match (a LEFT JOIN with a null PK) is left as None, not as a phantom object.
717
+ """
718
+ objects: dict[tuple[str, ...], object | None] = {}
719
+ offset = 0
720
+ for prefix, model, table in segments:
721
+ width = len(table.columns)
722
+ chunk = row[offset : offset + width]
723
+ offset += width
724
+ objects[prefix] = (
725
+ None
726
+ if prefix and _pk_is_null(model, table, chunk)
727
+ else _instantiate(model, table, chunk)
728
+ )
729
+ for prefix, _model, _table in segments:
730
+ parent = objects[prefix[:-1]] if prefix else None
731
+ if prefix and parent is not None:
732
+ attach_relationship(parent, prefix[-1], objects[prefix])
733
+ return objects[()]
734
+
735
+
736
+ def _select_in_condition(
737
+ child_columns: tuple[str, ...], parent_keys: list[tuple[object, ...]]
738
+ ) -> SnakeCondition:
739
+ """Filter of the select-in: a simple FK -> `col IN (...)`; a composite FK -> `(a, b) IN ((...),
740
+ ...)` (`SnakeTupleIn`, one row per parent with the tuple of its PK values)."""
741
+ if len(child_columns) == 1:
742
+ column: SnakeExpr[object] = SnakeExpr(path=(child_columns[0],))
743
+ return column.in_([key[0] for key in parent_keys])
744
+ columns: tuple[SnakeValue[object], ...] = tuple(
745
+ SnakeExpr(path=(name,)) for name in child_columns
746
+ )
747
+ return SnakeTupleIn(columns=columns, rows=tuple(parent_keys))
748
+
749
+
750
+ def _attr_for_column(table: SnakeTableInfo, name: str) -> str:
751
+ """Returns a column's Python attribute by its SQL name (to read/group by FK/PK)."""
752
+ for column in table.columns:
753
+ if column.name == name:
754
+ return column.attr_name or column.name
755
+ raise SnakeUnknownColumn(f"Column '{name}' does not exist in '{table.name}'.")
756
+
757
+
758
+ def _aggregate_python_type(
759
+ aggregate: SnakeAggregate[Any], root: SnakeTableInfo, reg: SnakeRegistry
760
+ ) -> type | None:
761
+ """Return type of an aggregate: COUNT -> int, AVG -> float; SUM/MIN/MAX preserve their column's."""
762
+ if aggregate.func is SnakeAggFunc.COUNT:
763
+ return int
764
+ if aggregate.func is SnakeAggFunc.AVG:
765
+ return float
766
+ if isinstance(aggregate.arg, SnakeExpr):
767
+ column = _resolve_column(aggregate.arg.path, root, reg)
768
+ return column.python_type if column is not None else None
769
+ return None
770
+
771
+
772
+ def _resolve_column(
773
+ path: tuple[str, ...], root: SnakeTableInfo, reg: SnakeRegistry
774
+ ) -> SnakeColumnInfo | None:
775
+ """Resolves a path (relationships... + column) to its SnakeColumnInfo, or None if unreachable.
776
+
777
+ It walks the graph along the path's hops; None on any link that does not match (the caller does
778
+ not coerce).
779
+ """
780
+ table: SnakeTableInfo | None = root
781
+ for step in path[:-1]:
782
+ if table is None:
783
+ return None
784
+ relationship = next(
785
+ (rel for rel in table.relationships if rel.name == step), None
786
+ )
787
+ if relationship is None:
788
+ return None
789
+ # The caller's registry, threaded rather than defaulted. A default of the global one is
790
+ # exactly the shape of the bug: it works until two models share a class name, and then
791
+ # this walk reads a stranger's table and coerces the projection to the wrong type.
792
+ table = reg.resolve_relationship(relationship)[0]
793
+ return table.get_column(path[-1]) if table is not None else None
794
+
795
+
796
+ def _pk_is_null(model: type, table: SnakeTableInfo, chunk: Sequence[object]) -> bool:
797
+ """Tells whether the chunk's PK is all NULL (a LEFT JOIN with no match -> no such relationship).
798
+
799
+ The positions come precomputed: resolving them per row was O(columns²) on the `include` path.
800
+ """
801
+ return all(chunk[position] is None for position in pk_positions(model, table))
802
+
803
+
804
+ def _with_prefetch_filter(
805
+ condition: SnakeCondition, hop: SnakePrefetchHop
806
+ ) -> SnakeCondition:
807
+ """ANDs the hop's filter (if there is one) onto the `WHERE fk IN (...)` of that level's select-in.
808
+
809
+ The filter's paths are DIRECT columns of the child (the subquery is over a single table), so they
810
+ are emitted unqualified. It narrows WHICH CHILDREN come back; the grouping afterwards still
811
+ assigns [] to the parents with no matching children.
812
+ """
813
+ if hop.child_filter is None:
814
+ return condition
815
+ return SnakeAnd(parts=(condition, hop.child_filter))