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.
- pyoq/__init__.py +10 -0
- pyoq/__main__.py +5 -0
- pyoq/_native.pyi +5 -0
- pyoq/cli/__init__.py +5 -0
- pyoq/cli/commands.py +270 -0
- pyoq/cli/defaults.py +98 -0
- pyoq/cli/services.py +97 -0
- pyoq/config/__init__.py +31 -0
- pyoq/config/connection.py +161 -0
- pyoq/config/loader.py +289 -0
- pyoq/config/models.py +245 -0
- pyoq/config/values.py +142 -0
- pyoq/descriptors.py +165 -0
- pyoq/diagnostics/__init__.py +68 -0
- pyoq/diagnostics/budget.py +136 -0
- pyoq/diagnostics/events.py +137 -0
- pyoq/diagnostics/fingerprint.py +267 -0
- pyoq/diagnostics/instrumented.py +237 -0
- pyoq/diagnostics/metrics.py +61 -0
- pyoq/diagnostics/observation.py +227 -0
- pyoq/diagnostics/scoped.py +103 -0
- pyoq/django/__init__.py +15 -0
- pyoq/django/apps.py +17 -0
- pyoq/django/execution.py +317 -0
- pyoq/django/generation.py +59 -0
- pyoq/django/management/__init__.py +0 -0
- pyoq/django/management/commands/__init__.py +0 -0
- pyoq/django/management/commands/makemigrations.py +53 -0
- pyoq/django/management/commands/pyoq_codegen.py +75 -0
- pyoq/django/parameters.py +101 -0
- pyoq/django/schema.py +379 -0
- pyoq/django/settings.py +87 -0
- pyoq/django/timeouts.py +105 -0
- pyoq/dsl/__init__.py +64 -0
- pyoq/dsl/aio/__init__.py +31 -0
- pyoq/dsl/aio/context.py +295 -0
- pyoq/dsl/aio/queries.py +335 -0
- pyoq/dsl/aio/writes.py +368 -0
- pyoq/dsl/context.py +326 -0
- pyoq/dsl/entry.py +37 -0
- pyoq/dsl/labels.py +36 -0
- pyoq/dsl/queries.py +339 -0
- pyoq/dsl/result.py +164 -0
- pyoq/dsl/writes.py +360 -0
- pyoq/errors.py +317 -0
- pyoq/fastapi/__init__.py +32 -0
- pyoq/fastapi/dependencies.py +167 -0
- pyoq/fastapi/lifespan.py +119 -0
- pyoq/fetching/__init__.py +55 -0
- pyoq/fetching/collections.py +136 -0
- pyoq/fetching/execution.py +587 -0
- pyoq/fetching/joined.py +79 -0
- pyoq/fetching/nesting.py +183 -0
- pyoq/fetching/plans.py +541 -0
- pyoq/fetching/select_in.py +149 -0
- pyoq/fetching/tables.py +110 -0
- pyoq/generation/__init__.py +54 -0
- pyoq/generation/cleanup.py +44 -0
- pyoq/generation/contracts.py +248 -0
- pyoq/generation/drift.py +169 -0
- pyoq/generation/lock.py +33 -0
- pyoq/generation/manifest.py +114 -0
- pyoq/generation/model.py +1001 -0
- pyoq/generation/pipeline.py +119 -0
- pyoq/generation/rendering/__init__.py +5 -0
- pyoq/generation/rendering/domains.py +51 -0
- pyoq/generation/rendering/enums.py +29 -0
- pyoq/generation/rendering/exports.py +70 -0
- pyoq/generation/rendering/imports.py +63 -0
- pyoq/generation/rendering/package.py +56 -0
- pyoq/generation/rendering/relations.py +133 -0
- pyoq/generation/rendering/routines.py +396 -0
- pyoq/generation/rendering/rows.py +79 -0
- pyoq/generation/rendering/source.py +121 -0
- pyoq/generation/rendering/tables.py +300 -0
- pyoq/generation/rendering/writes.py +514 -0
- pyoq/generation/validation.py +27 -0
- pyoq/generation/writer.py +184 -0
- pyoq/hydration/__init__.py +24 -0
- pyoq/hydration/engine.py +155 -0
- pyoq/hydration/identity.py +194 -0
- pyoq/hydration/plan.py +116 -0
- pyoq/migrations/__init__.py +9 -0
- pyoq/migrations/alembic.py +106 -0
- pyoq/migrations/hooks.py +75 -0
- pyoq/naming.py +261 -0
- pyoq/policies/__init__.py +47 -0
- pyoq/policies/bypass.py +122 -0
- pyoq/policies/governed.py +430 -0
- pyoq/policies/model.py +242 -0
- pyoq/policies/rewriting.py +263 -0
- pyoq/py.typed +1 -0
- pyoq/query/__init__.py +312 -0
- pyoq/query/aggregates.py +172 -0
- pyoq/query/arrays.py +65 -0
- pyoq/query/binding.py +52 -0
- pyoq/query/capabilities.py +317 -0
- pyoq/query/casts.py +73 -0
- pyoq/query/choices.py +185 -0
- pyoq/query/decoding.py +360 -0
- pyoq/query/documents.py +56 -0
- pyoq/query/execution/__init__.py +63 -0
- pyoq/query/execution/aio/__init__.py +31 -0
- pyoq/query/execution/aio/operations.py +228 -0
- pyoq/query/execution/aio/pooling.py +233 -0
- pyoq/query/execution/aio/streaming.py +161 -0
- pyoq/query/execution/aio/transactions.py +105 -0
- pyoq/query/execution/batch.py +96 -0
- pyoq/query/execution/binding_style.py +30 -0
- pyoq/query/execution/compilation.py +48 -0
- pyoq/query/execution/context.py +61 -0
- pyoq/query/execution/control.py +50 -0
- pyoq/query/execution/operations.py +224 -0
- pyoq/query/execution/planning.py +107 -0
- pyoq/query/execution/pooling.py +279 -0
- pyoq/query/execution/results.py +36 -0
- pyoq/query/execution/streaming.py +178 -0
- pyoq/query/execution/transactions.py +95 -0
- pyoq/query/expressions.py +1200 -0
- pyoq/query/fields.py +60 -0
- pyoq/query/mysql/__init__.py +59 -0
- pyoq/query/mysql/aio/__init__.py +38 -0
- pyoq/query/mysql/aio/commands.py +389 -0
- pyoq/query/mysql/aio/driver.py +196 -0
- pyoq/query/mysql/aio/executor.py +123 -0
- pyoq/query/mysql/aio/factory.py +26 -0
- pyoq/query/mysql/aio/operations.py +38 -0
- pyoq/query/mysql/aio/pool.py +53 -0
- pyoq/query/mysql/aio/transactions.py +313 -0
- pyoq/query/mysql/commands.py +354 -0
- pyoq/query/mysql/compiler.py +134 -0
- pyoq/query/mysql/context.py +20 -0
- pyoq/query/mysql/executor.py +126 -0
- pyoq/query/mysql/expressions.py +244 -0
- pyoq/query/mysql/factory.py +46 -0
- pyoq/query/mysql/health.py +66 -0
- pyoq/query/mysql/identifiers.py +9 -0
- pyoq/query/mysql/model.py +79 -0
- pyoq/query/mysql/operations.py +43 -0
- pyoq/query/mysql/parameters.py +69 -0
- pyoq/query/mysql/planning.py +20 -0
- pyoq/query/mysql/pool.py +67 -0
- pyoq/query/mysql/transactions.py +331 -0
- pyoq/query/mysql/writes.py +73 -0
- pyoq/query/nodes.py +750 -0
- pyoq/query/postgres/__init__.py +48 -0
- pyoq/query/postgres/aio/__init__.py +25 -0
- pyoq/query/postgres/aio/bulk.py +56 -0
- pyoq/query/postgres/aio/commands.py +264 -0
- pyoq/query/postgres/aio/executor.py +152 -0
- pyoq/query/postgres/aio/factory.py +26 -0
- pyoq/query/postgres/aio/operations.py +26 -0
- pyoq/query/postgres/aio/pool.py +40 -0
- pyoq/query/postgres/aio/transactions.py +295 -0
- pyoq/query/postgres/bulk.py +62 -0
- pyoq/query/postgres/commands.py +238 -0
- pyoq/query/postgres/compiler.py +114 -0
- pyoq/query/postgres/context.py +20 -0
- pyoq/query/postgres/executor.py +147 -0
- pyoq/query/postgres/expressions.py +311 -0
- pyoq/query/postgres/factory.py +24 -0
- pyoq/query/postgres/health.py +24 -0
- pyoq/query/postgres/identifiers.py +9 -0
- pyoq/query/postgres/model.py +81 -0
- pyoq/query/postgres/operations.py +25 -0
- pyoq/query/postgres/parameters.py +71 -0
- pyoq/query/postgres/planning.py +20 -0
- pyoq/query/postgres/pool.py +52 -0
- pyoq/query/postgres/transactions.py +295 -0
- pyoq/query/postgres/writes.py +37 -0
- pyoq/query/projections.py +105 -0
- pyoq/query/raw.py +90 -0
- pyoq/query/recursion.py +265 -0
- pyoq/query/rendering/__init__.py +1 -0
- pyoq/query/rendering/expressions.py +913 -0
- pyoq/query/rendering/identifiers.py +40 -0
- pyoq/query/rendering/projections.py +63 -0
- pyoq/query/rendering/queries.py +334 -0
- pyoq/query/rendering/sources.py +66 -0
- pyoq/query/rendering/writes.py +176 -0
- pyoq/query/results.py +459 -0
- pyoq/query/routines.py +196 -0
- pyoq/query/rows.py +156 -0
- pyoq/query/select.py +793 -0
- pyoq/query/select_nodes.py +277 -0
- pyoq/query/sources.py +236 -0
- pyoq/query/sqlite/__init__.py +43 -0
- pyoq/query/sqlite/commands.py +201 -0
- pyoq/query/sqlite/compiler.py +139 -0
- pyoq/query/sqlite/context.py +20 -0
- pyoq/query/sqlite/executor.py +119 -0
- pyoq/query/sqlite/expressions.py +224 -0
- pyoq/query/sqlite/factory.py +32 -0
- pyoq/query/sqlite/health.py +28 -0
- pyoq/query/sqlite/identifiers.py +9 -0
- pyoq/query/sqlite/model.py +73 -0
- pyoq/query/sqlite/operations.py +36 -0
- pyoq/query/sqlite/parameters.py +50 -0
- pyoq/query/sqlite/planning.py +20 -0
- pyoq/query/sqlite/pool.py +50 -0
- pyoq/query/sqlite/streaming.py +13 -0
- pyoq/query/sqlite/transactions.py +274 -0
- pyoq/query/sqlite/writes.py +35 -0
- pyoq/query/statements.py +27 -0
- pyoq/query/values.py +23 -0
- pyoq/query/vendor.py +162 -0
- pyoq/query/windows.py +424 -0
- pyoq/query/write_nodes.py +174 -0
- pyoq/query/writes.py +628 -0
- pyoq/relations/__init__.py +66 -0
- pyoq/relations/batching.py +219 -0
- pyoq/relations/derivation.py +111 -0
- pyoq/relations/fetching.py +355 -0
- pyoq/relations/graph.py +245 -0
- pyoq/relations/loading.py +74 -0
- pyoq/relations/model.py +75 -0
- pyoq/relations/planning.py +206 -0
- pyoq/runtime/__init__.py +9 -0
- pyoq/runtime/kernels.py +25 -0
- pyoq/runtime/python.py +43 -0
- pyoq/runtime/selection.py +73 -0
- pyoq/sanic/__init__.py +32 -0
- pyoq/sanic/scope.py +197 -0
- pyoq/sanic/workers.py +129 -0
- pyoq/schema/__init__.py +108 -0
- pyoq/schema/codec.py +711 -0
- pyoq/schema/models.py +604 -0
- pyoq/schema/mysql/__init__.py +16 -0
- pyoq/schema/mysql/connection.py +73 -0
- pyoq/schema/mysql/dsn.py +72 -0
- pyoq/schema/mysql/records.py +354 -0
- pyoq/schema/mysql/reflection.py +309 -0
- pyoq/schema/mysql/source.py +30 -0
- pyoq/schema/mysql/sql.py +128 -0
- pyoq/schema/mysql/types.py +105 -0
- pyoq/schema/postgres/__init__.py +13 -0
- pyoq/schema/postgres/connection.py +63 -0
- pyoq/schema/postgres/records.py +384 -0
- pyoq/schema/postgres/reflection.py +466 -0
- pyoq/schema/postgres/source.py +30 -0
- pyoq/schema/postgres/sql.py +246 -0
- pyoq/schema/postgres/types.py +98 -0
- pyoq/schema/registry.py +45 -0
- pyoq/schema/source.py +15 -0
- pyoq/schema/sqlite/__init__.py +6 -0
- pyoq/schema/sqlite/connection.py +54 -0
- pyoq/schema/sqlite/records.py +167 -0
- pyoq/schema/sqlite/reflection.py +393 -0
- pyoq/schema/sqlite/source.py +30 -0
- pyoq/schema/sqlite/sql.py +254 -0
- pyoq/schema/sqlite/types.py +74 -0
- pyoq/serving/__init__.py +23 -0
- pyoq/serving/databases.py +107 -0
- pyoq/serving/opening.py +331 -0
- pyoq/snapshots/__init__.py +20 -0
- pyoq/snapshots/drift.py +312 -0
- pyoq/snapshots/files.py +96 -0
- pyoq/snapshots/routing.py +40 -0
- pyoq/snapshots/source.py +33 -0
- pyoq/tracing/__init__.py +5 -0
- pyoq/tracing/spans.py +89 -0
- pyoq/unset.py +14 -0
- pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
- pyoq_sql-1.0.2.dist-info/RECORD +267 -0
- pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
- pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
- 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")
|