pyoq-sql 1.0.2__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 (267) hide show
  1. pyoq/__init__.py +10 -0
  2. pyoq/__main__.py +5 -0
  3. pyoq/_native.pyi +5 -0
  4. pyoq/cli/__init__.py +5 -0
  5. pyoq/cli/commands.py +270 -0
  6. pyoq/cli/defaults.py +98 -0
  7. pyoq/cli/services.py +97 -0
  8. pyoq/config/__init__.py +31 -0
  9. pyoq/config/connection.py +161 -0
  10. pyoq/config/loader.py +289 -0
  11. pyoq/config/models.py +245 -0
  12. pyoq/config/values.py +142 -0
  13. pyoq/descriptors.py +165 -0
  14. pyoq/diagnostics/__init__.py +68 -0
  15. pyoq/diagnostics/budget.py +136 -0
  16. pyoq/diagnostics/events.py +137 -0
  17. pyoq/diagnostics/fingerprint.py +267 -0
  18. pyoq/diagnostics/instrumented.py +237 -0
  19. pyoq/diagnostics/metrics.py +61 -0
  20. pyoq/diagnostics/observation.py +227 -0
  21. pyoq/diagnostics/scoped.py +103 -0
  22. pyoq/django/__init__.py +15 -0
  23. pyoq/django/apps.py +17 -0
  24. pyoq/django/execution.py +317 -0
  25. pyoq/django/generation.py +59 -0
  26. pyoq/django/management/__init__.py +0 -0
  27. pyoq/django/management/commands/__init__.py +0 -0
  28. pyoq/django/management/commands/makemigrations.py +53 -0
  29. pyoq/django/management/commands/pyoq_codegen.py +75 -0
  30. pyoq/django/parameters.py +101 -0
  31. pyoq/django/schema.py +379 -0
  32. pyoq/django/settings.py +87 -0
  33. pyoq/django/timeouts.py +105 -0
  34. pyoq/dsl/__init__.py +64 -0
  35. pyoq/dsl/aio/__init__.py +31 -0
  36. pyoq/dsl/aio/context.py +295 -0
  37. pyoq/dsl/aio/queries.py +335 -0
  38. pyoq/dsl/aio/writes.py +368 -0
  39. pyoq/dsl/context.py +326 -0
  40. pyoq/dsl/entry.py +37 -0
  41. pyoq/dsl/labels.py +36 -0
  42. pyoq/dsl/queries.py +339 -0
  43. pyoq/dsl/result.py +164 -0
  44. pyoq/dsl/writes.py +360 -0
  45. pyoq/errors.py +317 -0
  46. pyoq/fastapi/__init__.py +32 -0
  47. pyoq/fastapi/dependencies.py +167 -0
  48. pyoq/fastapi/lifespan.py +119 -0
  49. pyoq/fetching/__init__.py +55 -0
  50. pyoq/fetching/collections.py +136 -0
  51. pyoq/fetching/execution.py +587 -0
  52. pyoq/fetching/joined.py +79 -0
  53. pyoq/fetching/nesting.py +183 -0
  54. pyoq/fetching/plans.py +541 -0
  55. pyoq/fetching/select_in.py +149 -0
  56. pyoq/fetching/tables.py +110 -0
  57. pyoq/generation/__init__.py +54 -0
  58. pyoq/generation/cleanup.py +44 -0
  59. pyoq/generation/contracts.py +248 -0
  60. pyoq/generation/drift.py +169 -0
  61. pyoq/generation/lock.py +33 -0
  62. pyoq/generation/manifest.py +114 -0
  63. pyoq/generation/model.py +1001 -0
  64. pyoq/generation/pipeline.py +119 -0
  65. pyoq/generation/rendering/__init__.py +5 -0
  66. pyoq/generation/rendering/domains.py +51 -0
  67. pyoq/generation/rendering/enums.py +29 -0
  68. pyoq/generation/rendering/exports.py +70 -0
  69. pyoq/generation/rendering/imports.py +63 -0
  70. pyoq/generation/rendering/package.py +56 -0
  71. pyoq/generation/rendering/relations.py +133 -0
  72. pyoq/generation/rendering/routines.py +396 -0
  73. pyoq/generation/rendering/rows.py +79 -0
  74. pyoq/generation/rendering/source.py +121 -0
  75. pyoq/generation/rendering/tables.py +300 -0
  76. pyoq/generation/rendering/writes.py +514 -0
  77. pyoq/generation/validation.py +27 -0
  78. pyoq/generation/writer.py +184 -0
  79. pyoq/hydration/__init__.py +24 -0
  80. pyoq/hydration/engine.py +155 -0
  81. pyoq/hydration/identity.py +194 -0
  82. pyoq/hydration/plan.py +116 -0
  83. pyoq/migrations/__init__.py +9 -0
  84. pyoq/migrations/alembic.py +106 -0
  85. pyoq/migrations/hooks.py +75 -0
  86. pyoq/naming.py +261 -0
  87. pyoq/policies/__init__.py +47 -0
  88. pyoq/policies/bypass.py +122 -0
  89. pyoq/policies/governed.py +430 -0
  90. pyoq/policies/model.py +242 -0
  91. pyoq/policies/rewriting.py +263 -0
  92. pyoq/py.typed +1 -0
  93. pyoq/query/__init__.py +312 -0
  94. pyoq/query/aggregates.py +172 -0
  95. pyoq/query/arrays.py +65 -0
  96. pyoq/query/binding.py +52 -0
  97. pyoq/query/capabilities.py +317 -0
  98. pyoq/query/casts.py +73 -0
  99. pyoq/query/choices.py +185 -0
  100. pyoq/query/decoding.py +360 -0
  101. pyoq/query/documents.py +56 -0
  102. pyoq/query/execution/__init__.py +63 -0
  103. pyoq/query/execution/aio/__init__.py +31 -0
  104. pyoq/query/execution/aio/operations.py +228 -0
  105. pyoq/query/execution/aio/pooling.py +233 -0
  106. pyoq/query/execution/aio/streaming.py +161 -0
  107. pyoq/query/execution/aio/transactions.py +105 -0
  108. pyoq/query/execution/batch.py +96 -0
  109. pyoq/query/execution/binding_style.py +30 -0
  110. pyoq/query/execution/compilation.py +48 -0
  111. pyoq/query/execution/context.py +61 -0
  112. pyoq/query/execution/control.py +50 -0
  113. pyoq/query/execution/operations.py +224 -0
  114. pyoq/query/execution/planning.py +107 -0
  115. pyoq/query/execution/pooling.py +279 -0
  116. pyoq/query/execution/results.py +36 -0
  117. pyoq/query/execution/streaming.py +178 -0
  118. pyoq/query/execution/transactions.py +95 -0
  119. pyoq/query/expressions.py +1200 -0
  120. pyoq/query/fields.py +60 -0
  121. pyoq/query/mysql/__init__.py +59 -0
  122. pyoq/query/mysql/aio/__init__.py +38 -0
  123. pyoq/query/mysql/aio/commands.py +389 -0
  124. pyoq/query/mysql/aio/driver.py +196 -0
  125. pyoq/query/mysql/aio/executor.py +123 -0
  126. pyoq/query/mysql/aio/factory.py +26 -0
  127. pyoq/query/mysql/aio/operations.py +38 -0
  128. pyoq/query/mysql/aio/pool.py +53 -0
  129. pyoq/query/mysql/aio/transactions.py +313 -0
  130. pyoq/query/mysql/commands.py +354 -0
  131. pyoq/query/mysql/compiler.py +134 -0
  132. pyoq/query/mysql/context.py +20 -0
  133. pyoq/query/mysql/executor.py +126 -0
  134. pyoq/query/mysql/expressions.py +244 -0
  135. pyoq/query/mysql/factory.py +46 -0
  136. pyoq/query/mysql/health.py +66 -0
  137. pyoq/query/mysql/identifiers.py +9 -0
  138. pyoq/query/mysql/model.py +79 -0
  139. pyoq/query/mysql/operations.py +43 -0
  140. pyoq/query/mysql/parameters.py +69 -0
  141. pyoq/query/mysql/planning.py +20 -0
  142. pyoq/query/mysql/pool.py +67 -0
  143. pyoq/query/mysql/transactions.py +331 -0
  144. pyoq/query/mysql/writes.py +73 -0
  145. pyoq/query/nodes.py +750 -0
  146. pyoq/query/postgres/__init__.py +48 -0
  147. pyoq/query/postgres/aio/__init__.py +25 -0
  148. pyoq/query/postgres/aio/bulk.py +56 -0
  149. pyoq/query/postgres/aio/commands.py +264 -0
  150. pyoq/query/postgres/aio/executor.py +152 -0
  151. pyoq/query/postgres/aio/factory.py +26 -0
  152. pyoq/query/postgres/aio/operations.py +26 -0
  153. pyoq/query/postgres/aio/pool.py +40 -0
  154. pyoq/query/postgres/aio/transactions.py +295 -0
  155. pyoq/query/postgres/bulk.py +62 -0
  156. pyoq/query/postgres/commands.py +238 -0
  157. pyoq/query/postgres/compiler.py +114 -0
  158. pyoq/query/postgres/context.py +20 -0
  159. pyoq/query/postgres/executor.py +147 -0
  160. pyoq/query/postgres/expressions.py +311 -0
  161. pyoq/query/postgres/factory.py +24 -0
  162. pyoq/query/postgres/health.py +24 -0
  163. pyoq/query/postgres/identifiers.py +9 -0
  164. pyoq/query/postgres/model.py +81 -0
  165. pyoq/query/postgres/operations.py +25 -0
  166. pyoq/query/postgres/parameters.py +71 -0
  167. pyoq/query/postgres/planning.py +20 -0
  168. pyoq/query/postgres/pool.py +52 -0
  169. pyoq/query/postgres/transactions.py +295 -0
  170. pyoq/query/postgres/writes.py +37 -0
  171. pyoq/query/projections.py +105 -0
  172. pyoq/query/raw.py +90 -0
  173. pyoq/query/recursion.py +265 -0
  174. pyoq/query/rendering/__init__.py +1 -0
  175. pyoq/query/rendering/expressions.py +913 -0
  176. pyoq/query/rendering/identifiers.py +40 -0
  177. pyoq/query/rendering/projections.py +63 -0
  178. pyoq/query/rendering/queries.py +334 -0
  179. pyoq/query/rendering/sources.py +66 -0
  180. pyoq/query/rendering/writes.py +176 -0
  181. pyoq/query/results.py +459 -0
  182. pyoq/query/routines.py +196 -0
  183. pyoq/query/rows.py +156 -0
  184. pyoq/query/select.py +793 -0
  185. pyoq/query/select_nodes.py +277 -0
  186. pyoq/query/sources.py +236 -0
  187. pyoq/query/sqlite/__init__.py +43 -0
  188. pyoq/query/sqlite/commands.py +201 -0
  189. pyoq/query/sqlite/compiler.py +139 -0
  190. pyoq/query/sqlite/context.py +20 -0
  191. pyoq/query/sqlite/executor.py +119 -0
  192. pyoq/query/sqlite/expressions.py +224 -0
  193. pyoq/query/sqlite/factory.py +32 -0
  194. pyoq/query/sqlite/health.py +28 -0
  195. pyoq/query/sqlite/identifiers.py +9 -0
  196. pyoq/query/sqlite/model.py +73 -0
  197. pyoq/query/sqlite/operations.py +36 -0
  198. pyoq/query/sqlite/parameters.py +50 -0
  199. pyoq/query/sqlite/planning.py +20 -0
  200. pyoq/query/sqlite/pool.py +50 -0
  201. pyoq/query/sqlite/streaming.py +13 -0
  202. pyoq/query/sqlite/transactions.py +274 -0
  203. pyoq/query/sqlite/writes.py +35 -0
  204. pyoq/query/statements.py +27 -0
  205. pyoq/query/values.py +23 -0
  206. pyoq/query/vendor.py +162 -0
  207. pyoq/query/windows.py +424 -0
  208. pyoq/query/write_nodes.py +174 -0
  209. pyoq/query/writes.py +628 -0
  210. pyoq/relations/__init__.py +66 -0
  211. pyoq/relations/batching.py +219 -0
  212. pyoq/relations/derivation.py +111 -0
  213. pyoq/relations/fetching.py +355 -0
  214. pyoq/relations/graph.py +245 -0
  215. pyoq/relations/loading.py +74 -0
  216. pyoq/relations/model.py +75 -0
  217. pyoq/relations/planning.py +206 -0
  218. pyoq/runtime/__init__.py +9 -0
  219. pyoq/runtime/kernels.py +25 -0
  220. pyoq/runtime/python.py +43 -0
  221. pyoq/runtime/selection.py +73 -0
  222. pyoq/sanic/__init__.py +32 -0
  223. pyoq/sanic/scope.py +197 -0
  224. pyoq/sanic/workers.py +129 -0
  225. pyoq/schema/__init__.py +108 -0
  226. pyoq/schema/codec.py +711 -0
  227. pyoq/schema/models.py +604 -0
  228. pyoq/schema/mysql/__init__.py +16 -0
  229. pyoq/schema/mysql/connection.py +73 -0
  230. pyoq/schema/mysql/dsn.py +72 -0
  231. pyoq/schema/mysql/records.py +354 -0
  232. pyoq/schema/mysql/reflection.py +309 -0
  233. pyoq/schema/mysql/source.py +30 -0
  234. pyoq/schema/mysql/sql.py +128 -0
  235. pyoq/schema/mysql/types.py +105 -0
  236. pyoq/schema/postgres/__init__.py +13 -0
  237. pyoq/schema/postgres/connection.py +63 -0
  238. pyoq/schema/postgres/records.py +384 -0
  239. pyoq/schema/postgres/reflection.py +466 -0
  240. pyoq/schema/postgres/source.py +30 -0
  241. pyoq/schema/postgres/sql.py +246 -0
  242. pyoq/schema/postgres/types.py +98 -0
  243. pyoq/schema/registry.py +45 -0
  244. pyoq/schema/source.py +15 -0
  245. pyoq/schema/sqlite/__init__.py +6 -0
  246. pyoq/schema/sqlite/connection.py +54 -0
  247. pyoq/schema/sqlite/records.py +167 -0
  248. pyoq/schema/sqlite/reflection.py +393 -0
  249. pyoq/schema/sqlite/source.py +30 -0
  250. pyoq/schema/sqlite/sql.py +254 -0
  251. pyoq/schema/sqlite/types.py +74 -0
  252. pyoq/serving/__init__.py +23 -0
  253. pyoq/serving/databases.py +107 -0
  254. pyoq/serving/opening.py +331 -0
  255. pyoq/snapshots/__init__.py +20 -0
  256. pyoq/snapshots/drift.py +312 -0
  257. pyoq/snapshots/files.py +96 -0
  258. pyoq/snapshots/routing.py +40 -0
  259. pyoq/snapshots/source.py +33 -0
  260. pyoq/tracing/__init__.py +5 -0
  261. pyoq/tracing/spans.py +89 -0
  262. pyoq/unset.py +14 -0
  263. pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
  264. pyoq_sql-1.0.2.dist-info/RECORD +267 -0
  265. pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
  266. pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
  267. pyoq_sql-1.0.2.dist-info/licenses/LICENSE +373 -0
@@ -0,0 +1,267 @@
1
+ """Recognizing one query shape across the many times it is executed.
2
+
3
+ A query executed in a loop differs only in its bound values, a query executed
4
+ against a growing list of keys differs only in how many placeholders it carries,
5
+ and a write chunked to fit a parameter limit differs only in how many groups of
6
+ them it carries. All three are one shape, and seeing them as one is what makes a
7
+ repeated query visible.
8
+
9
+ A literal written into the statement itself is taken out for the same reason,
10
+ and for one more: a shape is reported and logged, so anything left in it is
11
+ reported and logged too. PyOQ binds every value it is given, but raw SQL can
12
+ carry one, and a shape that kept it would put it in a log.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass
19
+ from functools import lru_cache
20
+ from hashlib import blake2b
21
+
22
+ _ESCAPED_PERCENT = "\x00escaped-percent\x00"
23
+ # PostgreSQL opens a string with a tag it chooses, and closes it with the same.
24
+ _DOLLAR_TAG = re.compile(r"\$[A-Za-z_]\w*\$|\$\$")
25
+ # What quotes a name rather than a value, in the dialects PyOQ speaks.
26
+ # What quotes a name rather than a value. A single quote is absent because a
27
+ # single quoted string is a value, and is read as one before this is asked.
28
+ _NAME_QUOTES = '"`'
29
+ # What begins a comment that runs to the end of its line.
30
+ _LINE_COMMENTS = ("--", "#")
31
+ # A number written in another radix, and one written in the usual way.
32
+ _RADIX_NUMBER = re.compile(r"0[xX][0-9a-fA-F]+|0[bB][01]+")
33
+ _DECIMAL_NUMBER = re.compile(r"\d+(?:\.\d+)?(?:[eE][+-]?\d+)?")
34
+ _PLACEHOLDER = re.compile(r"\$\d+|%s|\?")
35
+ _PLACEHOLDER_RUN = re.compile(r"\?(?:\s*,\s*\?)+")
36
+ _GROUP_RUN = re.compile(r"\(\?\)(?:\s*,\s*\(\?\))+")
37
+ _WHITESPACE = re.compile(r"\s+")
38
+ _DIGEST_BYTES = 16
39
+ # What a shape may carry into a log line or a span attribute.
40
+ _MAXIMUM_SHAPE = 4096
41
+ _TRUNCATED = " ..."
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class QueryShape:
46
+ """One query with its values and their number taken out.
47
+
48
+ The shape carries no bound value, so it can be reported and logged without
49
+ revealing anything a query was asked about.
50
+ """
51
+
52
+ sql: str
53
+ digest: str
54
+
55
+ def __str__(self) -> str:
56
+ return self.sql
57
+
58
+
59
+ # An application runs the same statements over and over, so the shape of one is
60
+ # asked for far more often than it changes. The cache is bounded, because the
61
+ # statements a process runs are not.
62
+ _SHAPE_CACHE_SIZE = 2048
63
+
64
+
65
+ @lru_cache(maxsize=_SHAPE_CACHE_SIZE)
66
+ def query_shape(sql: str, /) -> QueryShape:
67
+ """The shape of a statement, remembered while it keeps being asked for.
68
+
69
+ A shape is written to a log and set on a span, so what it carries is
70
+ bounded. The digest is taken from the whole statement, so two that differ
71
+ only past the bound are still told apart.
72
+ """
73
+ normalized = normalize_sql(sql)
74
+ return QueryShape(_within_bounds(normalized), _digest(normalized))
75
+
76
+
77
+ def _within_bounds(normalized: str) -> str:
78
+ """A generated statement can be enormous, and a log line cannot."""
79
+ if len(normalized) <= _MAXIMUM_SHAPE:
80
+ return normalized
81
+ return normalized[:_MAXIMUM_SHAPE] + _TRUNCATED
82
+
83
+
84
+ def shape_cache_metrics() -> CacheMetrics:
85
+ """How well the shapes being asked for are already known."""
86
+ info = query_shape.cache_info()
87
+ return CacheMetrics(
88
+ hits=info.hits,
89
+ misses=info.misses,
90
+ held=info.currsize,
91
+ capacity=_SHAPE_CACHE_SIZE,
92
+ )
93
+
94
+
95
+ def forget_shapes() -> None:
96
+ """Empty the cache, which only a test measuring it should need."""
97
+ query_shape.cache_clear()
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class CacheMetrics:
102
+ """What a cache has been asked for and how much of it it kept."""
103
+
104
+ hits: int
105
+ misses: int
106
+ held: int
107
+ capacity: int
108
+
109
+ @property
110
+ def hit_rate(self) -> float:
111
+ asked = self.hits + self.misses
112
+ return 0.0 if asked == 0 else self.hits / asked
113
+
114
+
115
+ def normalize_sql(sql: str, /) -> str:
116
+ """Reduce a statement to the shape it shares with its repetitions.
117
+
118
+ The statement is read once, left to right, because what a character means
119
+ depends on what it is inside. A quote inside a comment starts nothing, and
120
+ two dashes inside a string are not a comment, so deciding either one without
121
+ tracking the other gets both wrong.
122
+ """
123
+ pieces: list[str] = []
124
+ position = 0
125
+ length = len(sql)
126
+ while position < length:
127
+ step = (
128
+ _skip_comment(sql, position)
129
+ or _take_literal(sql, position)
130
+ or _take_quoted_name(sql, position)
131
+ )
132
+ if step is None:
133
+ pieces.append(sql[position])
134
+ position += 1
135
+ continue
136
+ text, position = step
137
+ pieces.append(text)
138
+ return _collapse("".join(pieces))
139
+
140
+
141
+ def _skip_comment(sql: str, position: int) -> tuple[str, int] | None:
142
+ """A comment is dropped, because it says nothing about the shape."""
143
+ if sql.startswith(_LINE_COMMENTS, position):
144
+ ending = sql.find("\n", position)
145
+ return (" ", len(sql) if ending == -1 else ending)
146
+ if sql.startswith("/*", position):
147
+ return (" ", _end_of_block_comment(sql, position))
148
+ return None
149
+
150
+
151
+ def _end_of_block_comment(sql: str, position: int) -> int:
152
+ """Past the closing marker, counting the nesting PostgreSQL allows.
153
+
154
+ A dialect that does not nest would have ended at the first marker, so this
155
+ can drop more than that dialect needed. Dropping too much loses a little of
156
+ a shape; dropping too little puts whatever was written there in a log.
157
+ """
158
+ depth = 0
159
+ index = position
160
+ while index < len(sql):
161
+ if sql.startswith("/*", index):
162
+ depth += 1
163
+ index += 2
164
+ continue
165
+ if sql.startswith("*/", index):
166
+ depth -= 1
167
+ index += 2
168
+ if depth == 0:
169
+ return index
170
+ continue
171
+ index += 1
172
+ return len(sql)
173
+
174
+
175
+ def _take_literal(sql: str, position: int) -> tuple[str, int] | None:
176
+ """Every kind of literal becomes one placeholder."""
177
+ dollar = _take_dollar_quoted(sql, position)
178
+ if dollar is not None:
179
+ return dollar
180
+ if sql[position] == "'":
181
+ return ("?", _end_of_quoted(sql, position, "'"))
182
+ if _starts_number(sql, position):
183
+ return ("?", _end_of_number(sql, position))
184
+ return None
185
+
186
+
187
+ def _take_dollar_quoted(sql: str, position: int) -> tuple[str, int] | None:
188
+ """PostgreSQL quotes with a tag of its own choosing, so it is read out."""
189
+ if sql[position] != "$":
190
+ return None
191
+ opening = _DOLLAR_TAG.match(sql, position)
192
+ if opening is None:
193
+ return None
194
+ tag = opening.group(0)
195
+ ending = sql.find(tag, opening.end())
196
+ return ("?", len(sql) if ending == -1 else ending + len(tag))
197
+
198
+
199
+ def _end_of_quoted(sql: str, position: int, quote: str) -> int:
200
+ """Past the closing quote, counting a doubled or escaped one as inside."""
201
+ index = position + 1
202
+ while index < len(sql):
203
+ character = sql[index]
204
+ if character == "\\":
205
+ index += 2
206
+ continue
207
+ if character == quote:
208
+ if sql.startswith(quote * 2, index):
209
+ index += 2
210
+ continue
211
+ return index + 1
212
+ index += 1
213
+ return len(sql)
214
+
215
+
216
+ def _take_quoted_name(sql: str, position: int) -> tuple[str, int] | None:
217
+ """A quoted name is a name, so it is kept as it was written."""
218
+ quote = sql[position]
219
+ if quote not in _NAME_QUOTES:
220
+ return None
221
+ ending = _end_of_quoted(sql, position, quote)
222
+ return (sql[position:ending], ending)
223
+
224
+
225
+ def _starts_number(sql: str, position: int) -> bool:
226
+ """A decimal digit, which is narrower than what `isdigit` accepts.
227
+
228
+ A superscript is a digit to `isdigit` and not to the pattern that reads the
229
+ rest of the number, and taking one out a character at a time would mean a
230
+ shape that changes every time it is taken.
231
+ """
232
+ if not sql[position].isdecimal():
233
+ return False
234
+ before = sql[position - 1] if position else ""
235
+ return not (before.isalnum() or before in "_.$")
236
+
237
+
238
+ def _end_of_number(sql: str, position: int) -> int:
239
+ """Past the whole number, in any of the ways one can be written."""
240
+ radix = _RADIX_NUMBER.match(sql, position)
241
+ if radix is not None:
242
+ return radix.end()
243
+ decimal = _DECIMAL_NUMBER.match(sql, position)
244
+ return position + 1 if decimal is None else decimal.end()
245
+
246
+
247
+ def _collapse(text: str) -> str:
248
+ text = text.replace("%%", _ESCAPED_PERCENT)
249
+ text = _PLACEHOLDER.sub("?", text)
250
+ text = _PLACEHOLDER_RUN.sub("?", text)
251
+ text = _GROUP_RUN.sub("(?)", text)
252
+ text = _WHITESPACE.sub(" ", text).strip()
253
+ return text.replace(_ESCAPED_PERCENT, "%%")
254
+
255
+
256
+ def _digest(normalized: str) -> str:
257
+ return blake2b(normalized.encode(), digest_size=_DIGEST_BYTES).hexdigest()
258
+
259
+
260
+ __all__ = (
261
+ "CacheMetrics",
262
+ "QueryShape",
263
+ "forget_shapes",
264
+ "normalize_sql",
265
+ "query_shape",
266
+ "shape_cache_metrics",
267
+ )
@@ -0,0 +1,237 @@
1
+ """Reporting what every statement did, at the point they all pass.
2
+
3
+ Instrumentation is a database that wraps another one, so a project that wants
4
+ none holds the plain database and pays nothing at all. There is no flag to read
5
+ on the way to the driver because there is nothing in the way.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from time import perf_counter
11
+ from typing import TYPE_CHECKING, TypeVar
12
+
13
+ from pyoq.diagnostics.events import (
14
+ EventPolicy,
15
+ StatementFailed,
16
+ StatementFinished,
17
+ StatementStarted,
18
+ reportable_values,
19
+ )
20
+ from pyoq.diagnostics.fingerprint import query_shape
21
+ from pyoq.query.execution import QueryOperations
22
+ from pyoq.query.execution.aio import AsyncQueryOperations
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Awaitable, Callable
26
+
27
+ from pyoq.diagnostics.events import EventSink
28
+ from pyoq.query.execution import (
29
+ BulkPlan,
30
+ CompiledQuery,
31
+ DatabaseCursor,
32
+ ExecutionControl,
33
+ WriteProvider,
34
+ )
35
+ from pyoq.query.execution.aio import AsyncDatabaseCursor
36
+ from pyoq.query.statements import StatementCompiler
37
+ from pyoq.query.write_nodes import WriteNode
38
+
39
+ OperationResult = TypeVar("OperationResult")
40
+
41
+
42
+ class _Rows:
43
+ """How many rows the cursor reported, which only it can say."""
44
+
45
+ __slots__ = ("count",)
46
+
47
+ def __init__(self) -> None:
48
+ self.count: int | None = None
49
+
50
+
51
+ class InstrumentedOperations(QueryOperations):
52
+ """A database that reports every statement it runs."""
53
+
54
+ __slots__ = ("_inner", "_policy", "_sink")
55
+
56
+ def __init__(
57
+ self,
58
+ inner: QueryOperations,
59
+ sink: EventSink,
60
+ policy: EventPolicy | None = None,
61
+ ) -> None:
62
+ self._inner = inner
63
+ self._sink = sink
64
+ self._policy = policy or EventPolicy()
65
+
66
+ @property
67
+ def policy(self) -> EventPolicy:
68
+ return self._policy
69
+
70
+ @property
71
+ def compiler(self) -> StatementCompiler:
72
+ return self._inner.compiler
73
+
74
+ def plan_bulk(self, statement: WriteProvider | WriteNode, /) -> BulkPlan:
75
+ return self._inner.plan_bulk(statement)
76
+
77
+ def last_inserted_id(self, cursor: DatabaseCursor) -> int | None:
78
+ return self._inner.last_inserted_id(cursor)
79
+
80
+ def _run(
81
+ self,
82
+ statement: CompiledQuery,
83
+ operation: Callable[[DatabaseCursor], OperationResult],
84
+ control: ExecutionControl | None,
85
+ ) -> OperationResult:
86
+ report = _Report(statement, self._sink, self._policy)
87
+ rows = _Rows()
88
+ report.started()
89
+ try:
90
+ outcome = self._inner._run(statement, _counting(operation, rows), control)
91
+ except BaseException as error:
92
+ report.failed(error)
93
+ raise
94
+ report.finished(rows.count)
95
+ return outcome
96
+
97
+
98
+ class AsyncInstrumentedOperations(AsyncQueryOperations):
99
+ """The asynchronous counterpart, reporting at the same point."""
100
+
101
+ __slots__ = ("_inner", "_policy", "_sink")
102
+
103
+ def __init__(
104
+ self,
105
+ inner: AsyncQueryOperations,
106
+ sink: EventSink,
107
+ policy: EventPolicy | None = None,
108
+ ) -> None:
109
+ self._inner = inner
110
+ self._sink = sink
111
+ self._policy = policy or EventPolicy()
112
+
113
+ @property
114
+ def policy(self) -> EventPolicy:
115
+ return self._policy
116
+
117
+ @property
118
+ def compiler(self) -> StatementCompiler:
119
+ return self._inner.compiler
120
+
121
+ def plan_bulk(self, statement: WriteProvider | WriteNode, /) -> BulkPlan:
122
+ return self._inner.plan_bulk(statement)
123
+
124
+ def last_inserted_id(self, cursor: AsyncDatabaseCursor) -> int | None:
125
+ return self._inner.last_inserted_id(cursor)
126
+
127
+ async def _run(
128
+ self,
129
+ statement: CompiledQuery,
130
+ operation: Callable[[AsyncDatabaseCursor], Awaitable[OperationResult]],
131
+ control: ExecutionControl | None,
132
+ ) -> OperationResult:
133
+ report = _Report(statement, self._sink, self._policy)
134
+ rows = _Rows()
135
+ report.started()
136
+ try:
137
+ outcome = await self._inner._run(
138
+ statement, _counting_async(operation, rows), control
139
+ )
140
+ except BaseException as error:
141
+ report.failed(error)
142
+ raise
143
+ report.finished(rows.count)
144
+ return outcome
145
+
146
+
147
+ class _Report:
148
+ """One statement's events, timed from the moment it was described."""
149
+
150
+ __slots__ = ("_policy", "_shape", "_sink", "_started", "_statement")
151
+
152
+ def __init__(
153
+ self,
154
+ statement: CompiledQuery,
155
+ sink: EventSink,
156
+ policy: EventPolicy,
157
+ ) -> None:
158
+ self._statement = statement
159
+ self._sink = sink
160
+ self._policy = policy
161
+ self._shape = query_shape(statement.sql)
162
+ self._started = perf_counter()
163
+
164
+ def started(self) -> None:
165
+ self._sink.record(
166
+ StatementStarted(
167
+ shape=self._shape,
168
+ kind=self._statement.statement_kind,
169
+ parameters=len(self._statement.parameters),
170
+ sensitive_parameters=len(self._statement.sensitive_parameter_indexes),
171
+ values=self._values(),
172
+ )
173
+ )
174
+
175
+ def finished(self, rows: int | None) -> None:
176
+ elapsed = perf_counter() - self._started
177
+ threshold = self._policy.slow_after
178
+ self._sink.record(
179
+ StatementFinished(
180
+ shape=self._shape,
181
+ kind=self._statement.statement_kind,
182
+ parameters=len(self._statement.parameters),
183
+ sensitive_parameters=len(self._statement.sensitive_parameter_indexes),
184
+ duration=elapsed,
185
+ rows=rows,
186
+ slow=threshold is not None and elapsed >= threshold,
187
+ values=self._values(),
188
+ )
189
+ )
190
+
191
+ def failed(self, error: BaseException) -> None:
192
+ self._sink.record(
193
+ StatementFailed(
194
+ shape=self._shape,
195
+ kind=self._statement.statement_kind,
196
+ parameters=len(self._statement.parameters),
197
+ sensitive_parameters=len(self._statement.sensitive_parameter_indexes),
198
+ duration=perf_counter() - self._started,
199
+ failure=type(error).__name__,
200
+ detail=self._detail(error),
201
+ values=self._values(),
202
+ )
203
+ )
204
+
205
+ def _values(self) -> tuple[object, ...]:
206
+ return reportable_values(self._statement, self._policy)
207
+
208
+ def _detail(self, error: BaseException) -> str | None:
209
+ """A driver names the offending value in its message, so this is asked."""
210
+ return str(error) if self._policy.include_failure_detail else None
211
+
212
+
213
+ def _counting(
214
+ operation: Callable[[DatabaseCursor], OperationResult],
215
+ rows: _Rows,
216
+ ) -> Callable[[DatabaseCursor], OperationResult]:
217
+ def counted(cursor: DatabaseCursor) -> OperationResult:
218
+ outcome = operation(cursor)
219
+ rows.count = cursor.rowcount
220
+ return outcome
221
+
222
+ return counted
223
+
224
+
225
+ def _counting_async(
226
+ operation: Callable[[AsyncDatabaseCursor], Awaitable[OperationResult]],
227
+ rows: _Rows,
228
+ ) -> Callable[[AsyncDatabaseCursor], Awaitable[OperationResult]]:
229
+ async def counted(cursor: AsyncDatabaseCursor) -> OperationResult:
230
+ outcome = await operation(cursor)
231
+ rows.count = cursor.rowcount
232
+ return outcome
233
+
234
+ return counted
235
+
236
+
237
+ __all__ = ("AsyncInstrumentedOperations", "InstrumentedOperations")
@@ -0,0 +1,61 @@
1
+ """What a database is doing right now, as numbers.
2
+
3
+ A pool already knows its own counts and an observer already knows what has run.
4
+ This puts the two together so one reading describes both, and adds nothing that
5
+ would have to be kept up to date separately.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Protocol
12
+
13
+ if TYPE_CHECKING:
14
+ from pyoq.diagnostics.observation import QueryObserver
15
+ from pyoq.query.execution import PoolStats
16
+
17
+
18
+ class ReportsPoolStats(Protocol):
19
+ """Anything that can say how its connections are being used."""
20
+
21
+ @property
22
+ def stats(self) -> PoolStats: ...
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class DatabaseMetrics:
27
+ """One reading, carrying counts and nothing a query was asked about."""
28
+
29
+ open_connections: int
30
+ idle_connections: int
31
+ checked_out_connections: int
32
+ closed: bool
33
+ statements: int
34
+ shapes: int
35
+
36
+ @property
37
+ def in_use(self) -> float:
38
+ """How much of what is open is checked out, between zero and one."""
39
+ if self.open_connections == 0:
40
+ return 0.0
41
+ return self.checked_out_connections / self.open_connections
42
+
43
+
44
+ def metrics_of(
45
+ pool: ReportsPoolStats,
46
+ /,
47
+ observer: QueryObserver | None = None,
48
+ ) -> DatabaseMetrics:
49
+ """A reading taken now, from a pool and optionally from what it has run."""
50
+ stats = pool.stats
51
+ return DatabaseMetrics(
52
+ open_connections=stats.open_connections,
53
+ idle_connections=stats.idle_connections,
54
+ checked_out_connections=stats.checked_out_connections,
55
+ closed=stats.closed,
56
+ statements=0 if observer is None else observer.executions,
57
+ shapes=0 if observer is None else len(observer.repeated(threshold=1)),
58
+ )
59
+
60
+
61
+ __all__ = ("DatabaseMetrics", "ReportsPoolStats", "metrics_of")