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
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!-- Vista "Config": cómo se configura el panel. Estático (informativo, no interactivo: el panel es SSR
|
|
2
|
+
por request). Textos traducibles con `data-t`; env-vars y código NO se traducen. -->
|
|
3
|
+
<div class="snk-help">
|
|
4
|
+
<div class="snk-help-card">
|
|
5
|
+
<h4 data-t="cfg_channels">Canales de debug</h4>
|
|
6
|
+
<p data-t="cfg_channels_d">Qué entrega el panel. Se combinan por coma:</p>
|
|
7
|
+
<p><code>SNAKE_ORM_DEBUG=ssr,envelope,timing</code></p>
|
|
8
|
+
<p><code>ssr</code> · <span data-t="cfg_ch_ssr">panel HTML inyectado en la respuesta</span></p>
|
|
9
|
+
<p><code>envelope</code> · <span data-t="cfg_ch_envelope">el debug en el JSON, bajo la clave</span> <code>snakeorm</code></p>
|
|
10
|
+
<p><code>timing</code> · <span data-t="cfg_ch_timing">cabecera Server-Timing</span></p>
|
|
11
|
+
<p><code>sidecar</code> · <span data-t="cfg_ch_sidecar">un token y su propia página en</span> <code>/__snake__/{token}</code></p>
|
|
12
|
+
<p><code>otel</code> · <span data-t="cfg_ch_otel">DECLARADO y sin implementar: encenderlo no entrega nada y avisa</span></p>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="snk-help-card">
|
|
15
|
+
<h4 data-t="cfg_advise">Umbral del asesor</h4>
|
|
16
|
+
<p data-t="cfg_advise_d">Solo sugiere índices para queries más lentas que X ms (por debajo, un índice no cambia nada):</p>
|
|
17
|
+
<p><code>SNAKE_ORM_ADVISE_MS=10</code></p>
|
|
18
|
+
<p><span data-t="cfg_advise_code">O tipado en código:</span> <code>SnakeDebugConfig(advise_min_ms=25)</code></p>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="snk-help-card">
|
|
21
|
+
<h4 data-t="cfg_where">Qué va en config y qué en el comando</h4>
|
|
22
|
+
<p data-t="cfg_where_d">La regla: si la aplicación lo necesita para funcionar, va en config; si solo lo necesita esa invocación, va en flag.</p>
|
|
23
|
+
<p><code>SnakeOrmConfig</code> · <span data-t="cfg_where_cfg">databases, debug, advise_ms, migrations_dir, language</span></p>
|
|
24
|
+
<p><span data-t="cfg_where_cli">Flags del comando:</span> <code>--out</code>, <code>--schema</code>, <code>--keep-underscores</code></p>
|
|
25
|
+
<p data-t="cfg_where_db">--database no sobrescribe nada: ELIGE una de las conexiones que declara la config. Un nombre que no esté no se puede crear desde el comando.</p>
|
|
26
|
+
<p data-t="cfg_where_django">En Django no se declara aparte: el CLI sube hasta manage.py, lee su DJANGO_SETTINGS_MODULE y traduce settings.DATABASES + settings.SNAKEORM al mismo objeto.</p>
|
|
27
|
+
</div>
|
|
28
|
+
<div class="snk-help-card">
|
|
29
|
+
<h4 data-t="cfg_prod">Producción</h4>
|
|
30
|
+
<p data-t="cfg_prod_d">Los que entregan el SQL a quien pide —ssr, envelope y sidecar— se caen con production=True, aunque los configures. timing se queda: mide, no cuenta.</p>
|
|
31
|
+
<p><code>SNAKE_ORM_PRODUCTION=true</code></p>
|
|
32
|
+
<p data-t="cfg_prod_env">Del entorno no se adivina nada: si hay un canal de riesgo encendido y nadie lo ha declarado —ni esta variable, ni production= en el middleware, ni SnakeDebugConfig— el arranque se niega en vez de elegir un defecto.</p>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!-- Vista "DB-first": adoptar una BD existente (scaffolding + modelo espejo + deriva). Estático. Textos
|
|
2
|
+
traducibles con `data-t`; comandos y decoradores NO se traducen. Se inlinea como una vista más. -->
|
|
3
|
+
<div class="snk-help">
|
|
4
|
+
<div class="snk-help-card">
|
|
5
|
+
<h4 data-t="dbf_scaffold">Generar modelos</h4>
|
|
6
|
+
<p data-t="dbf_scaffold_d">Espeja una BD existente a modelos Python:</p>
|
|
7
|
+
<p><code>snakeorm scaffold create --out models.py</code></p>
|
|
8
|
+
<p><span data-t="dbf_scaffold_u">Re-sincronizar (sobrescribe entero):</span> <code>scaffold update</code></p>
|
|
9
|
+
<p><span data-t="dbf_scaffold_out">Sin</span> <code>--out</code> <span data-t="dbf_scaffold_out2">no genera: no hay ruta por defecto y falla diciéndolo.</span></p>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="snk-help-card">
|
|
12
|
+
<h4 data-t="dbf_mirror">Modelo espejo</h4>
|
|
13
|
+
<p><code>@snake_db_first</code> · <span data-t="dbf_mirror_d">marca un modelo como espejo: las migraciones lo IGNORAN. El esquema es del sysadmin, no nuestro.</span></p>
|
|
14
|
+
<p data-t="dbf_mirror_adopt">NO hay adopción in situ: cambiarlo a @snake_model no le entrega los mandos a las migraciones, porque el histórico no conoce la tabla y el autogen solo emitiría un CreateTable, que contra la tabla existente muere. Sirve para LLEVARSE el esquema a otra base gobernada desde cero.</p>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="snk-help-card">
|
|
17
|
+
<h4 data-t="dbf_names">Nombres de las clases</h4>
|
|
18
|
+
<p data-t="dbf_names_d">La clase se llama como la tabla, en CapWords y con el esquema delante:</p>
|
|
19
|
+
<p><code>public.project_requests</code> → <code>PublicProjectRequests</code></p>
|
|
20
|
+
<p><code>--keep-underscores</code> · <span data-t="dbf_names_u">deja los guiones bajos</span> <code>Public_project_requests</code></p>
|
|
21
|
+
<p><code>--no-schema-prefix</code> · <span data-t="dbf_names_s">quita el esquema; dos esquemas con la misma tabla COLISIONAN y se avisa</span></p>
|
|
22
|
+
<p data-t="dbf_names_why">No se quita el plural: eso es adivinar en inglés y rompe status, analysis o direcciones. Lo que no se puede nombrar se avisa y se deja fuera, nunca se inventa.</p>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="snk-help-card">
|
|
25
|
+
<h4 data-t="dbf_check">Deriva</h4>
|
|
26
|
+
<p data-t="dbf_check_d">Compara el código con la BD real y avisa si no cuadran:</p>
|
|
27
|
+
<p><code>snakeorm check --models miapp.models</code></p>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!-- Contenido ESTÁTICO de la vista "Ayuda" del panel: cómo usar la herramienta. Se inlinea dentro del
|
|
2
|
+
shadow root como una vista más. Los textos traducibles llevan `data-t` (el JS los swapea); el
|
|
3
|
+
server pinta el español por defecto. Los fragmentos de código/env-var no se traducen. -->
|
|
4
|
+
<div class="snk-help">
|
|
5
|
+
<div class="snk-help-card">
|
|
6
|
+
<h4 data-t="help_enable">Activar el panel</h4>
|
|
7
|
+
<p><code>SNAKE_ORM_DEBUG=ssr,envelope,timing</code></p>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="snk-help-card">
|
|
10
|
+
<h4 data-t="help_json">JSON de la API</h4>
|
|
11
|
+
<p><span data-t="help_json_d">Con el canal envelope, en cada respuesta JSON bajo la clave</span> <code>snakeorm</code></p>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="snk-help-card">
|
|
14
|
+
<h4 data-t="help_shortcuts">Atajos</h4>
|
|
15
|
+
<p data-t="help_shortcuts_d">Esc cierra · arrastra el botón para moverlo · ◐ tema · 🌐 idioma</p>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="snk-help-card">
|
|
18
|
+
<h4 data-t="help_read">Leer las consultas</h4>
|
|
19
|
+
<p data-t="help_read_d">×N = misma consulta repetida · ↳ fichero:línea = de dónde salió · los ? ya llevan su valor real · pliega los avisos y elige cuántas queries ves por página para ganar sitio</p>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!-- Vista "Historial": las llamadas que la página hace DESPUÉS del render (fetch, XHR, HTMX, Alpine).
|
|
2
|
+
Es la única vista DINÁMICA: el servidor pinta el esqueleto y el cliente añade sus `<li>` a
|
|
3
|
+
`.snk-history-list`. Las tarjetas de arriba son aritmética del cliente sobre esa misma lista:
|
|
4
|
+
el servidor las pinta vacías (con sus textos traducibles) y el JS solo escribe los números.
|
|
5
|
+
Textos traducibles con `data-t`; el código y las env-vars no se traducen.
|
|
6
|
+
`.snk-history-off` solo se ve cuando la vista lleva `data-envelope="off"`. -->
|
|
7
|
+
<div class="snk-help">
|
|
8
|
+
<div class="snk-help-card snk-history-live">
|
|
9
|
+
<div class="snk-metrics snk-hmetrics">
|
|
10
|
+
<div class="snk-m" title="Llamadas que la página ha hecho desde que cargó." data-tt="hcalls_tip"><b data-hm="calls">0</b><span data-t="hcalls">llamadas</span></div>
|
|
11
|
+
<div class="snk-m" title="Suma de las consultas de esas llamadas." data-tt="hqueries_tip"><b data-hm="queries">0</b><span data-t="queries">queries</span><em class="snk-m-part" data-hm-part="queries" title="Calculado solo sobre las llamadas que trajeron el dato; las demás no cuentan como cero." data-tt="hpart_tip"></em></div>
|
|
12
|
+
<div class="snk-m" title="Suma de lo que esas llamadas esperaron a la BD." data-tt="hdb_tip"><b data-hm="db">0.00<small>ms</small></b><span data-t="db">en BD</span><em class="snk-m-part" data-hm-part="db" title="Calculado solo sobre las llamadas que trajeron el dato; las demás no cuentan como cero." data-tt="hpart_tip"></em></div>
|
|
13
|
+
<div class="snk-m" title="Suma del mapeo de esas llamadas: filas convertidas en objetos." data-tt="hmap_tip"><b data-hm="map">0.00<small>ms</small></b><span data-t="map">mapeo</span><em class="snk-m-part" data-hm-part="map" title="Calculado solo sobre las llamadas que trajeron el dato; las demás no cuentan como cero." data-tt="hpart_tip"></em></div>
|
|
14
|
+
<div class="snk-m" title="La llamada que más esperó a la BD de toda la lista." data-tt="hslowest_tip"><b data-hm="slowest">0.00<small>ms</small></b><span data-t="slowest">más lenta</span><em class="snk-m-part" data-hm-part="slowest" title="Calculado solo sobre las llamadas que trajeron el dato; las demás no cuentan como cero." data-tt="hpart_tip"></em></div>
|
|
15
|
+
</div>
|
|
16
|
+
<h4 data-t="hist_title">Llamadas posteriores</h4>
|
|
17
|
+
<p data-t="hist_intro">El informe de arriba es el de la petición que pintó esta página. Aquí se apilan las que vienen después, sin recargar.</p>
|
|
18
|
+
<ul class="snk-history-list"></ul>
|
|
19
|
+
<p class="snk-history-empty" data-t="hist_empty">Aún no hay llamadas.</p>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="snk-help-card snk-history-off">
|
|
22
|
+
<h4 data-t="hist_off">Falta el canal envelope</h4>
|
|
23
|
+
<p data-t="hist_off_d">El histórico lee el informe que el ORM cuelga de las respuestas JSON. Sin ese canal no llega ninguno, así que aquí no habría nada que apilar:</p>
|
|
24
|
+
<p><code>SNAKE_ORM_DEBUG=ssr,envelope</code></p>
|
|
25
|
+
<p data-t="hist_off_why">El panel lo dice y no lo enciende: ssr y envelope son canales independientes y quien los elige eres tú.</p>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""`CaptureDriver` / `AsyncCaptureDriver`: driver decorators that record each statement into the scope's collector.
|
|
2
|
+
|
|
3
|
+
With no active scope they delegate without timing or recording: with the debug off, the cost is one `ContextVar` read.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
9
|
+
from time import perf_counter
|
|
10
|
+
|
|
11
|
+
from snakeorm.debug.collector import current_collector
|
|
12
|
+
from snakeorm.debug.record import QueryKind
|
|
13
|
+
from snakeorm.drivers.asyncbase import AsyncDriver
|
|
14
|
+
from snakeorm.drivers.base import SnakeDriver
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CaptureDriver:
|
|
18
|
+
"""Wrap a `SnakeDriver` and record each statement into the active collector (if there is one)."""
|
|
19
|
+
|
|
20
|
+
__slots__ = ("_inner", "_system")
|
|
21
|
+
|
|
22
|
+
def __init__(self, inner: SnakeDriver, *, system: str = "") -> None:
|
|
23
|
+
"""`system` is the engine's OpenTelemetry name (`db.system.name`), DECLARED by the caller.
|
|
24
|
+
|
|
25
|
+
It is read once here and copied onto every record, so the per-query cost is an attribute
|
|
26
|
+
read. It is not derived from the driver's class: a decorator chain (pool, timeout, logging)
|
|
27
|
+
would hide it, and guessing from the SQL cannot tell MySQL from MariaDB.
|
|
28
|
+
"""
|
|
29
|
+
self._inner = inner
|
|
30
|
+
self._system = system
|
|
31
|
+
|
|
32
|
+
def fetch_all(self, sql: str, params: Sequence[object]) -> list[tuple[object, ...]]:
|
|
33
|
+
"""Run the read; if there is a scope, record SQL, time and rows returned."""
|
|
34
|
+
collector = current_collector()
|
|
35
|
+
if collector is None:
|
|
36
|
+
return self._inner.fetch_all(sql, params)
|
|
37
|
+
start = perf_counter()
|
|
38
|
+
rows = self._inner.fetch_all(sql, params)
|
|
39
|
+
collector.add(
|
|
40
|
+
sql=sql,
|
|
41
|
+
params=params,
|
|
42
|
+
duration_ms=(perf_counter() - start) * 1000,
|
|
43
|
+
rows=len(rows),
|
|
44
|
+
kind=QueryKind.SELECT,
|
|
45
|
+
started_at=start,
|
|
46
|
+
system=self._system,
|
|
47
|
+
)
|
|
48
|
+
return rows
|
|
49
|
+
|
|
50
|
+
def fetch_iter(
|
|
51
|
+
self, sql: str, params: Sequence[object], *, chunk: int = 1000
|
|
52
|
+
) -> Iterator[tuple[object, ...]]:
|
|
53
|
+
"""Yield the rows in streaming; if there is a scope, record SQL, time and rows CONSUMED.
|
|
54
|
+
|
|
55
|
+
It gets noted at the end, not at the start, and for two reasons. The first is that counting
|
|
56
|
+
beforehand would demand materialising the result, which is exactly what this path avoids.
|
|
57
|
+
The second is that in streaming the interesting figure IS what was consumed: a `break` at
|
|
58
|
+
the tenth row out of a million counts ten, and that is what the panel must show.
|
|
59
|
+
|
|
60
|
+
The measured time includes whatever the consumer takes between rows. It is unavoidable —the
|
|
61
|
+
cursor stays open that whole time— and it is also honest: that is how long the query lasts
|
|
62
|
+
from the point of view of the connection, which is the one being held.
|
|
63
|
+
"""
|
|
64
|
+
collector = current_collector()
|
|
65
|
+
if collector is None:
|
|
66
|
+
yield from self._inner.fetch_iter(sql, params, chunk=chunk)
|
|
67
|
+
return
|
|
68
|
+
start = perf_counter()
|
|
69
|
+
consumed = 0
|
|
70
|
+
try:
|
|
71
|
+
for row in self._inner.fetch_iter(sql, params, chunk=chunk):
|
|
72
|
+
consumed += 1
|
|
73
|
+
yield row
|
|
74
|
+
finally:
|
|
75
|
+
collector.add(
|
|
76
|
+
sql=sql,
|
|
77
|
+
params=params,
|
|
78
|
+
duration_ms=(perf_counter() - start) * 1000,
|
|
79
|
+
rows=consumed,
|
|
80
|
+
kind=QueryKind.SELECT,
|
|
81
|
+
started_at=start,
|
|
82
|
+
system=self._system,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def execute(self, sql: str, params: Sequence[object]) -> int:
|
|
86
|
+
"""Run the write; if there is a scope, record SQL, time and rows affected."""
|
|
87
|
+
collector = current_collector()
|
|
88
|
+
if collector is None:
|
|
89
|
+
return self._inner.execute(sql, params)
|
|
90
|
+
start = perf_counter()
|
|
91
|
+
affected = self._inner.execute(sql, params)
|
|
92
|
+
collector.add(
|
|
93
|
+
sql=sql,
|
|
94
|
+
params=params,
|
|
95
|
+
duration_ms=(perf_counter() - start) * 1000,
|
|
96
|
+
rows=affected,
|
|
97
|
+
kind=QueryKind.WRITE,
|
|
98
|
+
started_at=start,
|
|
99
|
+
system=self._system,
|
|
100
|
+
)
|
|
101
|
+
return affected
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def last_insert_id(self) -> int:
|
|
105
|
+
"""Forward to the wrapped driver (see the `SnakeDriver` Protocol)."""
|
|
106
|
+
return self._inner.last_insert_id
|
|
107
|
+
|
|
108
|
+
def commit(self) -> None:
|
|
109
|
+
"""Delegate the commit."""
|
|
110
|
+
self._inner.commit()
|
|
111
|
+
|
|
112
|
+
def rollback(self) -> None:
|
|
113
|
+
"""Delegate the rollback."""
|
|
114
|
+
self._inner.rollback()
|
|
115
|
+
|
|
116
|
+
def savepoint(self, name: str) -> None:
|
|
117
|
+
"""Delegate the savepoint."""
|
|
118
|
+
self._inner.savepoint(name)
|
|
119
|
+
|
|
120
|
+
def release_savepoint(self, name: str) -> None:
|
|
121
|
+
"""Delegate the savepoint release."""
|
|
122
|
+
self._inner.release_savepoint(name)
|
|
123
|
+
|
|
124
|
+
def rollback_to_savepoint(self, name: str) -> None:
|
|
125
|
+
"""Delegate the rollback to the savepoint."""
|
|
126
|
+
self._inner.rollback_to_savepoint(name)
|
|
127
|
+
|
|
128
|
+
def close(self) -> None:
|
|
129
|
+
"""Close the inner driver."""
|
|
130
|
+
self._inner.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AsyncCaptureDriver:
|
|
134
|
+
"""Asynchronous mirror of `CaptureDriver`, over the `AsyncDriver` Protocol."""
|
|
135
|
+
|
|
136
|
+
__slots__ = ("_inner", "_system")
|
|
137
|
+
|
|
138
|
+
def __init__(self, inner: AsyncDriver, *, system: str = "") -> None:
|
|
139
|
+
"""Same declaration as the synchronous one: the engine's `db.system.name`, given not guessed."""
|
|
140
|
+
self._inner = inner
|
|
141
|
+
self._system = system
|
|
142
|
+
|
|
143
|
+
async def fetch_all(
|
|
144
|
+
self, sql: str, params: Sequence[object]
|
|
145
|
+
) -> list[tuple[object, ...]]:
|
|
146
|
+
"""Run the async read; if there is a scope, record SQL, time and rows returned."""
|
|
147
|
+
collector = current_collector()
|
|
148
|
+
if collector is None:
|
|
149
|
+
return await self._inner.fetch_all(sql, params)
|
|
150
|
+
start = perf_counter()
|
|
151
|
+
rows = await self._inner.fetch_all(sql, params)
|
|
152
|
+
collector.add(
|
|
153
|
+
sql=sql,
|
|
154
|
+
params=params,
|
|
155
|
+
duration_ms=(perf_counter() - start) * 1000,
|
|
156
|
+
rows=len(rows),
|
|
157
|
+
kind=QueryKind.SELECT,
|
|
158
|
+
started_at=start,
|
|
159
|
+
system=self._system,
|
|
160
|
+
)
|
|
161
|
+
return rows
|
|
162
|
+
|
|
163
|
+
async def fetch_iter(
|
|
164
|
+
self, sql: str, params: Sequence[object], *, chunk: int = 1000
|
|
165
|
+
) -> AsyncIterator[tuple[object, ...]]:
|
|
166
|
+
"""Async mirror of `CaptureDriver.fetch_iter`: it records the rows CONSUMED, at the end."""
|
|
167
|
+
collector = current_collector()
|
|
168
|
+
if collector is None:
|
|
169
|
+
async for row in self._inner.fetch_iter(sql, params, chunk=chunk):
|
|
170
|
+
yield row
|
|
171
|
+
return
|
|
172
|
+
start = perf_counter()
|
|
173
|
+
consumed = 0
|
|
174
|
+
try:
|
|
175
|
+
async for row in self._inner.fetch_iter(sql, params, chunk=chunk):
|
|
176
|
+
consumed += 1
|
|
177
|
+
yield row
|
|
178
|
+
finally:
|
|
179
|
+
collector.add(
|
|
180
|
+
sql=sql,
|
|
181
|
+
params=params,
|
|
182
|
+
duration_ms=(perf_counter() - start) * 1000,
|
|
183
|
+
rows=consumed,
|
|
184
|
+
kind=QueryKind.SELECT,
|
|
185
|
+
started_at=start,
|
|
186
|
+
system=self._system,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def execute(self, sql: str, params: Sequence[object]) -> int:
|
|
190
|
+
"""Run the async write; if there is a scope, record SQL, time and rows affected."""
|
|
191
|
+
collector = current_collector()
|
|
192
|
+
if collector is None:
|
|
193
|
+
return await self._inner.execute(sql, params)
|
|
194
|
+
start = perf_counter()
|
|
195
|
+
affected = await self._inner.execute(sql, params)
|
|
196
|
+
collector.add(
|
|
197
|
+
sql=sql,
|
|
198
|
+
params=params,
|
|
199
|
+
duration_ms=(perf_counter() - start) * 1000,
|
|
200
|
+
rows=affected,
|
|
201
|
+
kind=QueryKind.WRITE,
|
|
202
|
+
started_at=start,
|
|
203
|
+
system=self._system,
|
|
204
|
+
)
|
|
205
|
+
return affected
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def last_insert_id(self) -> int:
|
|
209
|
+
"""Delegate the id of the last INSERT: capturing the SQL does not change what the engine wrote."""
|
|
210
|
+
return self._inner.last_insert_id
|
|
211
|
+
|
|
212
|
+
async def commit(self) -> None:
|
|
213
|
+
"""Delegate the commit."""
|
|
214
|
+
await self._inner.commit()
|
|
215
|
+
|
|
216
|
+
async def rollback(self) -> None:
|
|
217
|
+
"""Delegate the rollback."""
|
|
218
|
+
await self._inner.rollback()
|
|
219
|
+
|
|
220
|
+
async def savepoint(self, name: str) -> None:
|
|
221
|
+
"""Delegate the savepoint."""
|
|
222
|
+
await self._inner.savepoint(name)
|
|
223
|
+
|
|
224
|
+
async def release_savepoint(self, name: str) -> None:
|
|
225
|
+
"""Delegate the savepoint release."""
|
|
226
|
+
await self._inner.release_savepoint(name)
|
|
227
|
+
|
|
228
|
+
async def rollback_to_savepoint(self, name: str) -> None:
|
|
229
|
+
"""Delegate the rollback to the savepoint."""
|
|
230
|
+
await self._inner.rollback_to_savepoint(name)
|
|
231
|
+
|
|
232
|
+
async def close(self) -> None:
|
|
233
|
+
"""Close the inner driver."""
|
|
234
|
+
await self._inner.close()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Debug delivery channels and the parsing of `SNAKE_ORM_DEBUG`.
|
|
2
|
+
|
|
3
|
+
A channel is a DELIVERY mode of the same report; they compose as a `frozenset[SnakeDebugChannel]`. The environment string is only their serialisation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from enum import Enum, StrEnum
|
|
11
|
+
|
|
12
|
+
from snakeorm.core.exceptions import SnakeConfigError
|
|
13
|
+
|
|
14
|
+
DEBUG_ENV_KEY = "SNAKE_ORM_DEBUG"
|
|
15
|
+
"""Environment variable that turns the channels on, as a comma-separated list."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SnakeDebugChannel(StrEnum):
|
|
19
|
+
"""How the report is DELIVERED. Independent channels that combine freely; each contrib applies only the ones that make sense in its framework."""
|
|
20
|
+
|
|
21
|
+
SSR = "ssr" # an HTML panel injected into the response (Django/Flask)
|
|
22
|
+
ENVELOPE = "envelope" # a `snakeorm` block in the JSON + a text table — NO tooling (Postman)
|
|
23
|
+
TIMING = "timing" # a Server-Timing header — WITH tooling (the browser devtools)
|
|
24
|
+
SIDECAR = "sidecar" # a token + its own page at /__snake__/{token}
|
|
25
|
+
OTEL = "otel" # OpenTelemetry spans over OTLP — WITH tooling (Jaeger/Grafana)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ChannelAudience(Enum):
|
|
29
|
+
"""WHO ends up holding what a channel delivers. It is the axis that decides risk.
|
|
30
|
+
|
|
31
|
+
The set of risky channels used to be written out by hand as `{envelope, sidecar}`, under the
|
|
32
|
+
heading "the ones that come back to the client". That heading is not the rule it was standing
|
|
33
|
+
in for: `ssr` comes back to the client too, and `timing` also does while being harmless. `ssr`
|
|
34
|
+
was missing, and what is missing from a hand-written set reads as harmless without anyone
|
|
35
|
+
deciding so — which is how a production deploy came to hand its SQL to anonymous visitors.
|
|
36
|
+
|
|
37
|
+
The real question is two-part, and this enum asks both halves: who receives it, and does what
|
|
38
|
+
they receive contain the SQL.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
CLIENT_SQL = "client_sql"
|
|
42
|
+
"""The requester gets the statements themselves. Never in production."""
|
|
43
|
+
|
|
44
|
+
CLIENT_METRICS = "client_metrics"
|
|
45
|
+
"""The requester gets timings and counts, no SQL. Safe to leave on."""
|
|
46
|
+
|
|
47
|
+
OPERATOR = "operator"
|
|
48
|
+
"""It goes out SIDEWAYS, to infrastructure the operator already runs — never on the response."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
CHANNEL_AUDIENCE: Mapping[SnakeDebugChannel, ChannelAudience] = {
|
|
52
|
+
# The panel paints every statement with the placeholders ALREADY REPLACED by their values, plus
|
|
53
|
+
# the `file:line` of the application code that fired them. Strictly MORE than the envelope.
|
|
54
|
+
SnakeDebugChannel.SSR: ChannelAudience.CLIENT_SQL,
|
|
55
|
+
SnakeDebugChannel.ENVELOPE: ChannelAudience.CLIENT_SQL,
|
|
56
|
+
SnakeDebugChannel.SIDECAR: ChannelAudience.CLIENT_SQL,
|
|
57
|
+
# A `Server-Timing` header: how long, not what. It is the one delivery worth leaving on.
|
|
58
|
+
SnakeDebugChannel.TIMING: ChannelAudience.CLIENT_METRICS,
|
|
59
|
+
# Spans over OTLP, to the operator's own collector. Production is the ONLY place a tracing
|
|
60
|
+
# channel justifies existing, so filing it as risky would be declaring it dead.
|
|
61
|
+
SnakeDebugChannel.OTEL: ChannelAudience.OPERATOR,
|
|
62
|
+
}
|
|
63
|
+
"""Who receives each channel. Exhaustive by construction — see the guard right below."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def demand_every_channel_classified(
|
|
67
|
+
audience: Mapping[SnakeDebugChannel, ChannelAudience],
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Blows up if a channel of the enum has no audience declared. Runs AT IMPORT.
|
|
70
|
+
|
|
71
|
+
Same mechanism the capability catalogue uses (`SnakeCapabilities.__post_init__`) and for the
|
|
72
|
+
same reason: a channel that nobody classified would simply not be risky, which is precisely the
|
|
73
|
+
silence this module exists to prevent. Declaring the audience is not paperwork — it is the only
|
|
74
|
+
moment somebody is forced to think about who ends up with the SQL.
|
|
75
|
+
"""
|
|
76
|
+
missing = [
|
|
77
|
+
channel.value for channel in SnakeDebugChannel if channel not in audience
|
|
78
|
+
]
|
|
79
|
+
if missing:
|
|
80
|
+
raise SnakeConfigError(
|
|
81
|
+
f"These debug channels do not declare who receives them: {', '.join(missing)}. "
|
|
82
|
+
f"Every channel declares an audience: an unclassified one would read as harmless "
|
|
83
|
+
f"and would survive the production filter without anyone having decided so."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
demand_every_channel_classified(CHANNEL_AUDIENCE)
|
|
88
|
+
|
|
89
|
+
RISKY_CHANNELS: frozenset[SnakeDebugChannel] = frozenset(
|
|
90
|
+
channel
|
|
91
|
+
for channel, audience in CHANNEL_AUDIENCE.items()
|
|
92
|
+
if audience is ChannelAudience.CLIENT_SQL
|
|
93
|
+
)
|
|
94
|
+
"""Channels that hand the SQL to whoever asked: dropped in production even if configured.
|
|
95
|
+
|
|
96
|
+
DERIVED, never written out. The literal it replaces was missing `ssr` and nothing could tell."""
|
|
97
|
+
|
|
98
|
+
# Channels that are DECLARED but not built yet. Enabling one delivers nothing, so it WARNS instead
|
|
99
|
+
# of failing silently. It is EMPTY today: `otel` was the last one in it and now exports for real
|
|
100
|
+
# (`debug/otel/`). The mechanism stays for the next channel declared ahead of its exporter; what
|
|
101
|
+
# does not stay is a warning telling users that a working channel delivers nothing.
|
|
102
|
+
UNIMPLEMENTED_CHANNELS: frozenset[SnakeDebugChannel] = frozenset()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_channels(raw: str) -> frozenset[SnakeDebugChannel]:
|
|
106
|
+
"""Parse `"ssr,envelope"` into a `frozenset`. Empty → off; spaces/uppercase are forgiven.
|
|
107
|
+
|
|
108
|
+
An UNKNOWN name blows up naming it: a typo that silently left you without debug is precisely the failure being fought.
|
|
109
|
+
"""
|
|
110
|
+
channels: set[SnakeDebugChannel] = set()
|
|
111
|
+
for token in raw.split(","):
|
|
112
|
+
name = token.strip().lower()
|
|
113
|
+
if not name:
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
channels.add(SnakeDebugChannel(name))
|
|
117
|
+
except ValueError:
|
|
118
|
+
valid = ", ".join(channel.value for channel in SnakeDebugChannel)
|
|
119
|
+
raise SnakeConfigError(
|
|
120
|
+
f"Unknown debug channel: '{name}'. "
|
|
121
|
+
f"{DEBUG_ENV_KEY} accepts these channels: {valid}."
|
|
122
|
+
) from None
|
|
123
|
+
return frozenset(channels)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def channels_from_env(
|
|
127
|
+
environ: Mapping[str, str] | None = None,
|
|
128
|
+
) -> frozenset[SnakeDebugChannel]:
|
|
129
|
+
"""Read `SNAKE_ORM_DEBUG` from the environment (or the given map, for tests) and parse it into channels."""
|
|
130
|
+
env = os.environ if environ is None else environ
|
|
131
|
+
return parse_channels(env.get(DEBUG_ENV_KEY, ""))
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Per-scope collector where the `QueryRecord`s of a unit of work land.
|
|
2
|
+
|
|
3
|
+
Built on `ContextVar` (neither global nor thread-local): it works in sync and async and does not get contaminated between concurrent requests that share a thread.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from time import perf_counter
|
|
13
|
+
from typing import ParamSpec, TypeVar
|
|
14
|
+
|
|
15
|
+
from snakeorm.debug.origin import capture_origin
|
|
16
|
+
from snakeorm.debug.record import QueryKind, QueryRecord
|
|
17
|
+
from snakeorm.debug.report import DebugReport
|
|
18
|
+
|
|
19
|
+
P = ParamSpec("P")
|
|
20
|
+
R = TypeVar("R")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DebugCollector:
|
|
24
|
+
"""Accumulate the records of a scope and freeze them into a `DebugReport` on demand.
|
|
25
|
+
|
|
26
|
+
It also holds the scope's MAPPING total. It lives here and not on the report because the
|
|
27
|
+
mapping happens INSIDE the scope, exactly like the queries do — the wall clock is the odd one
|
|
28
|
+
out, measured by the middleware around the scope and stamped afterwards.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
__slots__ = ("_mapping_ms", "_records")
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._records: list[QueryRecord] = []
|
|
35
|
+
self._mapping_ms = 0.0
|
|
36
|
+
|
|
37
|
+
def add(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
sql: str,
|
|
41
|
+
params: Sequence[object],
|
|
42
|
+
duration_ms: float,
|
|
43
|
+
rows: int,
|
|
44
|
+
kind: QueryKind,
|
|
45
|
+
started_at: float = 0.0,
|
|
46
|
+
system: str = "",
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Record a statement, numbering it by arrival order (1-based) and noting its ORIGIN.
|
|
49
|
+
|
|
50
|
+
The origin (file/line/function of the user code that fired the query) is resolved HERE,
|
|
51
|
+
while the caller's stack is still alive: one frame up is the capture driver and, above
|
|
52
|
+
that, the app's code.
|
|
53
|
+
|
|
54
|
+
`started_at` is the monotonic instant the driver measured FROM; `system` the engine it
|
|
55
|
+
declared. Both are passed through untouched: this collector computes neither.
|
|
56
|
+
"""
|
|
57
|
+
self._records.append(
|
|
58
|
+
QueryRecord(
|
|
59
|
+
n=len(self._records) + 1,
|
|
60
|
+
sql=sql,
|
|
61
|
+
params=tuple(params),
|
|
62
|
+
duration_ms=duration_ms,
|
|
63
|
+
rows=rows,
|
|
64
|
+
kind=kind,
|
|
65
|
+
origin=capture_origin(),
|
|
66
|
+
started_at=started_at,
|
|
67
|
+
system=system,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def add_mapping(self, duration_ms: float) -> None:
|
|
72
|
+
"""Add a BATCH of hydration to the scope's mapping total (see `timed_mapping`)."""
|
|
73
|
+
self._mapping_ms += duration_ms
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def mapping_ms(self) -> float:
|
|
77
|
+
"""Time spent turning rows into objects in this scope. Zero is MEASURED, not unknown."""
|
|
78
|
+
return self._mapping_ms
|
|
79
|
+
|
|
80
|
+
def report(self) -> DebugReport:
|
|
81
|
+
"""The snapshot of what has been captured so far."""
|
|
82
|
+
return DebugReport.from_records(self._records, mapping_ms=self._mapping_ms)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# The active collector of the current scope. `None` = nothing is being captured (passthrough).
|
|
86
|
+
_active: ContextVar[DebugCollector | None] = ContextVar(
|
|
87
|
+
"snakeorm_debug_collector", default=None
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@contextmanager
|
|
92
|
+
def capture_queries() -> Iterator[DebugCollector]:
|
|
93
|
+
"""Open a capture scope and return its collector; on the way out it restores the previous one.
|
|
94
|
+
|
|
95
|
+
A synchronous context manager on purpose and still valid for async: setting/restoring a `ContextVar` needs no `await` and the value travels with the task.
|
|
96
|
+
"""
|
|
97
|
+
collector = DebugCollector()
|
|
98
|
+
token = _active.set(collector)
|
|
99
|
+
try:
|
|
100
|
+
yield collector
|
|
101
|
+
finally:
|
|
102
|
+
_active.reset(token)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def current_collector() -> DebugCollector | None:
|
|
106
|
+
"""The collector of the active scope, or `None` if nothing is being captured."""
|
|
107
|
+
return _active.get()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def timed_mapping(function: Callable[P, R]) -> Callable[P, R]:
|
|
111
|
+
"""Mark a callable that turns an ALREADY FETCHED result set into objects, and time it.
|
|
112
|
+
|
|
113
|
+
Decorate only a body that has its rows in hand and does nothing but build objects out of them.
|
|
114
|
+
Two things must stay outside or the number lies: I/O of any kind, and resolving a relationship
|
|
115
|
+
that fires its own query. Get that wrong and `DB + MAPPING + APP` stops adding up to the
|
|
116
|
+
request, which is the invariant the panel's cards promise.
|
|
117
|
+
|
|
118
|
+
ONE lookup per result set, never per row — the same argument `_hydrate_with_plan` makes about
|
|
119
|
+
the plan. Measured on the hydration loop with the debug OFF: a `ContextVar` read inside the row
|
|
120
|
+
loop costs +52 ns/row (+4.3% on a five-column row), while this gate costs ~130 ns per RESULT
|
|
121
|
+
SET however many rows it holds. A decorator and not a `with` block for the same reason: an
|
|
122
|
+
equivalent `@contextmanager` measured ~1000 ns per result set, which is +80% on the one-row
|
|
123
|
+
read `get()` and `first()` do.
|
|
124
|
+
|
|
125
|
+
Do NOT nest two of these: the inner one would be counted twice.
|
|
126
|
+
|
|
127
|
+
STREAMING (`iterate`) is deliberately NOT marked: the capture driver's stopwatch around
|
|
128
|
+
`fetch_iter` spans whatever the consumer does BETWEEN rows, so hydration during a stream is
|
|
129
|
+
already inside `db_ms` and timing it again would count it twice. A streaming read reports a
|
|
130
|
+
MAPPING that leaves that share out; the sum stays true.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
@wraps(function)
|
|
134
|
+
def timed(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
135
|
+
collector = current_collector()
|
|
136
|
+
if collector is None:
|
|
137
|
+
return function(*args, **kwargs)
|
|
138
|
+
start = perf_counter()
|
|
139
|
+
try:
|
|
140
|
+
return function(*args, **kwargs)
|
|
141
|
+
finally:
|
|
142
|
+
collector.add_mapping((perf_counter() - start) * 1000)
|
|
143
|
+
|
|
144
|
+
return timed
|