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,311 @@
1
+ """The `DebugReport`: it normalises the `QueryRecord`s and serves them in several formats (dict, text, Server-Timing, HTML).
2
+
3
+ Duplicate detection is the N+1 heuristic: the same SQL repeated FROM THE SAME LINE is the signature
4
+ of "one query per parent", and the line is where the fix goes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+
13
+ from snakeorm.debug.record import QueryOrigin, QueryRecord
14
+
15
+ # Primitives that go to JSON as they are; the rest gets serialised with str() so the envelope does
16
+ # not blow up.
17
+ _JSON_PRIMITIVES = (int, float, str, bool, type(None))
18
+
19
+
20
+ def _json_safe(value: object) -> object:
21
+ """Let JSON primitives through; convert everything else (datetime, Decimal, UUID...) to `str`.
22
+
23
+ The envelope travels inside a JSON response: an unserialisable param would break the whole
24
+ thing.
25
+ """
26
+ if isinstance(value, _JSON_PRIMITIVES):
27
+ return value
28
+ return str(value)
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class DuplicateGroup:
33
+ """The same SQL fired more than once FROM THE SAME PLACE, with its timings.
34
+
35
+ The key is `(sql, origin)` and not the SQL alone: the fix goes on the line that fires the
36
+ repeat, so fusing two call sites throws away the only actionable thing there is. `origin` is
37
+ `None` when it could not be resolved, and that is a key like any other.
38
+
39
+ `worst_params` are the SLOWEST run's. The rest are noise; these answer "which value took 18 ms?".
40
+ """
41
+
42
+ sql: str
43
+ origin: QueryOrigin | None
44
+ count: int
45
+ total_ms: float
46
+ worst_ms: float
47
+ worst_params: tuple[object, ...]
48
+
49
+ @property
50
+ def average_ms(self) -> float:
51
+ """Mean time per run of this group."""
52
+ return self.total_ms / self.count
53
+
54
+ @property
55
+ def location(self) -> str:
56
+ """`file:line` of the call site, or an empty string when there is no origin."""
57
+ if self.origin is None:
58
+ return ""
59
+ return f"{self.origin.file}:{self.origin.line}"
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class RequestInfo:
64
+ """WHICH request produced a report: verb, path, status and the instant it started.
65
+
66
+ The four travel together because they are one answer, and one optional field says "no request"
67
+ once instead of four that can disagree. Without it a list of reports is N identical lines.
68
+ """
69
+
70
+ method: str
71
+ path: str
72
+ status: int
73
+ at: datetime
74
+
75
+ def to_dict(self) -> dict[str, object]:
76
+ """JSON-serialisable form; the instant as ISO 8601 (a `datetime` is not JSON)."""
77
+ return {
78
+ "method": self.method,
79
+ "path": self.path,
80
+ "status": self.status,
81
+ "at": self.at.isoformat(),
82
+ }
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class DebugReport:
87
+ """The records of a scope, ready to read. Immutable: a snapshot of what ran.
88
+
89
+ `wall_ms` is the wall clock of the whole REQUEST (the middleware measures it around the app),
90
+ and it splits into THREE slices that add back up to it: `total_ms` (waiting on the driver),
91
+ `mapping_ms` (turning rows into objects) and `app_ms` (everything else — the user's Python and
92
+ the template). It is `None` when nobody measured it (a standalone report, a sidecar with no
93
+ request): then only the time in the DB is known.
94
+ """
95
+
96
+ records: tuple[QueryRecord, ...]
97
+ wall_ms: float | None = None
98
+ # Index suggestions `(table, column, worst_ms)` that the middleware computes with the advisor
99
+ # (only from the SLOW queries): the duration justifies the advice and orders the suggestions.
100
+ index_hints: tuple[tuple[str, str, float], ...] = ()
101
+ # WHICH request this was. Optional for the same reason `wall_ms` is: a standalone capture (a
102
+ # script, a test, a CLI command) has no request to name.
103
+ request: RequestInfo | None = None
104
+ # Time the ORM spent turning rows into objects, measured by the collector INSIDE the scope.
105
+ # `None` (never 0.0) when there was no scope to measure it: absent is not zero.
106
+ mapping_ms: float | None = None
107
+
108
+ @classmethod
109
+ def from_records(
110
+ cls,
111
+ records: Iterable[QueryRecord],
112
+ *,
113
+ wall_ms: float | None = None,
114
+ mapping_ms: float | None = None,
115
+ ) -> DebugReport:
116
+ """Freeze a sequence of records into a report (with the optional wall and mapping clocks)."""
117
+ return cls(tuple(records), wall_ms, mapping_ms=mapping_ms)
118
+
119
+ def with_wall_ms(self, wall_ms: float) -> DebugReport:
120
+ """Copy the report setting the request wall clock (the middleware measures it at the end)."""
121
+ return DebugReport(
122
+ self.records, wall_ms, self.index_hints, self.request, self.mapping_ms
123
+ )
124
+
125
+ def with_index_hints(
126
+ self, hints: tuple[tuple[str, str, float], ...]
127
+ ) -> DebugReport:
128
+ """Copy the report with the index suggestions (the middleware computes them with the advisor)."""
129
+ return DebugReport(
130
+ self.records, self.wall_ms, hints, self.request, self.mapping_ms
131
+ )
132
+
133
+ def with_request(self, request: RequestInfo) -> DebugReport:
134
+ """Copy the report naming the request that produced it (the adapters fill it in)."""
135
+ return DebugReport(
136
+ self.records, self.wall_ms, self.index_hints, request, self.mapping_ms
137
+ )
138
+
139
+ @property
140
+ def count(self) -> int:
141
+ """How many statements ran."""
142
+ return len(self.records)
143
+
144
+ @property
145
+ def total_ms(self) -> float:
146
+ """Total summed time of every statement, in milliseconds (the time IN THE DB)."""
147
+ return sum(record.duration_ms for record in self.records)
148
+
149
+ @property
150
+ def app_ms(self) -> float | None:
151
+ """The rest = wall - DB - MAPPING. `None` if the wall was not measured.
152
+
153
+ It used to be `wall - DB`, one opaque block where the ORM's own mapping hid among the
154
+ user's Python. With `mapping_ms` measured, that half comes out and this is what is left:
155
+ the application's code and its template. With no mapping measured the old formula stands —
156
+ there is no third slice to take out, and the two that exist still add up to the wall.
157
+ """
158
+ if self.wall_ms is None:
159
+ return None
160
+ mapping = 0.0 if self.mapping_ms is None else self.mapping_ms
161
+ return max(0.0, self.wall_ms - self.total_ms - mapping)
162
+
163
+ def duplicates(self) -> tuple[DuplicateGroup, ...]:
164
+ """Groups of `(sql, origin)` that ran MORE than once, from most to least repeated.
165
+
166
+ The params are not part of the key: the same SQL with different params is the signature of
167
+ an N+1. The ORIGIN is, because that is the line the reader has to change.
168
+ """
169
+ grouped: dict[tuple[str, QueryOrigin | None], list[QueryRecord]] = {}
170
+ for record in self.records:
171
+ grouped.setdefault((record.sql, record.origin), []).append(record)
172
+ groups = [
173
+ _group(sql, origin, records)
174
+ for (sql, origin), records in grouped.items()
175
+ if len(records) > 1
176
+ ]
177
+ # By count only, and the sort is STABLE: a tie keeps the order the groups first ran in.
178
+ # Breaking the tie on time would order the list by a MEASUREMENT, so the same run reported
179
+ # twice could list its warnings in a different order.
180
+ groups.sort(key=lambda group: group.count, reverse=True)
181
+ return tuple(groups)
182
+
183
+ def slowest(self) -> QueryRecord | None:
184
+ """The record that took longest (or `None` if there was none)."""
185
+ if not self.records:
186
+ return None
187
+ return max(self.records, key=lambda record: record.duration_ms)
188
+
189
+ @property
190
+ def warnings(self) -> tuple[str, ...]:
191
+ """Readable warnings: today, a possible N+1 per duplicated group, NAMING the call site.
192
+
193
+ The location is the actionable half: the SQL says what repeated, the line says where to go.
194
+ It is dropped when the origin could not be resolved, rather than printed empty.
195
+ """
196
+ return tuple(
197
+ f"The same SQL ran {group.count} times (a possible N+1)"
198
+ + (f" at {group.location}" if group.location else "")
199
+ + f": {group.sql}"
200
+ for group in self.duplicates()
201
+ )
202
+
203
+ @property
204
+ def summary(self) -> str:
205
+ """One line the dev scans at a glance: how many, how long, and duplicates."""
206
+ return (
207
+ f"{self.count} queries · {self.total_ms:.1f}ms · "
208
+ f"{len(self.duplicates())} duplicates"
209
+ )
210
+
211
+ def to_dict(self) -> dict[str, object]:
212
+ """JSON-serialisable envelope (the `snakeorm` block and the sidecar). The params go through `_json_safe` so the JSON does not break."""
213
+ return {
214
+ "summary": self.summary,
215
+ "request": None if self.request is None else self.request.to_dict(),
216
+ "count": self.count,
217
+ "total_ms": round(self.total_ms, 3),
218
+ "db_ms": round(self.total_ms, 3),
219
+ "mapping_ms": None
220
+ if self.mapping_ms is None
221
+ else round(self.mapping_ms, 3),
222
+ "wall_ms": None if self.wall_ms is None else round(self.wall_ms, 3),
223
+ "app_ms": None if self.app_ms is None else round(self.app_ms, 3),
224
+ "warnings": list(self.warnings),
225
+ "index_hints": [
226
+ f"{table}.{column} ({ms:.0f}ms)"
227
+ for table, column, ms in self.index_hints
228
+ ],
229
+ "queries": [
230
+ {
231
+ "n": record.n,
232
+ "ms": round(record.duration_ms, 3),
233
+ "kind": record.kind.value,
234
+ "sql": record.sql,
235
+ "params": [_json_safe(param) for param in record.params],
236
+ "rows": record.rows,
237
+ "origin": (
238
+ None
239
+ if record.origin is None
240
+ else {
241
+ "file": record.origin.file,
242
+ "line": record.origin.line,
243
+ "function": record.origin.function,
244
+ }
245
+ ),
246
+ }
247
+ for record in self.records
248
+ ],
249
+ }
250
+
251
+ def to_server_timing(self) -> str:
252
+ """`Server-Timing` header (W3C): the browser devtools paint it by themselves.
253
+
254
+ It always carries `db` (the time in the DB) and, when it was measured, `map` (the ORM
255
+ turning rows into objects). If the middleware measured the wall clock, it adds `total` (the
256
+ whole request) and `app` (the rest: the user's Python and the template); that way the
257
+ browser shows the breakdown, and the subtraction against the total response time the
258
+ browser sees IS the network trip.
259
+
260
+ `map` goes out even with no wall clock, because it is the ONLY thing a response with no
261
+ body can say about the ORM's own cost: an HTMX fragment carries headers and nothing else."""
262
+ parts = [f'db;dur={round(self.total_ms, 3)};desc="{self.count} queries"']
263
+ if self.mapping_ms is not None:
264
+ parts.append(f"map;dur={round(self.mapping_ms, 3)}")
265
+ if self.wall_ms is not None and self.app_ms is not None:
266
+ parts.append(f"app;dur={round(self.app_ms, 3)}")
267
+ parts.append(f'total;dur={round(self.wall_ms, 3)};desc="request"')
268
+ return ", ".join(parts)
269
+
270
+ def to_text(self) -> str:
271
+ """An aligned table, readable in a terminal/curl. The summary heads it; one row per query."""
272
+ header = ("#", "ms", "rows", "kind", "SQL")
273
+ rows = [
274
+ (
275
+ str(record.n),
276
+ f"{record.duration_ms:.2f}",
277
+ str(record.rows),
278
+ record.kind.value,
279
+ record.sql,
280
+ )
281
+ for record in self.records
282
+ ]
283
+ widths = [
284
+ max(len(header[i]), *(len(row[i]) for row in rows))
285
+ if rows
286
+ else len(header[i])
287
+ for i in range(len(header))
288
+ ]
289
+ line = " ".join(cell.ljust(widths[i]) for i, cell in enumerate(header))
290
+ separator = " ".join("-" * widths[i] for i in range(len(header)))
291
+ body = "\n".join(
292
+ " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))
293
+ for row in rows
294
+ )
295
+ parts = [self.summary, line, separator, body] if rows else [self.summary]
296
+ return "\n".join(parts)
297
+
298
+
299
+ def _group(
300
+ sql: str, origin: QueryOrigin | None, records: list[QueryRecord]
301
+ ) -> DuplicateGroup:
302
+ """Fold the records of one `(sql, origin)` key into its group (timings + the worst run's params)."""
303
+ worst = max(records, key=lambda record: record.duration_ms)
304
+ return DuplicateGroup(
305
+ sql=sql,
306
+ origin=origin,
307
+ count=len(records),
308
+ total_ms=sum(record.duration_ms for record in records),
309
+ worst_ms=worst.duration_ms,
310
+ worst_params=worst.params,
311
+ )
@@ -0,0 +1,23 @@
1
+ """`assert_queries`: a test assertion on how many statements a block runs (like Django's `assertNumQueries`).
2
+
3
+ When it fails it shows the queries that ran, not just that the number does not add up.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+
11
+ from snakeorm.debug.collector import DebugCollector, capture_queries
12
+
13
+
14
+ @contextmanager
15
+ def assert_queries(expected: int) -> Iterator[DebugCollector]:
16
+ """Assert the block runs EXACTLY `expected` statements, or raise `AssertionError`."""
17
+ with capture_queries() as collector:
18
+ yield collector
19
+ report = collector.report()
20
+ if report.count != expected:
21
+ raise AssertionError(
22
+ f"Expected {expected} queries, {report.count} ran:\n{report.to_text()}"
23
+ )
@@ -0,0 +1,13 @@
1
+ """SnakeORM public decorators."""
2
+
3
+ from snakeorm.decorators.abstract import snake_abstract as snake_abstract
4
+ from snakeorm.decorators.db_first import snake_db_first as snake_db_first
5
+ from snakeorm.decorators.function import snake_function as snake_function
6
+ from snakeorm.decorators.model import snake_model as snake_model
7
+ from snakeorm.decorators.model import snake_table as snake_table
8
+ from snakeorm.decorators.result import SnakeResult as SnakeResult
9
+ from snakeorm.decorators.result import snake_result as snake_result
10
+ from snakeorm.decorators.row import SnakeRow as SnakeRow
11
+ from snakeorm.decorators.row import snake_row as snake_row
12
+ from snakeorm.decorators.trigger import snake_trigger as snake_trigger
13
+ from snakeorm.decorators.view import snake_view as snake_view
@@ -0,0 +1,26 @@
1
+ """`@snake_abstract`: a base that contributes columns and is NOT a table.
2
+
3
+ It does not change the behaviour (inheritance already works via `collect_inherited`): it only
4
+ DECLARES that the class is a base, so that using it as a table gives a useful error instead of the
5
+ generic "is @snake_model missing?".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TypeVar
11
+
12
+ from snakeorm.helpers.inheritance import ABSTRACT_MARKER as _BRAND
13
+ from snakeorm.helpers.inheritance import is_abstract as is_abstract
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ def snake_abstract(cls: type[T]) -> type[T]:
19
+ """Mark a class as an abstract BASE: it contributes columns to its children, and is no table.
20
+
21
+ Each child gets the base's columns IN ITS OWN TABLE; the base never shows up in migrations (it
22
+ is not registered). If the child redefines an attribute, the child's wins. There is no
23
+ parametric form: there is nothing to configure.
24
+ """
25
+ setattr(cls, _BRAND, True)
26
+ return cls
@@ -0,0 +1,104 @@
1
+ """The `@snake_db_first` decorator: a model that MIRRORS something that already exists."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import TypeVar, dataclass_transform
7
+
8
+ from snakeorm.compiler import compile_model
9
+ from snakeorm.decorators.model import _install_dunders, _make_init
10
+ from snakeorm.core.placement import DEFAULT_SCHEMA
11
+ from snakeorm.fields import (
12
+ snake_auto,
13
+ snake_column,
14
+ snake_datetime,
15
+ snake_datetimetz,
16
+ snake_decimal,
17
+ snake_discriminator,
18
+ snake_enum,
19
+ snake_float,
20
+ snake_int,
21
+ snake_json,
22
+ snake_str,
23
+ snake_time,
24
+ snake_timetz,
25
+ snake_to_many,
26
+ snake_to_many_through,
27
+ snake_to_one,
28
+ )
29
+ from snakeorm.metadata import SnakeTableKind
30
+ from snakeorm.registry import SnakeRegistry
31
+ from snakeorm.registry import registry as default_registry
32
+
33
+ T = TypeVar("T")
34
+
35
+
36
+ # A LITERAL tuple as PEP 681 demands; tied to SNAKE_FIELD_SPECIFIERS by a test.
37
+ #
38
+ # It used to carry seven of these instead of the whole set, and nothing said so: the test that ties
39
+ # the sites together listed FOUR of them by hand and this was the fifth. The scaffolder emits
40
+ # `snake_decimal()`, `snake_int()` and `snake_str()`, so a mirrored model declared with those
41
+ # stopped typing its `__init__` in silence — the checker read the call as a plain default value.
42
+ @dataclass_transform(
43
+ kw_only_default=True,
44
+ field_specifiers=(
45
+ snake_column,
46
+ snake_auto,
47
+ snake_enum,
48
+ snake_int,
49
+ snake_str,
50
+ snake_decimal,
51
+ snake_datetime,
52
+ snake_datetimetz,
53
+ snake_float,
54
+ snake_time,
55
+ snake_timetz,
56
+ snake_json,
57
+ snake_to_one,
58
+ snake_to_many,
59
+ snake_to_many_through,
60
+ snake_discriminator,
61
+ ),
62
+ )
63
+ def snake_db_first(
64
+ *,
65
+ table: str | None = None,
66
+ schema: str = DEFAULT_SCHEMA,
67
+ database: str = "default",
68
+ registry: SnakeRegistry = default_registry,
69
+ ) -> Callable[[type[T]], type[T]]:
70
+ """Declare a model that MIRRORS a table that ALREADY exists and that we do NOT govern (the
71
+ Django `managed=False`).
72
+
73
+ You query and write it like any other model, but migrations IGNORE it (`current_schema()`
74
+ excludes anything unmanaged): the source of truth of the schema is the DB, not the model.
75
+
76
+ There is NO in-place adoption: swapping `@snake_db_first` for `@snake_model` does NOT hand the
77
+ controls to the migrations; the history does not know the table and the autogen would only
78
+ emit a `CreateTable`, which against the existing table dies with `DuplicateTable`. It IS good
79
+ for TAKING the schema to ANOTHER database managed from scratch (there the `CreateTable` is
80
+ correct); the original DB is left untouched.
81
+ """
82
+
83
+ def wrap(klass: type[T]) -> type[T]:
84
+ registry.register(
85
+ klass,
86
+ compile_model(
87
+ klass,
88
+ table=table,
89
+ schema=schema,
90
+ database=database,
91
+ kind=SnakeTableKind.EXTERNAL,
92
+ ),
93
+ )
94
+ # The mirror remembers WHICH registry it lives in, exactly as `@snake_model` does. Without
95
+ # it `@snake_db_first(registry=reg)` registered the model in `reg` and then every consumer
96
+ # that asks the MODEL —typed navigation, the query, the session— was answered by the global
97
+ # one, so the class was registered and unreachable. It went unnoticed because nothing asked
98
+ # until the query and the session stopped reaching for the global registry themselves.
99
+ setattr(klass, "__snake_registry__", registry)
100
+ setattr(klass, "__init__", _make_init(klass))
101
+ _install_dunders(klass, registry)
102
+ return klass
103
+
104
+ return wrap
@@ -0,0 +1,28 @@
1
+ """`snake_function`: DECLARES a DESIRED stored function/procedure for the autodetect.
2
+
3
+ A module helper (not a decorator: the routine has no Python body) that registers the routine in the
4
+ registry. The `body` is the raw and complete `CREATE OR REPLACE FUNCTION ...` (the ORM does not
5
+ check what it does). The autodetect compares it against the history and emits
6
+ `CreateFunction`/`AlterFunction` (the `body` changed)/`DropFunction`. Functions are created AFTER
7
+ tables and views and are NOT ordered among themselves.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from snakeorm.core.placement import DEFAULT_SCHEMA
13
+ from snakeorm.metadata import SnakeRoutineInfo
14
+ from snakeorm.registry import registry
15
+
16
+
17
+ def snake_function(
18
+ *, name: str, body: str, schema: str = DEFAULT_SCHEMA
19
+ ) -> SnakeRoutineInfo:
20
+ """Declare and register a desired routine; return its compiled `SnakeRoutineInfo`.
21
+
22
+ Registering the same `name` replaces the previous `body` (CREATE OR REPLACE semantics). Using
23
+ the returned object is optional: the source of truth is the registry, which the autodetect
24
+ consults.
25
+ """
26
+ routine = SnakeRoutineInfo(name=name, body=body, schema=schema)
27
+ registry.register_routine(routine)
28
+ return routine