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.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
snakeorm/debug/report.py
ADDED
|
@@ -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
|