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,232 @@
1
+ """Emission of aggregates (COUNT/EXISTS/projection) reusing a SELECT's FROM+JOINs+WHERE.
2
+
3
+ Same deep-navigation `plan`; they project an aggregate instead of columns.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Iterator, Sequence
9
+ from dataclasses import fields, is_dataclass
10
+ from typing import Any
11
+
12
+ from snakeorm.dialects import SnakeDialect
13
+ from snakeorm.core.exceptions import SnakeEmitError
14
+ from snakeorm.expressions import (
15
+ SnakeAggregate,
16
+ SnakeStringAgg,
17
+ SnakeCondition,
18
+ SnakeOrder,
19
+ SnakeValue,
20
+ count,
21
+ )
22
+ from snakeorm.expressions.window import has_window
23
+ from snakeorm.metadata import SnakeTableInfo
24
+ from snakeorm.sql.condition import emit_condition_into
25
+ from snakeorm.sql.joins import JoinPlan
26
+ from snakeorm.sql.refs import qualified
27
+ from snakeorm.sql.value import Correlation, ExistsAliases, emit_order_key, emit_value
28
+
29
+
30
+ def emit_count(
31
+ table: SnakeTableInfo,
32
+ dialect: SnakeDialect,
33
+ where: SnakeCondition | None = None,
34
+ plan: JoinPlan | None = None,
35
+ ) -> tuple[str, tuple[object, ...]]:
36
+ """Emits `SELECT COUNT(*) FROM ... [JOINs] [WHERE]`.
37
+
38
+ The COUNT(*) is emitted through the `count()` node (the only place that knows how to write it);
39
+ it consumes no params.
40
+ """
41
+ params: list[object] = []
42
+ qualify = plan.alias_for if plan is not None else None
43
+ projected = emit_value(count(), dialect, params, qualify)
44
+ return (
45
+ f"SELECT {projected} {_from_where(table, dialect, params, where, plan)}",
46
+ tuple(params),
47
+ )
48
+
49
+
50
+ def emit_exists(
51
+ table: SnakeTableInfo,
52
+ dialect: SnakeDialect,
53
+ where: SnakeCondition | None = None,
54
+ plan: JoinPlan | None = None,
55
+ ) -> tuple[str, tuple[object, ...]]:
56
+ """Emits `SELECT EXISTS(SELECT 1 FROM ... [JOINs] [WHERE])`."""
57
+ params: list[object] = []
58
+ inner = _from_where(table, dialect, params, where, plan)
59
+ return f"SELECT EXISTS(SELECT 1 {inner})", tuple(params)
60
+
61
+
62
+ def emit_project(
63
+ table: SnakeTableInfo,
64
+ dialect: SnakeDialect,
65
+ columns: Sequence[SnakeValue[Any]],
66
+ where: SnakeCondition | None = None,
67
+ plan: JoinPlan | None = None,
68
+ group_by: Sequence[SnakeValue[Any]] = (),
69
+ having: SnakeCondition | None = None,
70
+ order_by: tuple[SnakeOrder, ...] = (),
71
+ limit: int | None = None,
72
+ offset: int | None = None,
73
+ functional_dependency: bool = False,
74
+ distinct: bool = False,
75
+ ) -> tuple[str, tuple[object, ...]]:
76
+ """Emits `SELECT [DISTINCT] <cols> FROM ... [JOINs] [WHERE] [GROUP BY] [HAVING] [ORDER BY] [LIMIT]`.
77
+
78
+ Params in clause order (= SQL evaluation order) for continuous numbering. `having` without
79
+ `group_by` is valid (a global aggregate). `functional_dependency=True` disables the GROUP BY
80
+ guard: it is used by `annotate()`, which projects every column while grouping only by the PK
81
+ (they all depend on it).
82
+ """
83
+ if not functional_dependency:
84
+ _guard_grouped_projection(columns, group_by)
85
+ _guard_no_window_in_group_by(group_by)
86
+ params: list[object] = []
87
+ qualify = plan.alias_for if plan is not None else None
88
+ # One single correlation for the whole statement: aggregates that are correlated subqueries
89
+ # (e.g. a `collection.count()` projected by annotate) need to know how to reference the parent.
90
+ # Plain columns and aggregates ignore it.
91
+ correlate = _correlation(table, dialect, plan)
92
+ projected = ", ".join(
93
+ emit_value(column, dialect, params, qualify, correlate) for column in columns
94
+ )
95
+ keyword = "SELECT DISTINCT" if distinct else "SELECT"
96
+ sql = f"{keyword} {projected} {_from_where(table, dialect, params, where, plan)}"
97
+ if group_by:
98
+ keys = ", ".join(
99
+ emit_value(column, dialect, params, qualify, correlate)
100
+ for column in group_by
101
+ )
102
+ sql = f"{sql} GROUP BY {keys}"
103
+ if having is not None:
104
+ sql = f"{sql} HAVING {emit_condition_into(having, dialect, params, qualify, correlate)}"
105
+ if order_by:
106
+ keys = ", ".join(
107
+ emit_order_key(key, dialect, params, qualify, correlate) for key in order_by
108
+ )
109
+ sql = f"{sql} ORDER BY {keys}"
110
+ clause = dialect.limit_offset(limit, offset, params)
111
+ if clause:
112
+ sql = f"{sql} {clause}"
113
+ return sql, tuple(params)
114
+
115
+
116
+ def _correlation(
117
+ table: SnakeTableInfo, dialect: SnakeDialect, plan: JoinPlan | None
118
+ ) -> Correlation:
119
+ """Correlation context for the HAVING (in case it carries correlated subqueries)."""
120
+ parent_ref = (
121
+ plan.root_alias if plan is not None else dialect.quote_ident(table.name)
122
+ )
123
+ return Correlation(parent_ref=parent_ref, aliases=ExistsAliases())
124
+
125
+
126
+ def _flattened(child: object) -> Iterator[object]:
127
+ """One field's value as nodes, unwrapping sequences however deeply they nest.
128
+
129
+ A `CASE` keeps its branches as a tuple OF TUPLES, so unwrapping one level finds a tuple and
130
+ stops there — which is how the aggregate inside a branch stayed invisible.
131
+ """
132
+ if isinstance(child, (tuple, list)):
133
+ for item in child:
134
+ yield from _flattened(item)
135
+ else:
136
+ yield child
137
+
138
+
139
+ def _subnodes(value: object) -> Iterator[object]:
140
+ """The direct children of an expression node, with its sequences flattened out.
141
+
142
+ It reads the node's own fields instead of naming the node types, and that is deliberate: the set
143
+ of things that can wrap an aggregate is open — `COALESCE`, `NULLIF`, `CASE`, arithmetic, a cast,
144
+ and whatever comes next. A walk that lists them is a walk that has to be remembered.
145
+ """
146
+ if not is_dataclass(value) or isinstance(value, type):
147
+ return
148
+ for field in fields(value):
149
+ yield from _flattened(getattr(value, field.name))
150
+
151
+
152
+ def _aggregated_paths(value: object) -> set[tuple[str, ...]]:
153
+ """Every path that sits INSIDE an aggregate, however deeply it is wrapped.
154
+
155
+ An aggregate's own paths are all of them: `SUM(x)` aggregates `x` wherever the `SUM` appears.
156
+
157
+ `SnakeStringAgg` is named alongside `SnakeAggregate` and not folded into it: it IS an aggregate
158
+ —it collapses a group— but it is a separate node because its separator has no fixed position
159
+ across the engines. A walk that only knew the one class would have refused `string_agg(name)`
160
+ beside a `GROUP BY` as an ungrouped column, which is the opposite of what it is.
161
+ """
162
+ if isinstance(value, (SnakeAggregate, SnakeStringAgg)):
163
+ return set(value.paths())
164
+ found: set[tuple[str, ...]] = set()
165
+ for child in _subnodes(value):
166
+ found |= _aggregated_paths(child)
167
+ return found
168
+
169
+
170
+ def _guard_grouped_projection(
171
+ columns: Sequence[SnakeValue[Any]], group_by: Sequence[SnakeValue[Any]]
172
+ ) -> None:
173
+ """With GROUP BY, every non-aggregated projected column must be grouped (caught before Postgres).
174
+
175
+ The comparison goes through `paths()`. A path is fine when it is grouped OR when it sits inside
176
+ an aggregate — which is what the error message has always said, and what the check used to
177
+ decide by asking whether the projected node ITSELF was an aggregate. `COALESCE(SUM(x), 0)` is
178
+ valid on every engine and was being refused, because the outer node is a `COALESCE`.
179
+
180
+ The other direction is what the walk must not lose: `COALESCE(name, '')` aggregates nothing, and
181
+ a bare column laundered through a wrapper has to stay an error.
182
+ """
183
+ if not group_by:
184
+ return
185
+ grouped = {path for column in group_by for path in column.paths()}
186
+ for column in columns:
187
+ aggregated = _aggregated_paths(column)
188
+ for path in column.paths():
189
+ if path not in grouped and path not in aggregated:
190
+ name = ".".join(path)
191
+ raise SnakeEmitError(
192
+ f"Column '{name}' appears in the projection but neither in the GROUP BY "
193
+ f"nor inside an aggregate. Add it to group_by(...) or aggregate it."
194
+ )
195
+
196
+
197
+ def _from_where(
198
+ table: SnakeTableInfo,
199
+ dialect: SnakeDialect,
200
+ params: list[object],
201
+ where: SnakeCondition | None,
202
+ plan: JoinPlan | None,
203
+ ) -> str:
204
+ """Builds `FROM <table> [AS t0] [JOINs] [WHERE <cond>]` (shared by count/exists)."""
205
+ qualify = plan.alias_for if plan is not None else None
206
+ table_ref = qualified(table.schema, table.name, dialect)
207
+ if plan is not None:
208
+ table_ref = f"{table_ref} AS {plan.root_alias}"
209
+ sql = f"FROM {table_ref}"
210
+ if plan is not None and plan.has_joins:
211
+ sql = f"{sql} {' '.join(plan.joins)}"
212
+ if where is not None:
213
+ parent_ref = (
214
+ plan.root_alias if plan is not None else dialect.quote_ident(table.name)
215
+ )
216
+ correlate = Correlation(parent_ref=parent_ref, aliases=ExistsAliases())
217
+ sql = f"{sql} WHERE {emit_condition_into(where, dialect, params, qualify, correlate)}"
218
+ return sql
219
+
220
+
221
+ def _guard_no_window_in_group_by(group_by: Sequence[SnakeValue[Any]]) -> None:
222
+ """A window cannot group: SQL evaluates windows AFTER the GROUP BY.
223
+
224
+ The condition guard covers WHERE/HAVING, but the GROUP BY is a list of values, not a condition.
225
+ """
226
+ for value in group_by:
227
+ if has_window(value):
228
+ raise SnakeEmitError(
229
+ "A window function cannot go in a GROUP BY: SQL evaluates it AFTER grouping, so "
230
+ "there is nothing to group by yet. Group by the columns and project the window, "
231
+ "or wrap the query and group on the outside."
232
+ )
@@ -0,0 +1,359 @@
1
+ """Emission of a parametrised WHERE: SnakeCondition -> (sql, params).
2
+
3
+ Walks the boolean AST and produces SQL with the dialect's placeholders; the values travel in
4
+ `params` (kills injection and enables multi-engine). `qualify` qualifies columns with an alias (deep
5
+ JOINs).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from typing import Any
12
+
13
+ from snakeorm.dialects import SnakeDialect
14
+ from snakeorm.core.exceptions import (
15
+ SnakeEmitError,
16
+ SnakeNodeError,
17
+ SnakeUnsupportedFeature,
18
+ )
19
+ from snakeorm.expressions import (
20
+ SnakeAnd,
21
+ SnakeComparison,
22
+ SnakeCondition,
23
+ SnakeExists,
24
+ SnakeExistsJoin,
25
+ SnakeInList,
26
+ SnakeInSubquery,
27
+ SnakeIsNotNull,
28
+ SnakeIsNull,
29
+ SnakeLike,
30
+ SnakeNot,
31
+ SnakeOr,
32
+ SnakeSubquery,
33
+ SnakeTupleIn,
34
+ )
35
+ from snakeorm.expressions.window import has_window
36
+ from snakeorm.sql.refs import qualified
37
+ from snakeorm.sql.value import (
38
+ Correlation,
39
+ ExistsAliases,
40
+ Qualify,
41
+ emit_expression_or_literal,
42
+ emit_operand,
43
+ emit_value,
44
+ )
45
+
46
+
47
+ def emit_condition(
48
+ condition: SnakeCondition,
49
+ dialect: SnakeDialect,
50
+ qualify: Qualify | None = None,
51
+ correlate: Correlation | None = None,
52
+ ) -> tuple[str, tuple[object, ...]]:
53
+ """Translates an AST condition into a parametrised `(sql, params)`."""
54
+ params: list[object] = []
55
+ sql = emit_condition_into(condition, dialect, params, qualify, correlate)
56
+ return sql, tuple(params)
57
+
58
+
59
+ def inline_params(sql: str, params: Sequence[object], dialect: SnakeDialect) -> str:
60
+ """Replaces a compiled SQL's placeholders with their literals, for DDL (which takes no params).
61
+
62
+ A forward pass that never revisits: a literal containing the placeholder token is not
63
+ reinterpreted. The token is asked of the dialect by index (for numbered placeholders `$1`, `$2`).
64
+ """
65
+ written: list[str] = []
66
+ rest = sql
67
+ for index, value in enumerate(params, start=1):
68
+ head, separator, rest = rest.partition(dialect.placeholder(index))
69
+ if not separator:
70
+ raise SnakeEmitError(
71
+ f"The compiled SQL is missing a placeholder for parameter {index} of "
72
+ f"{len(params)}: it cannot be inlined into DDL."
73
+ )
74
+ written.append(head)
75
+ written.append(dialect.literal(value))
76
+ written.append(rest)
77
+ return "".join(written)
78
+
79
+
80
+ def emit_condition_ddl(condition: SnakeCondition, dialect: SnakeDialect) -> str:
81
+ """Emits a condition for DDL: reuses the parametrised walk and then inlines the literals.
82
+
83
+ It does not reopen injection: only the programmer's own literals get here, escaped by the
84
+ dialect.
85
+ """
86
+ sql, params = emit_condition(condition, dialect)
87
+ return inline_params(sql, params, dialect)
88
+
89
+
90
+ def _value_in_condition(
91
+ value: object,
92
+ dialect: SnakeDialect,
93
+ params: list[object],
94
+ qualify: Qualify | None = None,
95
+ correlate: Correlation | None = None,
96
+ ) -> str:
97
+ """Emits a value inside a condition, rejecting window functions.
98
+
99
+ SQL evaluates windows AFTER WHERE/GROUP BY/HAVING. The guard lives here (the single choke point
100
+ every condition goes through) instead of being repeated in every node.
101
+ """
102
+ if has_window(value):
103
+ raise SnakeEmitError(
104
+ "A window function cannot be used in a WHERE, a GROUP BY or a HAVING: SQL evaluates "
105
+ "them AFTER those clauses. Project it with select(...) and filter on the outside, "
106
+ "or solve it with a subquery."
107
+ )
108
+ return emit_value(value, dialect, params, qualify, correlate)
109
+
110
+
111
+ def emit_condition_into(
112
+ node: SnakeCondition,
113
+ dialect: SnakeDialect,
114
+ params: list[object],
115
+ qualify: Qualify | None = None,
116
+ correlate: Correlation | None = None,
117
+ ) -> str:
118
+ """Emits a node accumulating values into `params` (a side effect) and returns the SQL.
119
+
120
+ The placeholders use `len(params)`, so the numbering carries on (this is what allows combining
121
+ SET + WHERE without a collision). `qualify` qualifies columns with an alias; `correlate` carries
122
+ the EXISTS/COUNT context.
123
+ """
124
+ if isinstance(node, SnakeComparison):
125
+ left = _value_in_condition(node.left, dialect, params, qualify, correlate)
126
+ right = emit_expression_or_literal(
127
+ node.right, dialect, params, qualify, correlate
128
+ )
129
+ return f"{left} {node.op.value} {right}"
130
+ if isinstance(node, SnakeAnd):
131
+ joined = " AND ".join(
132
+ emit_condition_into(part, dialect, params, qualify, correlate)
133
+ for part in node.parts
134
+ )
135
+ return f"({joined})"
136
+ if isinstance(node, SnakeOr):
137
+ joined = " OR ".join(
138
+ emit_condition_into(part, dialect, params, qualify, correlate)
139
+ for part in node.parts
140
+ )
141
+ return f"({joined})"
142
+ if isinstance(node, SnakeInList):
143
+ if not node.values:
144
+ raise SnakeEmitError("An IN needs at least one value")
145
+ left = _value_in_condition(node.left, dialect, params, qualify, correlate)
146
+ items = ", ".join(
147
+ emit_expression_or_literal(value, dialect, params, qualify, correlate)
148
+ for value in node.values
149
+ )
150
+ return f"{left} IN ({items})"
151
+ if isinstance(node, SnakeTupleIn):
152
+ return _emit_tuple_in(node, dialect, params, qualify, correlate)
153
+ if isinstance(node, SnakeIsNull):
154
+ return f"{_value_in_condition(node.left, dialect, params, qualify, correlate)} IS NULL"
155
+ if isinstance(node, SnakeIsNotNull):
156
+ return f"{_value_in_condition(node.left, dialect, params, qualify, correlate)} IS NOT NULL"
157
+ if isinstance(node, SnakeLike):
158
+ left = _value_in_condition(node.left, dialect, params, qualify, correlate)
159
+ pattern = emit_expression_or_literal(
160
+ node.pattern, dialect, params, qualify, correlate
161
+ )
162
+ # An ESCAPE clause only if the value's wildcards were escaped (startswith/...): without it
163
+ # SQLite treats the `\` as a character and silently filters wrong. A raw `.like()` respects
164
+ # the default.
165
+ #
166
+ # The character is the backslash on all three engines — that is decided in `_escape_like`,
167
+ # which is engine-agnostic — but how to WRITE it inside a literal is the dialect's job, so
168
+ # it goes through `literal()`. Hardcoded as `'\'` this clause was ERROR 1064 on MySQL, where
169
+ # the backslash escapes the quote and the literal never closes: `startswith`, `contains` and
170
+ # `endswith` did not work on that engine at all.
171
+ escape = f" ESCAPE {dialect.literal(chr(92))}" if node.escaped else ""
172
+ if node.case_insensitive and not dialect.supports_ilike:
173
+ # Portable fallback: SQLite has no ILIKE, so it folds both sides to
174
+ # lowercase.
175
+ return f"LOWER({left}) LIKE LOWER({pattern}){escape}"
176
+ operator = "ILIKE" if node.case_insensitive else "LIKE"
177
+ return f"{left} {operator} {pattern}{escape}"
178
+ if isinstance(node, SnakeNot):
179
+ return f"NOT ({emit_condition_into(node.operand, dialect, params, qualify, correlate)})"
180
+ if isinstance(node, SnakeExists):
181
+ return _emit_exists(node, dialect, params, correlate)
182
+ if isinstance(node, SnakeInSubquery):
183
+ left = _value_in_condition(node.left, dialect, params, qualify, correlate)
184
+ return f"{left} IN ({_emit_in_subquery(node.subquery, dialect, params)})"
185
+ raise SnakeNodeError(f"Condition node cannot be emitted: {type(node).__name__}")
186
+
187
+
188
+ def _emit_in_subquery(
189
+ sub: SnakeSubquery[Any], dialect: SnakeDialect, params: list[object]
190
+ ) -> str:
191
+ """Emits `SELECT <column> FROM <table> [WHERE ...]` of a scalar subquery.
192
+
193
+ Its params accumulate in the shared list (continuous numbering, positional).
194
+ Single-table (no alias); it gets its own correlation in case its WHERE nests
195
+ an EXISTS.
196
+ """
197
+ quote = dialect.quote_ident
198
+ table_ref = qualified(sub.schema, sub.name, dialect)
199
+ sql = f"SELECT {quote(sub.column)} FROM {table_ref}"
200
+ if sub.where is not None:
201
+ correlate = Correlation(parent_ref=quote(sub.name), aliases=ExistsAliases())
202
+ where_sql = emit_condition_into(sub.where, dialect, params, None, correlate)
203
+ sql = f"{sql} WHERE {where_sql}"
204
+ return sql
205
+
206
+
207
+ def _guard_placeholder_ceiling(node: SnakeTupleIn, dialect: SnakeDialect) -> None:
208
+ """Refuses a row constructor that would bind more placeholders than the engine accepts.
209
+
210
+ The number is the engine's own declared `bind_params`, which `add_all` and the prefetch already
211
+ slice by; this is the one path where the caller supplies the rows by hand.
212
+
213
+ MEASURED, at two widths, because two laws were candidates. On SQLite 3.53, 16.383 keys of two
214
+ columns and 8.191 of four both stop at the SAME placeholder count: what governs it is the
215
+ placeholders. Without this the engine answers `too many SQL variables`, which names neither the
216
+ query nor the way out.
217
+
218
+ WHAT IS DELIBERATELY NOT GUARDED is PostgreSQL's other ceiling. Measured on 17: it refuses at
219
+ 8.184 keys with `stack depth limit exceeded` at BOTH widths — so there the law is the number of
220
+ keys and not the placeholders, and it is the PARSER's recursion, well under the 65.535 of the
221
+ protocol. That number moves with `max_stack_depth`, which is a server setting, so the ORM cannot
222
+ know it: refusing at a number taken from one server's configuration would forbid on a tuned
223
+ server what the database there allows. The limit is written down in the reference instead of
224
+ invented here.
225
+ """
226
+ placeholders = len(node.rows) * len(node.columns)
227
+ if placeholders <= dialect.max_bind_params:
228
+ return
229
+ raise SnakeEmitError(
230
+ f"This composite IN binds {placeholders} placeholders — {len(node.rows)} keys of "
231
+ f"{len(node.columns)} columns — and {type(dialect).__name__} accepts "
232
+ f"{dialect.max_bind_params} per statement. Slice the list of keys and combine the results; "
233
+ f"the ORM will not split it for you, because the halves are separate queries and only the "
234
+ f"caller knows whether that is acceptable here."
235
+ )
236
+
237
+
238
+ def _emit_tuple_in(
239
+ node: SnakeTupleIn,
240
+ dialect: SnakeDialect,
241
+ params: list[object],
242
+ qualify: Qualify | None,
243
+ correlate: Correlation | None,
244
+ ) -> str:
245
+ """Emits `(c1, c2) IN ((%s, %s), ...)`, or its OR-of-ANDs equivalent without `supports_row_constructor`.
246
+
247
+ No rows is unemittable (there is no `IN ()`). Columns with `emit_value` (no params),
248
+ values with `emit_operand` (parametrised, continuous numbering).
249
+ """
250
+ if not node.rows:
251
+ raise SnakeEmitError("A tuple IN needs at least one row")
252
+ _guard_placeholder_ceiling(node, dialect)
253
+ if dialect.supports_row_constructor:
254
+ columns = ", ".join(
255
+ emit_value(column, dialect, params, qualify, correlate)
256
+ for column in node.columns
257
+ )
258
+ rows = ", ".join(
259
+ "(" + ", ".join(emit_operand(value, dialect, params) for value in row) + ")"
260
+ for row in node.rows
261
+ )
262
+ return f"({columns}) IN ({rows})"
263
+ clauses: list[str] = []
264
+ for row in node.rows:
265
+ conjunction = " AND ".join(
266
+ f"{emit_value(column, dialect, params, qualify, correlate)} "
267
+ f"= {emit_operand(value, dialect, params)}"
268
+ for column, value in zip(node.columns, row, strict=True)
269
+ )
270
+ clauses.append(f"({conjunction})")
271
+ return "(" + " OR ".join(clauses) + ")"
272
+
273
+
274
+ def _emit_exists(
275
+ node: SnakeExists,
276
+ dialect: SnakeDialect,
277
+ params: list[object],
278
+ correlate: Correlation | None,
279
+ ) -> str:
280
+ """Emits `EXISTS (SELECT 1 FROM child AS alias [JOINs] WHERE child.fk = parent.pk [AND cond])`.
281
+
282
+ The alias (e0, e1...) comes from its own counter shared with the nested ones (it does not
283
+ collide with the outer t0...). The child's condition is re-anchored to the subquery's
284
+ alias; its to-one navigation JOINs (`node.joins`) are emitted inside, with aliases from the
285
+ same counter. A composite FK ANDs every pair. Everything parametrised.
286
+ """
287
+ if correlate is None:
288
+ raise SnakeNodeError(
289
+ "SnakeExists without a correlation context: the parent cannot be referenced"
290
+ )
291
+ quote = dialect.quote_ident
292
+ alias = correlate.aliases.allocate()
293
+ # `qualified()`, not a hand-written `f"{schema}.{name}"`: an engine without schemas
294
+ # (SQLite) does not accept `"public".` and gave `no such table: public.x`, breaking
295
+ # `.any()`, `.count()` and the aggregates.
296
+ child_ref = qualified(node.child_schema, node.child_name, dialect)
297
+ on = " AND ".join(
298
+ f"{alias}.{quote(child_col)} = {correlate.parent_ref}.{quote(parent_col)}"
299
+ for child_col, parent_col in node.pairs
300
+ )
301
+ alias_map, joins_sql = _build_exists_joins(
302
+ node.joins, alias, correlate.aliases, dialect
303
+ )
304
+ from_clause = f"{child_ref} AS {alias}{joins_sql}"
305
+ if node.condition is None:
306
+ return f"EXISTS (SELECT 1 FROM {from_clause} WHERE {on})"
307
+ inner = Correlation(parent_ref=alias, aliases=correlate.aliases)
308
+ cond_sql = emit_condition_into(
309
+ node.condition, dialect, params, _exists_qualify(alias_map), inner
310
+ )
311
+ return f"EXISTS (SELECT 1 FROM {from_clause} WHERE {on} AND {cond_sql})"
312
+
313
+
314
+ def _build_exists_joins(
315
+ joins: tuple[SnakeExistsJoin, ...],
316
+ child_alias: str,
317
+ aliases: ExistsAliases,
318
+ dialect: SnakeDialect,
319
+ ) -> tuple[dict[tuple[str, ...], str], str]:
320
+ """Assigns an alias to each navigated JOIN of the EXISTS and emits its clauses; returns the prefix→alias map.
321
+
322
+ JOINs ordered from parent to child (the parent is already in `alias_map`; the base child
323
+ takes the empty prefix). Aliases from the same shared `ExistsAliases`, so they never
324
+ collide. to-one ON: `parent.local = alias.remote`; a composite FK ANDs the pairs.
325
+ """
326
+ quote = dialect.quote_ident
327
+ alias_map: dict[tuple[str, ...], str] = {(): child_alias}
328
+ clauses: list[str] = []
329
+ for join in joins:
330
+ alias = aliases.allocate()
331
+ alias_map[join.prefix] = alias
332
+ parent_alias = alias_map[join.prefix[:-1]]
333
+ on = " AND ".join(
334
+ f"{parent_alias}.{quote(local)} = {alias}.{quote(remote)}"
335
+ for local, remote in join.pairs
336
+ )
337
+ table_ref = qualified(join.schema, join.name, dialect)
338
+ clauses.append(f" JOIN {table_ref} AS {alias} ON {on}")
339
+ return alias_map, "".join(clauses)
340
+
341
+
342
+ def _exists_qualify(alias_map: dict[tuple[str, ...], str]) -> Qualify:
343
+ """Qualifies a child column (or navigated target) with the alias of its relation prefix.
344
+
345
+ `Maker.name` → `()` → the child's alias; `Maker.nation.name` → `("nation",)` → the JOIN's
346
+ alias. A missing prefix would be a JOIN that was never built (it should not happen after
347
+ validating `.any()`).
348
+ """
349
+
350
+ def qualify(prefix: tuple[str, ...]) -> str:
351
+ alias = alias_map.get(prefix)
352
+ if alias is None:
353
+ raise SnakeUnsupportedFeature(
354
+ "navigation inside an EXISTS without a JOIN built for prefix "
355
+ f"'{'.'.join(prefix)}'; it should not happen after the .any() validation."
356
+ )
357
+ return alias
358
+
359
+ return qualify
snakeorm/sql/delete.py ADDED
@@ -0,0 +1,46 @@
1
+ """Emission of a parametrised DELETE: `DELETE FROM <schema.table> [WHERE <cond>]`.
2
+
3
+ `where=None` deletes the whole table (valid SQL): the guard-rail lives in query/session, not here.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from snakeorm.dialects import SnakeDialect
9
+ from snakeorm.expressions import SnakeCondition
10
+ from snakeorm.metadata import SnakeTableInfo
11
+ from snakeorm.sql.condition import emit_condition
12
+ from snakeorm.sql.joins import JoinPlan
13
+ from snakeorm.sql.pk_subquery import emit_pk_in, emit_pk_subquery
14
+ from snakeorm.sql.refs import qualified
15
+
16
+
17
+ def emit_delete(
18
+ table: SnakeTableInfo,
19
+ dialect: SnakeDialect,
20
+ where: SnakeCondition | None = None,
21
+ ) -> tuple[str, tuple[object, ...]]:
22
+ """Emits `DELETE FROM ... [WHERE ...]` with the params kept apart."""
23
+ table_ref = qualified(table.schema, table.name, dialect)
24
+ sql = f"DELETE FROM {table_ref}"
25
+ if where is None:
26
+ return sql, ()
27
+ where_sql, params = emit_condition(where, dialect)
28
+ return f"{sql} WHERE {where_sql}", params
29
+
30
+
31
+ def emit_delete_pk_in_subquery(
32
+ table: SnakeTableInfo,
33
+ dialect: SnakeDialect,
34
+ plan: JoinPlan,
35
+ where: SnakeCondition,
36
+ ) -> tuple[str, tuple[object, ...]]:
37
+ """Emits `DELETE FROM ... WHERE <pk> IN (SELECT <pk> FROM ... [JOINs] WHERE <deep cond>)`.
38
+
39
+ For a WHERE that navigates a relationship: it is rewritten into a subquery over the PK (avoids
40
+ `DELETE ... FROM`, Postgres jargon). A composite PK uses a row constructor. Parametrised.
41
+ """
42
+ table_ref = qualified(table.schema, table.name, dialect)
43
+ params: list[object] = []
44
+ subquery = emit_pk_subquery(table, dialect, plan, where, params)
45
+ pk_in = emit_pk_in(table, dialect, subquery)
46
+ return f"DELETE FROM {table_ref} WHERE {pk_in}", tuple(params)