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,393 @@
1
+ """The HYBRID shape of a trace: one root span, one CLIENT child per query, aggregates on both.
2
+
3
+ Three shapes were compared in Jaeger's own interface, not in JSON, and this one won:
4
+
5
+ - A single BLOB span is invisible to the search. One operation, nothing to group by, and the whole
6
+ report inside an attribute — the panel of SnakeORM stuffed into Jaeger, paying for Jaeger without
7
+ using it.
8
+ - A purely MAPPED tree loses `warnings`, `index_hints` and `summary`: the convention has no
9
+ equivalent for them, so a strict mapping simply drops them.
10
+ - The HYBRID keeps both halves. The children make `db.collection.name` and `code.line.number`
11
+ groupable, which is how Trace Statistics re-derives the ORM's own `(sql, origin)` grouping AND
12
+ adds the share of the cost, which the panel does not give. The aggregates ride on the root as
13
+ attributes —searchable ACROSS traces, which is what answers "show me every request with an
14
+ N+1"— and as events, so they get a row on the timeline.
15
+
16
+ Nothing here executes or sends: it is a pure mapping from a `DebugReport` to spans.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from dataclasses import dataclass
23
+ from enum import IntEnum
24
+ from time import perf_counter, time_ns
25
+ from typing import Protocol
26
+
27
+ from snakeorm.debug.otel.context import TraceContext
28
+ from snakeorm.debug.otel.summary import summarise
29
+ from snakeorm.debug.report import DebugReport, RequestInfo
30
+ from snakeorm.debug.record import QueryRecord
31
+
32
+ AttributeValue = str | int | float | bool | tuple[str, ...]
33
+ """What an OTLP attribute can hold. Deliberately narrow: no nesting, no `Any`."""
34
+
35
+ # --- Semantic conventions, v1.44 ------------------------------------------------------------------
36
+ # Written as literals and NOT imported from `opentelemetry.semconv`: the channel has to work with the
37
+ # API alone, and with nothing installed at all. `src/test/debug/otel/test_semconv_names.py` checks
38
+ # them against the installed package so the literals cannot go stale in silence.
39
+ DB_SYSTEM_NAME = "db.system.name"
40
+ DB_QUERY_TEXT = "db.query.text"
41
+ DB_QUERY_SUMMARY = "db.query.summary"
42
+ DB_COLLECTION_NAME = "db.collection.name"
43
+ DB_NAMESPACE = "db.namespace"
44
+ DB_OPERATION_NAME = "db.operation.name"
45
+ DB_RESPONSE_RETURNED_ROWS = "db.response.returned_rows"
46
+ DB_QUERY_PARAMETER_PREFIX = "db.query.parameter."
47
+ CODE_FILE_PATH = "code.file.path"
48
+ CODE_LINE_NUMBER = "code.line.number"
49
+ CODE_FUNCTION_NAME = "code.function.name"
50
+ HTTP_REQUEST_METHOD = "http.request.method"
51
+ HTTP_RESPONSE_STATUS_CODE = "http.response.status_code"
52
+ URL_PATH = "url.path"
53
+
54
+ # --- What the convention has no name for, under our own prefix ------------------------------------
55
+ SNAKEORM_QUERY_COUNT = "snakeorm.query_count"
56
+ SNAKEORM_DB_MS = "snakeorm.db_ms"
57
+ SNAKEORM_WALL_MS = "snakeorm.wall_ms"
58
+ # Rides next to `app_ms` because it is the half that was taken OUT of it: `app_ms` is
59
+ # `wall - db - mapping`, so a root that omits this one does not add up.
60
+ SNAKEORM_MAPPING_MS = "snakeorm.mapping_ms"
61
+ SNAKEORM_APP_MS = "snakeorm.app_ms"
62
+ SNAKEORM_DUPLICATE_GROUPS = "snakeorm.duplicate_groups"
63
+ SNAKEORM_WORST_REPEAT_COUNT = "snakeorm.worst_repeat_count"
64
+ SNAKEORM_HAS_N_PLUS_ONE = "snakeorm.has_n_plus_one"
65
+ SNAKEORM_SUMMARY = "snakeorm.summary"
66
+ SNAKEORM_WARNINGS = "snakeorm.warnings"
67
+ SNAKEORM_WARNING = "snakeorm.warning"
68
+ SNAKEORM_INDEX_HINTS = "snakeorm.index_hints"
69
+ SNAKEORM_INDEX_HINT = "snakeorm.index_hint"
70
+ SNAKEORM_COLUMN = "snakeorm.column"
71
+ SNAKEORM_WORST_MS = "snakeorm.worst_ms"
72
+ SNAKEORM_REPEAT_COUNT = "snakeorm.repeat_count"
73
+ SNAKEORM_N = "snakeorm.n"
74
+ SNAKEORM_KIND = "snakeorm.kind"
75
+
76
+ ROOT_SPAN_NAME = "snakeorm"
77
+ """The root's name when there is no request to name it after (a script, a CLI command, a test)."""
78
+
79
+
80
+ class SpanKind(IntEnum):
81
+ """OTLP's span kinds, by their protocol numbers (the payload spells them as integers)."""
82
+
83
+ INTERNAL = 1
84
+ SERVER = 2
85
+ CLIENT = 3
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class SpanEvent:
90
+ """A point in time inside a span: what gives an aggregate a ROW on Jaeger's timeline."""
91
+
92
+ name: str
93
+ time_unix_nano: int
94
+ attributes: tuple[tuple[str, AttributeValue], ...] = ()
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class SnakeSpan:
99
+ """One span, already resolved: ids in hex, instants in Unix nanoseconds, attributes flattened.
100
+
101
+ `parent_span_id` is an empty string when there is no parent; the payload then OMITS the field,
102
+ because an empty parent and no parent are different things to a collector.
103
+ """
104
+
105
+ trace_id: str
106
+ span_id: str
107
+ parent_span_id: str
108
+ name: str
109
+ kind: SpanKind
110
+ start_unix_nano: int
111
+ end_unix_nano: int
112
+ attributes: tuple[tuple[str, AttributeValue], ...]
113
+ events: tuple[SpanEvent, ...] = ()
114
+
115
+
116
+ class IdSource(Protocol):
117
+ """Where the ids come from. A Protocol so a test can assert the tree instead of randomness."""
118
+
119
+ def trace_id(self) -> str:
120
+ """A fresh 32-character hex trace id."""
121
+ ...
122
+
123
+ def span_id(self) -> str:
124
+ """A fresh 16-character hex span id."""
125
+ ...
126
+
127
+
128
+ class RandomIds:
129
+ """Ids from `os.urandom`, which is what an id needs to be: unique, not meaningful."""
130
+
131
+ __slots__ = ()
132
+
133
+ def trace_id(self) -> str:
134
+ """16 random bytes in hex, the width OTLP gives a trace."""
135
+ return os.urandom(16).hex()
136
+
137
+ def span_id(self) -> str:
138
+ """8 random bytes in hex, the width OTLP gives a span."""
139
+ return os.urandom(8).hex()
140
+
141
+
142
+ def monotonic_epoch_ns() -> int:
143
+ """The Unix nanosecond a `perf_counter()` reading of `0.0` would name.
144
+
145
+ `perf_counter` is monotonic and has no origin, so a reading means nothing on its own; anchoring
146
+ it to the wall clock ONCE per export turns every reading into an absolute instant while keeping
147
+ the ORDER and the DISTANCES the monotonic clock measured. That is exactly the trade a timeline
148
+ wants: the wall clock can step (NTP), the differences between queries cannot.
149
+ """
150
+ return time_ns() - int(perf_counter() * 1_000_000_000)
151
+
152
+
153
+ def spans_from_report(
154
+ report: DebugReport,
155
+ *,
156
+ parent: TraceContext | None = None,
157
+ ids: IdSource | None = None,
158
+ epoch_ns: int | None = None,
159
+ parameter_keys: frozenset[str] = frozenset(),
160
+ ) -> tuple[SnakeSpan, ...]:
161
+ """Map a report to its spans: the root first, then one CLIENT child per statement.
162
+
163
+ `parent` is the application's active span, when it has one: the root then hangs off it as an
164
+ INTERNAL section instead of claiming to be the request's server span. `parameter_keys` names
165
+ the parameter positions to send, and is empty by default — the convention makes parameters
166
+ opt-in and there is no way to ask for them in bulk.
167
+ """
168
+ source = RandomIds() if ids is None else ids
169
+ anchor = monotonic_epoch_ns() if epoch_ns is None else epoch_ns
170
+ trace_id = source.trace_id() if parent is None else parent.trace_id
171
+ root_id = source.span_id()
172
+
173
+ children = tuple(
174
+ _child_span(
175
+ record,
176
+ trace_id=trace_id,
177
+ parent_span_id=root_id,
178
+ span_id=source.span_id(),
179
+ epoch_ns=anchor,
180
+ parameter_keys=parameter_keys,
181
+ )
182
+ for record in report.records
183
+ )
184
+ root = _root_span(
185
+ report,
186
+ trace_id=trace_id,
187
+ span_id=root_id,
188
+ parent=parent,
189
+ children=children,
190
+ epoch_ns=anchor,
191
+ )
192
+ return (root, *children)
193
+
194
+
195
+ def _child_span(
196
+ record: QueryRecord,
197
+ *,
198
+ trace_id: str,
199
+ parent_span_id: str,
200
+ span_id: str,
201
+ epoch_ns: int,
202
+ parameter_keys: frozenset[str],
203
+ ) -> SnakeSpan:
204
+ """One statement as a CLIENT span, named after its summary and carrying the `db.*` attributes."""
205
+ summary = summarise(record.sql)
206
+ start = epoch_ns + int(record.started_at * 1_000_000_000)
207
+ attributes: list[tuple[str, AttributeValue]] = [
208
+ (DB_QUERY_TEXT, record.sql),
209
+ (DB_QUERY_SUMMARY, summary.text),
210
+ (DB_OPERATION_NAME, summary.operation),
211
+ (DB_RESPONSE_RETURNED_ROWS, record.rows),
212
+ (SNAKEORM_N, record.n),
213
+ (SNAKEORM_KIND, record.kind.value),
214
+ ]
215
+ if summary.collection:
216
+ attributes.append((DB_COLLECTION_NAME, summary.collection))
217
+ if summary.namespace:
218
+ attributes.append((DB_NAMESPACE, summary.namespace))
219
+ if record.system:
220
+ attributes.append((DB_SYSTEM_NAME, record.system))
221
+ if record.origin is not None:
222
+ attributes.append((CODE_FILE_PATH, record.origin.file))
223
+ attributes.append((CODE_LINE_NUMBER, record.origin.line))
224
+ attributes.append((CODE_FUNCTION_NAME, record.origin.function))
225
+ attributes.extend(_parameters(record, parameter_keys))
226
+ return SnakeSpan(
227
+ trace_id=trace_id,
228
+ span_id=span_id,
229
+ parent_span_id=parent_span_id,
230
+ name=summary.text or ROOT_SPAN_NAME,
231
+ kind=SpanKind.CLIENT,
232
+ start_unix_nano=start,
233
+ end_unix_nano=start + int(record.duration_ms * 1_000_000),
234
+ attributes=tuple(attributes),
235
+ )
236
+
237
+
238
+ def _parameters(
239
+ record: QueryRecord, parameter_keys: frozenset[str]
240
+ ) -> list[tuple[str, AttributeValue]]:
241
+ """The parameters explicitly ASKED for, one key at a time. Empty by default, which is the point.
242
+
243
+ The convention collects the PARAMETRISED text by default "because parametrising is a strong
244
+ signal from the user that anything sensitive is in the values", and makes the values themselves
245
+ opt-in. SnakeORM never interpolates, so the text is safe by construction and the values are the
246
+ only thing that could carry user data — which is why they are named one by one and never in
247
+ bulk. The key of a positional parameter is its 0-based index, as the convention says.
248
+ """
249
+ if not parameter_keys:
250
+ return []
251
+ return [
252
+ (f"{DB_QUERY_PARAMETER_PREFIX}{index}", str(value))
253
+ for index, value in enumerate(record.params)
254
+ if str(index) in parameter_keys
255
+ ]
256
+
257
+
258
+ def _root_span(
259
+ report: DebugReport,
260
+ *,
261
+ trace_id: str,
262
+ span_id: str,
263
+ parent: TraceContext | None,
264
+ children: tuple[SnakeSpan, ...],
265
+ epoch_ns: int,
266
+ ) -> SnakeSpan:
267
+ """The span of the whole unit of work, with the aggregates as attributes AND as events."""
268
+ start, end = _root_window(report, children, epoch_ns)
269
+ duplicates = report.duplicates()
270
+ attributes: list[tuple[str, AttributeValue]] = [
271
+ (SNAKEORM_SUMMARY, report.summary),
272
+ (SNAKEORM_QUERY_COUNT, report.count),
273
+ (SNAKEORM_DB_MS, round(report.total_ms, 3)),
274
+ (SNAKEORM_DUPLICATE_GROUPS, len(duplicates)),
275
+ (SNAKEORM_HAS_N_PLUS_ONE, bool(duplicates)),
276
+ (
277
+ SNAKEORM_WORST_REPEAT_COUNT,
278
+ max((group.count for group in duplicates), default=0),
279
+ ),
280
+ ]
281
+ if report.wall_ms is not None:
282
+ attributes.append((SNAKEORM_WALL_MS, round(report.wall_ms, 3)))
283
+ if report.mapping_ms is not None:
284
+ attributes.append((SNAKEORM_MAPPING_MS, round(report.mapping_ms, 3)))
285
+ if report.app_ms is not None:
286
+ attributes.append((SNAKEORM_APP_MS, round(report.app_ms, 3)))
287
+ if report.warnings:
288
+ attributes.append((SNAKEORM_WARNINGS, report.warnings))
289
+ if report.index_hints:
290
+ attributes.append((SNAKEORM_INDEX_HINTS, _hint_texts(report)))
291
+ attributes.extend(_request_attributes(report.request))
292
+ return SnakeSpan(
293
+ trace_id=trace_id,
294
+ span_id=span_id,
295
+ parent_span_id="" if parent is None else parent.span_id,
296
+ name=_root_name(report.request),
297
+ kind=SpanKind.SERVER if parent is None else SpanKind.INTERNAL,
298
+ start_unix_nano=start,
299
+ end_unix_nano=end,
300
+ attributes=tuple(attributes),
301
+ events=_root_events(report, at=end),
302
+ )
303
+
304
+
305
+ def _root_window(
306
+ report: DebugReport, children: tuple[SnakeSpan, ...], epoch_ns: int
307
+ ) -> tuple[int, int]:
308
+ """The root's start and end, wide enough to CONTAIN every child.
309
+
310
+ Two clocks meet here and they must not be allowed to disagree visibly. The request's instant
311
+ (`RequestInfo.at`) comes off the wall clock; the children come off the monotonic one anchored to
312
+ it. In a live process the two are taken microseconds apart and the containment is free — the
313
+ adapters read them on the same line. Widening to the children's extremes costs nothing there and
314
+ guarantees a tree Jaeger can draw when it is not, which is better than a child that renders
315
+ outside its parent.
316
+ """
317
+ starts = [child.start_unix_nano for child in children]
318
+ ends = [child.end_unix_nano for child in children]
319
+ if report.request is None:
320
+ opened = min(starts, default=epoch_ns + int(perf_counter() * 1_000_000_000))
321
+ else:
322
+ opened = int(report.request.at.timestamp() * 1_000_000_000)
323
+ closed = opened + int((report.wall_ms or 0.0) * 1_000_000)
324
+ return min([opened, *starts]), max([closed, *ends])
325
+
326
+
327
+ def _root_name(request: RequestInfo | None) -> str:
328
+ """`GET /orders` when there is a request; the package's name when there is not."""
329
+ if request is None:
330
+ return ROOT_SPAN_NAME
331
+ return f"{request.method} {request.path}".strip()
332
+
333
+
334
+ def _request_attributes(
335
+ request: RequestInfo | None,
336
+ ) -> list[tuple[str, AttributeValue]]:
337
+ """The HTTP half of the root, under the convention's names. Empty for a standalone report."""
338
+ if request is None:
339
+ return []
340
+ return [
341
+ (HTTP_REQUEST_METHOD, request.method),
342
+ (URL_PATH, request.path),
343
+ (HTTP_RESPONSE_STATUS_CODE, request.status),
344
+ ]
345
+
346
+
347
+ def _hint_texts(report: DebugReport) -> tuple[str, ...]:
348
+ """The advisor's suggestions in the SAME wording the envelope uses: one message, one spelling."""
349
+ return tuple(
350
+ f"{table}.{column} ({ms:.0f}ms)" for table, column, ms in report.index_hints
351
+ )
352
+
353
+
354
+ def _root_events(report: DebugReport, *, at: int) -> tuple[SpanEvent, ...]:
355
+ """Summary, warnings and index hints as events, so each gets a row on the timeline.
356
+
357
+ They are stamped at the END of the span because that is when they exist: the duplicates are only
358
+ known once every statement has run.
359
+ """
360
+ events = [
361
+ SpanEvent(
362
+ name=SNAKEORM_SUMMARY,
363
+ time_unix_nano=at,
364
+ attributes=((SNAKEORM_SUMMARY, report.summary),),
365
+ )
366
+ ]
367
+ for group, warning in zip(report.duplicates(), report.warnings, strict=True):
368
+ attributes: list[tuple[str, AttributeValue]] = [
369
+ (SNAKEORM_WARNING, warning),
370
+ (SNAKEORM_REPEAT_COUNT, group.count),
371
+ (DB_QUERY_TEXT, group.sql),
372
+ ]
373
+ if group.origin is not None:
374
+ attributes.append((CODE_FILE_PATH, group.origin.file))
375
+ attributes.append((CODE_LINE_NUMBER, group.origin.line))
376
+ events.append(
377
+ SpanEvent(
378
+ name=SNAKEORM_WARNING, time_unix_nano=at, attributes=tuple(attributes)
379
+ )
380
+ )
381
+ for table, column, worst_ms in report.index_hints:
382
+ events.append(
383
+ SpanEvent(
384
+ name=SNAKEORM_INDEX_HINT,
385
+ time_unix_nano=at,
386
+ attributes=(
387
+ (DB_COLLECTION_NAME, table),
388
+ (SNAKEORM_COLUMN, column),
389
+ (SNAKEORM_WORST_MS, round(worst_ms, 3)),
390
+ ),
391
+ )
392
+ )
393
+ return tuple(events)
@@ -0,0 +1,59 @@
1
+ """`db.query.summary`: the short shape of a statement, which is what NAMES the span.
2
+
3
+ The convention is explicit that the span name is the summary and NOT the query text. Jaeger gives
4
+ `db.query.text` no special formatting, so a span named after the whole SQL is an unreadable row; a
5
+ span named `SELECT orders` reads at a glance and groups with its five hundred siblings.
6
+
7
+ It is a HEURISTIC over already-emitted SQL, not a parser, and it says so: the verb is the first word
8
+ and the collection is the first table the statement names. Anything it cannot read comes back empty,
9
+ never wrong.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass
16
+
17
+ # The first word: the verb (SELECT, INSERT, CREATE...). Anything else gives no operation.
18
+ _OPERATION = re.compile(r"^\s*([A-Za-z]+)")
19
+
20
+ # The first table the statement names, however the engine quotes it (`"x"`, `` `x` ``, bare), and
21
+ # QUALIFIED or not. The optional first group is the schema: SnakeORM emits `"public"."users"` against
22
+ # Postgres, so a pattern that stopped at the first identifier named the SCHEMA on every single span.
23
+ _COLLECTION = re.compile(
24
+ r"\b(?:FROM|INTO|UPDATE|TABLE|VIEW|INDEX)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?"
25
+ r'(?:["`]?([A-Za-z_]\w*)["`]?\.)?["`]?([A-Za-z_]\w*)',
26
+ re.IGNORECASE,
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class QuerySummary:
32
+ """A statement's `db.operation.name`, `db.collection.name` and `db.namespace`, plus the summary.
33
+
34
+ The namespace is the SCHEMA half of a qualified name (`"public"."users"`). It is kept apart
35
+ because the convention keeps it apart, and because folding it in made every span of a real
36
+ request read `SELECT public`.
37
+ """
38
+
39
+ operation: str
40
+ collection: str
41
+ namespace: str = ""
42
+
43
+ @property
44
+ def text(self) -> str:
45
+ """`db.query.summary`: `SELECT orders`, or the verb alone when no table is named."""
46
+ if not self.collection:
47
+ return self.operation
48
+ return f"{self.operation} {self.collection}"
49
+
50
+
51
+ def summarise(sql: str) -> QuerySummary:
52
+ """The verb and the table of an emitted statement. Empty pieces when it cannot tell."""
53
+ operation = _OPERATION.match(sql)
54
+ collection = _COLLECTION.search(sql)
55
+ return QuerySummary(
56
+ operation="" if operation is None else operation.group(1).upper(),
57
+ collection="" if collection is None else collection.group(2),
58
+ namespace="" if collection is None else (collection.group(1) or ""),
59
+ )
@@ -0,0 +1,54 @@
1
+ """A `QueryRecord`: the atom of the debug, one captured SQL statement. Immutable (`frozen`): a measurement already taken is not touched."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+
9
+ class QueryKind(StrEnum):
10
+ """Which kind of statement it was, so reads can be told from writes in the panel."""
11
+
12
+ SELECT = "select" # came from fetch_all (SELECT / RETURNING)
13
+ WRITE = "write" # came from execute (INSERT / UPDATE / DELETE / DDL)
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class QueryOrigin:
18
+ """Where the query came from: the USER code frame that fired it (file, line, function).
19
+
20
+ It is the "who" that Django Debug Toolbar gives: with the origin you find the code guilty of
21
+ the extra calls (an N+1 or a duplicate), instead of merely seeing that they exist.
22
+ """
23
+
24
+ file: str
25
+ line: int
26
+ function: str
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class QueryRecord:
31
+ """One captured statement: order, SQL, params, duration, rows and ORIGIN.
32
+
33
+ `n` is the (1-based) sequence within the scope, so they can be listed in the ORDER they ran (that is how you read an N+1); `rows` are rows returned (SELECT) or affected (a write); `origin` is the user frame that fired it (or `None` if it could not be resolved).
34
+
35
+ `started_at` is a `perf_counter()` reading, monotonic and meaningless on its own: only
36
+ differences between two of them mean anything. It is kept because a duration cannot place a
37
+ query on a TIMELINE — a span needs a start and an end, and synthesising the start makes the
38
+ timeline lie. It costs nothing to keep: the capture driver already reads that clock to measure
39
+ the duration, so this is the first half of a subtraction that was happening anyway.
40
+
41
+ `system` is what OpenTelemetry calls the engine (`db.system.name`: `postgresql`, `mysql`,
42
+ `mariadb`, `sqlite`). It is DECLARED by whoever wires the capture driver, never guessed from the
43
+ SQL; empty means undeclared, and the exporter then omits the attribute instead of inventing one.
44
+ """
45
+
46
+ n: int
47
+ sql: str
48
+ params: tuple[object, ...]
49
+ duration_ms: float
50
+ rows: int
51
+ kind: QueryKind
52
+ origin: QueryOrigin | None = None
53
+ started_at: float = 0.0
54
+ system: str = ""