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,293 @@
1
+ """Scalar text and date functions (`LOWER`, `TRIM`, `DATE_TRUNC`, `EXTRACT`...).
2
+
3
+ A single node (`SnakeFunc`) with an agnostic enum instead of one class per function: a new node
4
+ would be one more place to forget something. The dialect translates the names. The TYPE is pinned
5
+ down by the constructors (`snake_length(User.name)` is `SnakeValue[int]`), typing the projection
6
+ with no `Any`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from enum import Enum
14
+ from typing import Any, TypeVar
15
+
16
+ from snakeorm.core.exceptions import SnakeUnsupportedFeature
17
+ from snakeorm.expressions.expression import CASTABLE, SnakeCast, SnakeValue
18
+
19
+ T = TypeVar("T")
20
+ V = TypeVar("V")
21
+
22
+
23
+ class SnakeFunc(Enum):
24
+ """Supported scalar functions, with agnostic names that the dialect translates."""
25
+
26
+ LOWER = "lower"
27
+ UPPER = "upper"
28
+ TRIM = "trim"
29
+ LENGTH = "length"
30
+ CONCAT = "concat"
31
+ DATE_TRUNC = "date_trunc"
32
+ EXTRACT = "extract"
33
+ ABS = "abs"
34
+ ROUND = "round"
35
+ SUBSTRING = "substring"
36
+ REPLACE = "replace"
37
+ CEIL = "ceil"
38
+ FLOOR = "floor"
39
+ SQRT = "sqrt"
40
+ POWER = "power"
41
+
42
+
43
+ class SnakeDatePart(Enum):
44
+ """A part of a date, for `DATE_TRUNC` and `EXTRACT`. Standard SQL names."""
45
+
46
+ YEAR = "year"
47
+ QUARTER = "quarter"
48
+ MONTH = "month"
49
+ WEEK = "week"
50
+ DAY = "day"
51
+ HOUR = "hour"
52
+ MINUTE = "minute"
53
+ SECOND = "second"
54
+
55
+
56
+ @dataclass(frozen=True, slots=True, eq=False)
57
+ class SnakeFuncCall(SnakeValue[T]):
58
+ """A call to a scalar function: `FUNC(arg, ...)`.
59
+
60
+ It is a VALUE (it gets compared, projected, aggregated). `part` is only used by the date
61
+ functions and travels separately (in SQL, `EXTRACT(year FROM col)` is not a normal argument).
62
+ """
63
+
64
+ func: SnakeFunc
65
+ arguments: tuple[SnakeValue[object] | object, ...]
66
+ part: SnakeDatePart | None = None
67
+
68
+ def paths(self) -> tuple[tuple[str, ...], ...]:
69
+ """Paths of the arguments that are columns, so the planner sees their JOINs."""
70
+ return tuple(
71
+ path
72
+ for argument in self.arguments
73
+ if isinstance(argument, SnakeValue)
74
+ for path in argument.paths()
75
+ )
76
+
77
+
78
+ def snake_lower(value: SnakeValue[str]) -> SnakeFuncCall[str]:
79
+ """`LOWER(value)`."""
80
+ return SnakeFuncCall(SnakeFunc.LOWER, (value,))
81
+
82
+
83
+ def snake_upper(value: SnakeValue[str]) -> SnakeFuncCall[str]:
84
+ """`UPPER(value)`."""
85
+ return SnakeFuncCall(SnakeFunc.UPPER, (value,))
86
+
87
+
88
+ def snake_trim(value: SnakeValue[str]) -> SnakeFuncCall[str]:
89
+ """`TRIM(value)`: strips the whitespace off both ends."""
90
+ return SnakeFuncCall(SnakeFunc.TRIM, (value,))
91
+
92
+
93
+ def snake_length(value: SnakeValue[str]) -> SnakeFuncCall[int]:
94
+ """`LENGTH(value)`: returns an INTEGER, not text. That is the whole point of the typing."""
95
+ return SnakeFuncCall(SnakeFunc.LENGTH, (value,))
96
+
97
+
98
+ def snake_concat(*values: SnakeValue[str] | str) -> SnakeFuncCall[str]:
99
+ """`CONCAT(a, b, ...)`. Unlike `||`, it ignores NULLs instead of propagating them."""
100
+ return SnakeFuncCall(SnakeFunc.CONCAT, values)
101
+
102
+
103
+ D = TypeVar("D", bound=datetime)
104
+ """The datetime a date function was handed, kept instead of flattened.
105
+
106
+ `SnakeValue[T]` is INVARIANT, so a parameter annotated `SnakeValue[datetime]` rejects a
107
+ `SnakeValue[SnakeUtc]` even though `SnakeUtc` is a `datetime` subclass and the call runs fine. That
108
+ left the ORM's own recommended timestamp type unable to use the date functions, with no cast to
109
+ reach for: `CASTABLE` is `int`, `float`, `bool`.
110
+
111
+ Bounded rather than free, because `EXTRACT` over a `str` is a different mistake and one worth
112
+ keeping.
113
+ """
114
+
115
+
116
+ def snake_date_trunc(part: SnakeDatePart, value: SnakeValue[D]) -> SnakeFuncCall[D]:
117
+ """`DATE_TRUNC('part', value)`: trims the date to the given precision (grouping by month).
118
+
119
+ It gives back the type it was handed. Trimming a timestamp to the month does not change what
120
+ KIND of thing it is, so a `SnakeUtc` column keeps coming out a `SnakeUtc` — the alternative
121
+ would hand back a plain `datetime` and lose the guarantee the column was declared for.
122
+ """
123
+ return SnakeFuncCall(SnakeFunc.DATE_TRUNC, (value,), part=part)
124
+
125
+
126
+ def snake_extract(part: SnakeDatePart, value: SnakeValue[D]) -> SnakeFuncCall[int]:
127
+ """`EXTRACT(part FROM value)`: pulls out one component as a NUMBER (the year, the month...)."""
128
+ return SnakeFuncCall(SnakeFunc.EXTRACT, (value,), part=part)
129
+
130
+
131
+ def snake_abs(value: SnakeValue[T]) -> SnakeFuncCall[T]:
132
+ """`ABS(value)`: keeps the numeric type of its argument."""
133
+ return SnakeFuncCall(SnakeFunc.ABS, (value,))
134
+
135
+
136
+ def snake_round(value: SnakeValue[T], digits: int = 0) -> SnakeFuncCall[T]:
137
+ """`ROUND(value)`, or `ROUND(value, digits)` when digits are asked for.
138
+
139
+ The zero is NOT passed through, and that is not tidiness. Postgres has `ROUND(double precision)`
140
+ and `ROUND(numeric, int)` but no `ROUND(double precision, int)`, so emitting the second argument
141
+ unasked made `snake_round()` on a float column fail there while working on the other two — for
142
+ the default call, the one nobody would think to test on three engines. `ROUND(x)` means the same
143
+ thing on all three.
144
+
145
+ Asking for digits DOES work on all three now. Postgres has no `ROUND(double precision, int)`,
146
+ so it declares the type its two-argument form wants —`syntax.round_casts_first_argument_to`— and
147
+ the emitter casts. A SHAPE difference, translated, never stopping the plan.
148
+ """
149
+ if digits:
150
+ return SnakeFuncCall(SnakeFunc.ROUND, (value, digits))
151
+ return SnakeFuncCall(SnakeFunc.ROUND, (value,))
152
+
153
+
154
+ def snake_cast(value: SnakeValue[Any], as_type: type[V]) -> SnakeCast[V]:
155
+ """`CAST(value AS <type>)`: an EXPLICIT change of type, named by whoever writes it.
156
+
157
+ THE ORM DOES NOT PROMOTE, and this is the door that makes that stance liveable. The arithmetic
158
+ operators carry one single `T` for both operands and the result, so `Stock.reserved /
159
+ Stock.on_hand` is an `int` — which is what SQL does, and sometimes what you want. When it is not,
160
+ you say so:
161
+
162
+ snake_cast(Stock.reserved, float) / Stock.on_hand -> SnakeArith[float]
163
+
164
+ `as_type` is what the RESULT is typed as, so the conversion travels through the type system
165
+ instead of around it. The SQL name of that type is the dialect's business: measured, SQLite needs
166
+ `REAL` where `NUMERIC` would answer 0.
167
+
168
+ The whitelist refuses by name at the CALL SITE rather than at emission, which is where a refusal
169
+ is worth something: the alternative is SQL the engine rejects and a driver explaining a decision
170
+ this ORM made.
171
+ """
172
+ if as_type not in CASTABLE:
173
+ raise SnakeUnsupportedFeature(
174
+ f"There is no cast to {getattr(as_type, '__name__', as_type)!r}: an explicit cast "
175
+ f"targets one of {', '.join(t.__name__ for t in CASTABLE)}. This is a whitelist and not "
176
+ f"an oversight — a type the dialects cannot spell would be emitted as SQL the engine "
177
+ f"rejects, and the complaint would arrive from the driver rather than from here."
178
+ )
179
+ return SnakeCast(source=value, as_type=as_type)
180
+
181
+
182
+ # What a date can be shifted by. A WHITELIST built by removing from `SnakeDatePart`, not a second
183
+ # enum: a quarter is a real thing to TRUNCATE to and not a thing any of the three engines spells as
184
+ # an interval, so it is refused by name rather than silently translated into three months.
185
+ SHIFTABLE: tuple[SnakeDatePart, ...] = (
186
+ SnakeDatePart.YEAR,
187
+ SnakeDatePart.MONTH,
188
+ SnakeDatePart.WEEK,
189
+ SnakeDatePart.DAY,
190
+ SnakeDatePart.HOUR,
191
+ SnakeDatePart.MINUTE,
192
+ SnakeDatePart.SECOND,
193
+ )
194
+
195
+
196
+ @dataclass(frozen=True, slots=True, eq=False)
197
+ class SnakeDateShift(SnakeValue[T]):
198
+ """Moving a date or a timestamp by a fixed amount: `placed_on + 30 days`.
199
+
200
+ ONE node for both directions: subtracting is adding a negative `amount`, which the three engines
201
+ accept and which keeps the sign in the VALUE instead of in a second node. `T` is the type of the
202
+ SOURCE — shifting a date gives a date.
203
+
204
+ The three spellings share nothing (`+ INTERVAL`, `DATE_ADD`, a modifier string), so the SQL is
205
+ the dialect's business. It is the clearest case in the whole ORM for that seam.
206
+ """
207
+
208
+ value: SnakeValue[Any]
209
+ amount: int
210
+ unit: SnakeDatePart
211
+
212
+ def paths(self) -> tuple[tuple[str, ...], ...]:
213
+ """What it shifts: the amount is a literal and contributes no navigation of its own."""
214
+ return self.value.paths()
215
+
216
+
217
+ def _shift(value: SnakeValue[T], amount: int, unit: SnakeDatePart) -> SnakeDateShift[T]:
218
+ """Shared guard for both directions: the unit has to be one a date can actually move by."""
219
+ if unit not in SHIFTABLE:
220
+ raise SnakeUnsupportedFeature(
221
+ f"A date cannot be shifted by {unit.name}: no engine spells it as an interval. "
222
+ f"`SnakeDatePart` is shared with DATE_TRUNC and EXTRACT, where {unit.name} is real. "
223
+ f"Shift by one of {', '.join(part.name for part in SHIFTABLE)} — a quarter is three "
224
+ f"MONTHs, and writing that out is the caller's decision rather than a silent rewrite."
225
+ )
226
+ return SnakeDateShift(value=value, amount=amount, unit=unit)
227
+
228
+
229
+ def snake_date_add(
230
+ value: SnakeValue[T], amount: int, unit: SnakeDatePart
231
+ ) -> SnakeDateShift[T]:
232
+ """Moves a date FORWARD: `snake_date_add(Order.placed_on, 30, SnakeDatePart.DAY)`.
233
+
234
+ CALENDAR UNITS ARE NOT PORTABLE and the ORM says so rather than hiding it. Measured, `2026-01-31`
235
+ plus one month is `2026-02-28` on PostgreSQL and MySQL —both clamp— and `2026-03-03` on SQLite,
236
+ which overflows. That divergence is declared as `Cap.CALENDAR_INTERVAL`, so the session warns
237
+ once instead of letting whichever engine the developer happens to run be the one that gets
238
+ tested. DAY, HOUR, MINUTE, SECOND and WEEK are a fixed span and identical on all three.
239
+
240
+ Emulating the clamp was the alternative, and it is the wrong one: the ORM would be computing
241
+ dates in Python behind an expression that claims to be SQL.
242
+ """
243
+ return _shift(value, amount, unit)
244
+
245
+
246
+ def snake_date_sub(
247
+ value: SnakeValue[T], amount: int, unit: SnakeDatePart
248
+ ) -> SnakeDateShift[T]:
249
+ """Moves a date BACKWARD. Same node with the sign flipped, because that is all it is."""
250
+ return _shift(value, -amount, unit)
251
+
252
+
253
+ def snake_substring(
254
+ value: SnakeValue[str], start: int, length: int
255
+ ) -> SnakeFuncCall[str]:
256
+ """`SUBSTRING(value, start, length)`: a slice of text, counted from ONE like SQL does.
257
+
258
+ The bounds are VALUES and travel as parameters, which is not obvious enough to leave unsaid: a
259
+ slice computed from user input with the numbers written into the statement is the shape an
260
+ injection takes when nobody is looking at strings.
261
+ """
262
+ return SnakeFuncCall(SnakeFunc.SUBSTRING, (value, start, length))
263
+
264
+
265
+ def snake_replace(value: SnakeValue[str], old: str, new: str) -> SnakeFuncCall[str]:
266
+ """`REPLACE(value, old, new)`: every occurrence, not the first. Both strings are parameters."""
267
+ return SnakeFuncCall(SnakeFunc.REPLACE, (value, old, new))
268
+
269
+
270
+ def snake_ceil(value: SnakeValue[T]) -> SnakeFuncCall[T]:
271
+ """`CEIL(value)`: rounds UP, keeping the type of its argument.
272
+
273
+ `T -> T` and not `-> int`, and that was measured rather than assumed: `CEIL(1.2)` answers `2` on
274
+ PostgreSQL and MySQL and `2.0` on SQLite. Declaring `int` would be false on one engine of three,
275
+ which is the exact family of bug the integer-division work removed. Keeping the argument's type
276
+ is true everywhere — a float in, a float back.
277
+ """
278
+ return SnakeFuncCall(SnakeFunc.CEIL, (value,))
279
+
280
+
281
+ def snake_floor(value: SnakeValue[T]) -> SnakeFuncCall[T]:
282
+ """`FLOOR(value)`: rounds DOWN, keeping the type of its argument. Same measurement as `CEIL`."""
283
+ return SnakeFuncCall(SnakeFunc.FLOOR, (value,))
284
+
285
+
286
+ def snake_sqrt(value: SnakeValue[Any]) -> SnakeFuncCall[float]:
287
+ """`SQRT(value)`: always a float. Measured `double precision` on PostgreSQL and a real on SQLite."""
288
+ return SnakeFuncCall(SnakeFunc.SQRT, (value,))
289
+
290
+
291
+ def snake_power(value: SnakeValue[Any], exponent: float) -> SnakeFuncCall[float]:
292
+ """`POWER(value, exponent)`: always a float, and the exponent travels as a parameter."""
293
+ return SnakeFuncCall(SnakeFunc.POWER, (value, exponent))
@@ -0,0 +1,248 @@
1
+ """Window functions: `<func> OVER (PARTITION BY ... ORDER BY ...)`.
2
+
3
+ They compute one value per row by looking at NEIGHBOURING rows without collapsing them (unlike a
4
+ GROUP BY). Without windows, that gets solved with a correlated subquery per row (the N+1 of SQL).
5
+ `SnakeWindow` is a plain `SnakeValue` (it gets projected and dispatched by type). It is not allowed
6
+ in WHERE/GROUP BY/HAVING (SQL evaluates them afterwards): the condition emitter watches for that.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, replace
12
+ from enum import Enum
13
+ from typing import Any, TypeVar
14
+
15
+ from snakeorm.core.exceptions import SnakeUnsupportedFeature, SnakeValueError
16
+
17
+ from snakeorm.expressions.expression import (
18
+ SnakeAggregate,
19
+ SnakeArith,
20
+ SnakeOrder,
21
+ SnakeValue,
22
+ )
23
+
24
+ T = TypeVar("T")
25
+
26
+
27
+ # `eq=False` is NOT optional: `SnakeValue.__eq__` returns `SnakeCondition`, and a dataclass `__eq__`
28
+ # would return `bool`, breaking `window == value`. Same reason in `SnakeAggregate` and `SnakeOrder`.
29
+ @dataclass(frozen=True, slots=True, eq=False)
30
+ class SnakeWindow(SnakeValue[T]):
31
+ """`<func>(<arg>...) OVER (PARTITION BY ... ORDER BY ...)`.
32
+
33
+ `func` is the SQL NAME, kept as a string because two families live side by side (ranking, and
34
+ aggregates used as windows, which already have their own enum): a union enum would lie.
35
+ `extra_args` are literals of the function (the offset of `LAG`/`LEAD`), parametrised like every
36
+ other value.
37
+ """
38
+
39
+ func: str
40
+ arg: SnakeValue[Any] | None = None
41
+ extra_args: tuple[object, ...] = ()
42
+ partition_by: tuple[SnakeValue[Any], ...] = ()
43
+ order_by: tuple[SnakeOrder, ...] = ()
44
+ frame: SnakeFrame | None = None
45
+
46
+ def over(
47
+ self,
48
+ *,
49
+ partition_by: tuple[SnakeValue[Any], ...] | list[SnakeValue[Any]] = (),
50
+ order_by: tuple[SnakeOrder, ...] | list[SnakeOrder] = (),
51
+ frame: SnakeFrame | None = None,
52
+ ) -> SnakeWindow[T]:
53
+ """The same function with its window defined. Without `over(...)`, the window is ALL the rows.
54
+
55
+ `frame` is what turns a running total into a MOVING one. Without it the default frame runs
56
+ from the start of the partition to the current row, which is one useful answer out of many:
57
+ a trailing average, a centred window or a look-ahead all need the frame said out loud.
58
+
59
+ A FRAME WITHOUT AN ORDER IS REFUSED. `6 PRECEDING` has to be preceding IN something, and with
60
+ no `ORDER BY` the engine picks an order of its own — so the same query answers differently on
61
+ two runs with nothing to show for it. That is the shape of failure this ORM does not ship.
62
+
63
+ It returns a NEW node (immutable AST): reusing a stored window holds no surprises.
64
+ """
65
+ if frame is not None and not order_by:
66
+ raise SnakeUnsupportedFeature(
67
+ "A window frame needs an order to be measured against: pass order_by= alongside "
68
+ "frame=. Without it, '6 PRECEDING' is six rows before whichever row the engine "
69
+ "happened to put first, which can change between two runs of the same query."
70
+ )
71
+ return replace(
72
+ self,
73
+ partition_by=tuple(partition_by),
74
+ order_by=tuple(order_by),
75
+ frame=frame,
76
+ )
77
+
78
+ def paths(self) -> tuple[tuple[str, ...], ...]:
79
+ """Paths of everything the window touches (argument, partition, order): all three plan JOINs."""
80
+ paths: list[tuple[str, ...]] = []
81
+ if self.arg is not None:
82
+ paths.extend(self.arg.paths())
83
+ for value in self.partition_by:
84
+ paths.extend(value.paths())
85
+ for key in self.order_by:
86
+ paths.extend(key.expr.paths())
87
+ return tuple(paths)
88
+
89
+
90
+ def _rank_window(name: str) -> SnakeWindow[int]:
91
+ """Ranking function with no argument: it returns one integer per row."""
92
+ return SnakeWindow[int](func=name)
93
+
94
+
95
+ def row_number() -> SnakeWindow[int]:
96
+ """`ROW_NUMBER()`: position within the partition, with no ties (1, 2, 3, 4...)."""
97
+ return _rank_window("ROW_NUMBER")
98
+
99
+
100
+ def rank() -> SnakeWindow[int]:
101
+ """`RANK()`: position with ties, SKIPPING the gaps (1, 2, 2, 4...)."""
102
+ return _rank_window("RANK")
103
+
104
+
105
+ def dense_rank() -> SnakeWindow[int]:
106
+ """`DENSE_RANK()`: position with ties and NO gaps (1, 2, 2, 3...)."""
107
+ return _rank_window("DENSE_RANK")
108
+
109
+
110
+ def lag(value: SnakeValue[T], offset: int = 1) -> SnakeWindow[T | None]:
111
+ """`LAG(value, n)`: the value of the row n positions BEFORE in the window.
112
+
113
+ Optional: on the first n rows there is no previous one and the result is NULL.
114
+ """
115
+ return SnakeWindow[T | None](func="LAG", arg=value, extra_args=(offset,))
116
+
117
+
118
+ def lead(value: SnakeValue[T], offset: int = 1) -> SnakeWindow[T | None]:
119
+ """`LEAD(value, n)`: the value of the row n positions AFTER. Optional for the same reason."""
120
+ return SnakeWindow[T | None](func="LEAD", arg=value, extra_args=(offset,))
121
+
122
+
123
+ def as_window(aggregate: SnakeAggregate[T]) -> SnakeWindow[T]:
124
+ """Turns an aggregate into a window: `SUM(x)` -> `SUM(x) OVER (...)`.
125
+
126
+ With GROUP BY it returns one row per group; as a window, every row with its running total. Used by `SnakeAggregate.over(...)`.
127
+ """
128
+ return SnakeWindow[T](func=aggregate.func.value, arg=aggregate.arg)
129
+
130
+
131
+ def has_window(value: object) -> bool:
132
+ """Does this value contain a window function, however nested?
133
+
134
+ It walks the nodes that WRAP another value (arithmetic, aggregate); the rest are leaves. It exists
135
+ to REJECT the window where SQL forbids it: `WHERE row_number() + 1 > 3` is just as impossible as it is without the `+ 1`.
136
+ """
137
+ if isinstance(value, SnakeWindow):
138
+ return True
139
+ if isinstance(value, SnakeArith):
140
+ return has_window(value.left) or has_window(value.right)
141
+ if isinstance(value, SnakeAggregate):
142
+ return value.arg is not None and has_window(value.arg)
143
+ return False
144
+
145
+
146
+ class SnakeFrameMode(Enum):
147
+ """`ROWS` counts ROWS, `RANGE` counts VALUES. With ties they answer differently.
148
+
149
+ Offering only `ROWS` would be the smaller API and the wrong one: somebody ordering by a day that
150
+ has several readings in it means RANGE, and handing them ROWS is a wrong answer with no error.
151
+ """
152
+
153
+ ROWS = "ROWS"
154
+ RANGE = "RANGE"
155
+
156
+
157
+ @dataclass(frozen=True, slots=True)
158
+ class SnakeFrameBound:
159
+ """One end of a frame. `offset=None` is UNBOUNDED; `offset=0` is the current row.
160
+
161
+ The offset reaches the STATEMENT rather than `params`, and that is measured rather than lazy:
162
+ PostgreSQL and SQLite take a placeholder in a bound and MariaDB rejects it outright, so the only
163
+ portable spelling is the literal. It is safe for the same reason the JSON key path is — the value
164
+ is an `int` from Python's own type system and it is checked when the bound is BUILT, before any
165
+ SQL exists. An integer carries no injection.
166
+ """
167
+
168
+ offset: int | None
169
+ following: bool
170
+
171
+ def sql(self) -> str:
172
+ """The bound as standard SQL. The three engines spell this identically (measured)."""
173
+ direction = "FOLLOWING" if self.following else "PRECEDING"
174
+ if self.offset is None:
175
+ return f"UNBOUNDED {direction}"
176
+ if self.offset == 0:
177
+ return "CURRENT ROW"
178
+ return f"{self.offset} {direction}"
179
+
180
+ def rank(self) -> tuple[int, int]:
181
+ """Where this bound sits on the timeline, so a frame can check it does not run backwards."""
182
+ if self.offset is None:
183
+ return (4, 0) if self.following else (0, 0)
184
+ if self.offset == 0:
185
+ return (2, 0)
186
+ return (3, self.offset) if self.following else (1, -self.offset)
187
+
188
+
189
+ @dataclass(frozen=True, slots=True)
190
+ class SnakeFrame:
191
+ """`<mode> BETWEEN <start> AND <end>`: which neighbouring rows the function may look at."""
192
+
193
+ mode: SnakeFrameMode
194
+ start: SnakeFrameBound
195
+ end: SnakeFrameBound
196
+
197
+ def sql(self) -> str:
198
+ """The whole clause. No dialect involved: it is the same on the three engines."""
199
+ return f"{self.mode.value} BETWEEN {self.start.sql()} AND {self.end.sql()}"
200
+
201
+
202
+ # The current row, which is the commonest end of a trailing window. A constant rather than a
203
+ # function because it takes no argument and there is exactly one of it.
204
+ SNAKE_CURRENT_ROW = SnakeFrameBound(offset=0, following=False)
205
+
206
+
207
+ def _bound(rows: int | None, following: bool) -> SnakeFrameBound:
208
+ """Shared guard. Refusing here is what lets the number be interpolated later."""
209
+ if rows is not None and rows < 0:
210
+ raise SnakeValueError(
211
+ f"A frame bound cannot be negative (got {rows}): the DIRECTION is the function you "
212
+ f"call, so the distance is always counted forwards from here. For {abs(rows)} rows the "
213
+ f"other way, call the other one."
214
+ )
215
+ return SnakeFrameBound(offset=rows, following=following)
216
+
217
+
218
+ def snake_preceding(rows: int | None = None) -> SnakeFrameBound:
219
+ """`n PRECEDING`, or `UNBOUNDED PRECEDING` with no argument."""
220
+ return _bound(rows, following=False)
221
+
222
+
223
+ def snake_following(rows: int | None = None) -> SnakeFrameBound:
224
+ """`n FOLLOWING`, or `UNBOUNDED FOLLOWING` with no argument."""
225
+ return _bound(rows, following=True)
226
+
227
+
228
+ def _frame(
229
+ mode: SnakeFrameMode, start: SnakeFrameBound, end: SnakeFrameBound
230
+ ) -> SnakeFrame:
231
+ """Shared guard: a frame that ends before it starts is empty by construction."""
232
+ if start.rank() > end.rank():
233
+ raise SnakeValueError(
234
+ f"This frame starts after it ends ({start.sql()} .. {end.sql()}), so it can never "
235
+ f"contain a row. The engines reject it too — it is caught here so the complaint names "
236
+ f"the frame instead of arriving from the driver."
237
+ )
238
+ return SnakeFrame(mode=mode, start=start, end=end)
239
+
240
+
241
+ def snake_rows(start: SnakeFrameBound, end: SnakeFrameBound) -> SnakeFrame:
242
+ """`ROWS BETWEEN start AND end`: counts ROWS, so ties are separate rows."""
243
+ return _frame(SnakeFrameMode.ROWS, start, end)
244
+
245
+
246
+ def snake_range(start: SnakeFrameBound, end: SnakeFrameBound) -> SnakeFrame:
247
+ """`RANGE BETWEEN start AND end`: counts VALUES, so tied rows come in together."""
248
+ return _frame(SnakeFrameMode.RANGE, start, end)
@@ -0,0 +1,68 @@
1
+ """SnakeORM field descriptors and their field specifiers."""
2
+
3
+ from snakeorm.fields.check import snake_check as snake_check
4
+ from snakeorm.fields.check import snake_checks as snake_checks
5
+ from snakeorm.fields.check import snake_indexes as snake_indexes
6
+ from snakeorm.fields.column import MISSING as MISSING
7
+ from snakeorm.fields.column import SnakeColumn as SnakeColumn
8
+ from snakeorm.fields.column import snake_auto as snake_auto
9
+ from snakeorm.fields.column import snake_column as snake_column
10
+ from snakeorm.fields.column import snake_discriminator as snake_discriminator
11
+ from snakeorm.fields.enum import snake_enum as snake_enum
12
+ from snakeorm.fields.index import SnakeIndex as SnakeIndex
13
+ from snakeorm.fields.relationship import SnakeCollection as SnakeCollection
14
+ from snakeorm.fields.relationship import SnakePrefetch as SnakePrefetch
15
+ from snakeorm.fields.relationship import SnakePrefetchHop as SnakePrefetchHop
16
+ from snakeorm.core.exceptions import (
17
+ SnakeRelationshipNotLoaded as SnakeRelationshipNotLoaded,
18
+ )
19
+ from snakeorm.fields.relationship import SnakePathProxy as SnakePathProxy
20
+ from snakeorm.fields.relationship import SnakeToMany as SnakeToMany
21
+ from snakeorm.fields.relationship import path_of as path_of
22
+ from snakeorm.fields.relationship import SnakeToOne as SnakeToOne
23
+ from snakeorm.fields.relationship import snake_to_many as snake_to_many
24
+ from snakeorm.fields.relationship import snake_to_many_through as snake_to_many_through
25
+ from snakeorm.fields.relationship import snake_to_one as snake_to_one
26
+ from snakeorm.fields.typed import snake_datetime as snake_datetime
27
+ from snakeorm.fields.typed import snake_datetimetz as snake_datetimetz
28
+ from snakeorm.fields.typed import snake_decimal as snake_decimal
29
+ from snakeorm.fields.typed import snake_int as snake_int
30
+ from snakeorm.fields.typed import snake_json as snake_json
31
+ from snakeorm.fields.typed import snake_str as snake_str
32
+ from snakeorm.fields.typed import snake_float as snake_float
33
+ from snakeorm.fields.typed import snake_time as snake_time
34
+ from snakeorm.fields.typed import snake_timetz as snake_timetz
35
+
36
+ # CANONICAL reference of the field specifiers. It CANNOT be passed to `@dataclass_transform`:
37
+ # PEP 681 demands a literal tuple at each site and mypy rejects it; the language imposes the
38
+ # duplication across the FIVE sites — `SnakeModel` and `SnakeView` in `model.py`, and the
39
+ # `@snake_model`, `@snake_view` and `@snake_db_first` decorators.
40
+ #
41
+ # `test/typing/test_field_specifiers.py` checks that they all match, and it DISCOVERS them through
42
+ # the `__dataclass_transform__` PEP 681 leaves at runtime instead of listing them: forgetting a
43
+ # site silently stops typing that path — with every test still green — so the count is exactly the
44
+ # thing that must not be remembered.
45
+ #
46
+ # The three relation specifiers ARE here, and NOT because relations are constructor arguments —
47
+ # they are not. `snake_to_one`, `snake_to_many` and `snake_to_many_through` each declare
48
+ # `init: Literal[False]`, and a checker only reads that signal off a call it recognises as a field
49
+ # specifier, i.e. one listed here. Leaving one out makes the checker read it as "a field with a
50
+ # default" and bless a line the runtime rejects with `TypeError`.
51
+ SNAKE_FIELD_SPECIFIERS = (
52
+ snake_column,
53
+ snake_auto,
54
+ snake_enum,
55
+ snake_int,
56
+ snake_str,
57
+ snake_decimal,
58
+ snake_datetime,
59
+ snake_datetimetz,
60
+ snake_float,
61
+ snake_time,
62
+ snake_timetz,
63
+ snake_json,
64
+ snake_to_one,
65
+ snake_to_many,
66
+ snake_to_many_through,
67
+ snake_discriminator,
68
+ )
@@ -0,0 +1,79 @@
1
+ """Declaring CHECK constraints: `snake_check(...)` in the body of the model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+
7
+ from snakeorm.core.exceptions import SnakeModelError
8
+ from snakeorm.expressions import SnakeCondition
9
+ from snakeorm.fields.index import SnakeIndex
10
+ from snakeorm.metadata import SnakeIndexInfo
11
+ from snakeorm.fields.relationship import _registry_of
12
+ from snakeorm.metadata.check import SnakeCheckInfo
13
+
14
+
15
+ def snake_check(
16
+ condition: SnakeCondition, *, name: str | None = None
17
+ ) -> SnakeCheckInfo:
18
+ """Declare a typed CHECK constraint: the condition is the same `SnakeCondition` as
19
+ `.filter()`.
20
+
21
+ It gets validated here, at declaration time, and not when generating the migration: a
22
+ condition with a subquery or an EXISTS does not fit in a CHECK, and the useful place to find
23
+ out is where you wrote it.
24
+ """
25
+ info = SnakeCheckInfo(condition=condition, name=name)
26
+ # A dry render so the rejection fires now; `render_condition` is the only one that knows which
27
+ # nodes are reconstructible (duplicating that criterion would mean having two truths).
28
+ from snakeorm.migration.render import render_condition
29
+
30
+ render_condition(condition)
31
+ return info
32
+
33
+
34
+ def snake_checks(model: type, *checks: SnakeCheckInfo) -> None:
35
+ """Add CHECK constraints to an ALREADY decorated model, referencing its TYPED columns.
36
+
37
+ snake_checks(Person, snake_check(Person.age >= 18, name="adult"))
38
+
39
+ Outside the class body (not inside it like `SnakeIndexes`) because a condition is built on the
40
+ spot and inside the body `age` is the raw descriptor, with `__set_name__` not yet run: it does
41
+ not know its name yet. Once decorated, `Person.age` is CLASS access → `SnakeExpr[int]`, which
42
+ preserves the typing.
43
+ """
44
+ reg = _registry_of(model)
45
+ table = reg.table_of(model)
46
+ if table is None:
47
+ raise SnakeModelError(
48
+ f"{model.__name__} is not a @snake_model: declare the model before adding checks to it."
49
+ )
50
+ reg.register(model, replace(table, checks=(*table.checks, *checks)))
51
+
52
+
53
+ def snake_indexes(model: type, *indexes: SnakeIndex) -> None:
54
+ """Add indexes to an ALREADY decorated model, referencing its TYPED columns.
55
+
56
+ snake_indexes(Customer, SnakeIndex(Customer.name, unique=True, where=Customer.closed_at.is_null()))
57
+
58
+ Same reason as `snake_checks`: a PARTIAL index needs a condition, which is built on the spot
59
+ and does not fit in the `SnakeIndexes` of the body. It re-registers the model instead of
60
+ mutating an attribute (assigning `SnakeIndexes` after the decorator never reaches the graph:
61
+ it has already compiled).
62
+ """
63
+ reg = _registry_of(model)
64
+ table = reg.table_of(model)
65
+ if table is None:
66
+ raise SnakeModelError(
67
+ f"{model.__name__} is not a @snake_model: declare the model before adding indexes to it."
68
+ )
69
+ compiled = tuple(
70
+ SnakeIndexInfo(
71
+ columns=index.column_names(),
72
+ unique=index.unique,
73
+ name=index.name,
74
+ where=index.where,
75
+ method=index.method,
76
+ )
77
+ for index in indexes
78
+ )
79
+ reg.register(model, replace(table, indexes=(*table.indexes, *compiled)))