pyoq-sql 1.0.0__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 (264) hide show
  1. pyoq/__init__.py +7 -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 +19 -0
  9. pyoq/config/loader.py +204 -0
  10. pyoq/config/models.py +243 -0
  11. pyoq/descriptors.py +165 -0
  12. pyoq/diagnostics/__init__.py +68 -0
  13. pyoq/diagnostics/budget.py +136 -0
  14. pyoq/diagnostics/events.py +137 -0
  15. pyoq/diagnostics/fingerprint.py +267 -0
  16. pyoq/diagnostics/instrumented.py +237 -0
  17. pyoq/diagnostics/metrics.py +61 -0
  18. pyoq/diagnostics/observation.py +227 -0
  19. pyoq/diagnostics/scoped.py +103 -0
  20. pyoq/django/__init__.py +15 -0
  21. pyoq/django/apps.py +17 -0
  22. pyoq/django/execution.py +317 -0
  23. pyoq/django/generation.py +59 -0
  24. pyoq/django/management/__init__.py +0 -0
  25. pyoq/django/management/commands/__init__.py +0 -0
  26. pyoq/django/management/commands/makemigrations.py +53 -0
  27. pyoq/django/management/commands/pyoq_codegen.py +75 -0
  28. pyoq/django/parameters.py +101 -0
  29. pyoq/django/schema.py +379 -0
  30. pyoq/django/settings.py +87 -0
  31. pyoq/django/timeouts.py +105 -0
  32. pyoq/dsl/__init__.py +64 -0
  33. pyoq/dsl/aio/__init__.py +31 -0
  34. pyoq/dsl/aio/context.py +295 -0
  35. pyoq/dsl/aio/queries.py +335 -0
  36. pyoq/dsl/aio/writes.py +368 -0
  37. pyoq/dsl/context.py +326 -0
  38. pyoq/dsl/entry.py +37 -0
  39. pyoq/dsl/labels.py +36 -0
  40. pyoq/dsl/queries.py +339 -0
  41. pyoq/dsl/result.py +164 -0
  42. pyoq/dsl/writes.py +360 -0
  43. pyoq/errors.py +317 -0
  44. pyoq/fastapi/__init__.py +32 -0
  45. pyoq/fastapi/dependencies.py +167 -0
  46. pyoq/fastapi/lifespan.py +119 -0
  47. pyoq/fetching/__init__.py +55 -0
  48. pyoq/fetching/collections.py +136 -0
  49. pyoq/fetching/execution.py +587 -0
  50. pyoq/fetching/joined.py +79 -0
  51. pyoq/fetching/nesting.py +183 -0
  52. pyoq/fetching/plans.py +541 -0
  53. pyoq/fetching/select_in.py +149 -0
  54. pyoq/fetching/tables.py +110 -0
  55. pyoq/generation/__init__.py +54 -0
  56. pyoq/generation/cleanup.py +44 -0
  57. pyoq/generation/contracts.py +248 -0
  58. pyoq/generation/drift.py +169 -0
  59. pyoq/generation/lock.py +33 -0
  60. pyoq/generation/manifest.py +114 -0
  61. pyoq/generation/model.py +1001 -0
  62. pyoq/generation/pipeline.py +119 -0
  63. pyoq/generation/rendering/__init__.py +5 -0
  64. pyoq/generation/rendering/domains.py +51 -0
  65. pyoq/generation/rendering/enums.py +29 -0
  66. pyoq/generation/rendering/exports.py +70 -0
  67. pyoq/generation/rendering/imports.py +63 -0
  68. pyoq/generation/rendering/package.py +56 -0
  69. pyoq/generation/rendering/relations.py +133 -0
  70. pyoq/generation/rendering/routines.py +396 -0
  71. pyoq/generation/rendering/rows.py +79 -0
  72. pyoq/generation/rendering/source.py +121 -0
  73. pyoq/generation/rendering/tables.py +300 -0
  74. pyoq/generation/rendering/writes.py +514 -0
  75. pyoq/generation/validation.py +27 -0
  76. pyoq/generation/writer.py +184 -0
  77. pyoq/hydration/__init__.py +24 -0
  78. pyoq/hydration/engine.py +155 -0
  79. pyoq/hydration/identity.py +194 -0
  80. pyoq/hydration/plan.py +116 -0
  81. pyoq/migrations/__init__.py +9 -0
  82. pyoq/migrations/alembic.py +106 -0
  83. pyoq/migrations/hooks.py +75 -0
  84. pyoq/naming.py +261 -0
  85. pyoq/policies/__init__.py +47 -0
  86. pyoq/policies/bypass.py +122 -0
  87. pyoq/policies/governed.py +430 -0
  88. pyoq/policies/model.py +242 -0
  89. pyoq/policies/rewriting.py +263 -0
  90. pyoq/py.typed +1 -0
  91. pyoq/query/__init__.py +312 -0
  92. pyoq/query/aggregates.py +172 -0
  93. pyoq/query/arrays.py +65 -0
  94. pyoq/query/binding.py +52 -0
  95. pyoq/query/capabilities.py +317 -0
  96. pyoq/query/casts.py +73 -0
  97. pyoq/query/choices.py +185 -0
  98. pyoq/query/decoding.py +360 -0
  99. pyoq/query/documents.py +56 -0
  100. pyoq/query/execution/__init__.py +63 -0
  101. pyoq/query/execution/aio/__init__.py +31 -0
  102. pyoq/query/execution/aio/operations.py +228 -0
  103. pyoq/query/execution/aio/pooling.py +233 -0
  104. pyoq/query/execution/aio/streaming.py +161 -0
  105. pyoq/query/execution/aio/transactions.py +105 -0
  106. pyoq/query/execution/batch.py +96 -0
  107. pyoq/query/execution/binding_style.py +30 -0
  108. pyoq/query/execution/compilation.py +48 -0
  109. pyoq/query/execution/context.py +61 -0
  110. pyoq/query/execution/control.py +50 -0
  111. pyoq/query/execution/operations.py +224 -0
  112. pyoq/query/execution/planning.py +107 -0
  113. pyoq/query/execution/pooling.py +279 -0
  114. pyoq/query/execution/results.py +36 -0
  115. pyoq/query/execution/streaming.py +178 -0
  116. pyoq/query/execution/transactions.py +95 -0
  117. pyoq/query/expressions.py +1200 -0
  118. pyoq/query/fields.py +60 -0
  119. pyoq/query/mysql/__init__.py +59 -0
  120. pyoq/query/mysql/aio/__init__.py +38 -0
  121. pyoq/query/mysql/aio/commands.py +389 -0
  122. pyoq/query/mysql/aio/driver.py +196 -0
  123. pyoq/query/mysql/aio/executor.py +123 -0
  124. pyoq/query/mysql/aio/factory.py +26 -0
  125. pyoq/query/mysql/aio/operations.py +38 -0
  126. pyoq/query/mysql/aio/pool.py +53 -0
  127. pyoq/query/mysql/aio/transactions.py +313 -0
  128. pyoq/query/mysql/commands.py +354 -0
  129. pyoq/query/mysql/compiler.py +134 -0
  130. pyoq/query/mysql/context.py +20 -0
  131. pyoq/query/mysql/executor.py +126 -0
  132. pyoq/query/mysql/expressions.py +244 -0
  133. pyoq/query/mysql/factory.py +46 -0
  134. pyoq/query/mysql/health.py +66 -0
  135. pyoq/query/mysql/identifiers.py +9 -0
  136. pyoq/query/mysql/model.py +79 -0
  137. pyoq/query/mysql/operations.py +43 -0
  138. pyoq/query/mysql/parameters.py +69 -0
  139. pyoq/query/mysql/planning.py +20 -0
  140. pyoq/query/mysql/pool.py +67 -0
  141. pyoq/query/mysql/transactions.py +331 -0
  142. pyoq/query/mysql/writes.py +73 -0
  143. pyoq/query/nodes.py +750 -0
  144. pyoq/query/postgres/__init__.py +48 -0
  145. pyoq/query/postgres/aio/__init__.py +25 -0
  146. pyoq/query/postgres/aio/bulk.py +56 -0
  147. pyoq/query/postgres/aio/commands.py +264 -0
  148. pyoq/query/postgres/aio/executor.py +152 -0
  149. pyoq/query/postgres/aio/factory.py +26 -0
  150. pyoq/query/postgres/aio/operations.py +26 -0
  151. pyoq/query/postgres/aio/pool.py +40 -0
  152. pyoq/query/postgres/aio/transactions.py +295 -0
  153. pyoq/query/postgres/bulk.py +62 -0
  154. pyoq/query/postgres/commands.py +238 -0
  155. pyoq/query/postgres/compiler.py +114 -0
  156. pyoq/query/postgres/context.py +20 -0
  157. pyoq/query/postgres/executor.py +147 -0
  158. pyoq/query/postgres/expressions.py +311 -0
  159. pyoq/query/postgres/factory.py +24 -0
  160. pyoq/query/postgres/health.py +24 -0
  161. pyoq/query/postgres/identifiers.py +9 -0
  162. pyoq/query/postgres/model.py +81 -0
  163. pyoq/query/postgres/operations.py +25 -0
  164. pyoq/query/postgres/parameters.py +71 -0
  165. pyoq/query/postgres/planning.py +20 -0
  166. pyoq/query/postgres/pool.py +52 -0
  167. pyoq/query/postgres/transactions.py +295 -0
  168. pyoq/query/postgres/writes.py +37 -0
  169. pyoq/query/projections.py +105 -0
  170. pyoq/query/raw.py +90 -0
  171. pyoq/query/recursion.py +265 -0
  172. pyoq/query/rendering/__init__.py +1 -0
  173. pyoq/query/rendering/expressions.py +913 -0
  174. pyoq/query/rendering/identifiers.py +40 -0
  175. pyoq/query/rendering/projections.py +63 -0
  176. pyoq/query/rendering/queries.py +334 -0
  177. pyoq/query/rendering/sources.py +66 -0
  178. pyoq/query/rendering/writes.py +176 -0
  179. pyoq/query/results.py +459 -0
  180. pyoq/query/routines.py +196 -0
  181. pyoq/query/rows.py +156 -0
  182. pyoq/query/select.py +793 -0
  183. pyoq/query/select_nodes.py +277 -0
  184. pyoq/query/sources.py +236 -0
  185. pyoq/query/sqlite/__init__.py +43 -0
  186. pyoq/query/sqlite/commands.py +201 -0
  187. pyoq/query/sqlite/compiler.py +139 -0
  188. pyoq/query/sqlite/context.py +20 -0
  189. pyoq/query/sqlite/executor.py +119 -0
  190. pyoq/query/sqlite/expressions.py +224 -0
  191. pyoq/query/sqlite/factory.py +32 -0
  192. pyoq/query/sqlite/health.py +28 -0
  193. pyoq/query/sqlite/identifiers.py +9 -0
  194. pyoq/query/sqlite/model.py +73 -0
  195. pyoq/query/sqlite/operations.py +36 -0
  196. pyoq/query/sqlite/parameters.py +50 -0
  197. pyoq/query/sqlite/planning.py +20 -0
  198. pyoq/query/sqlite/pool.py +50 -0
  199. pyoq/query/sqlite/streaming.py +13 -0
  200. pyoq/query/sqlite/transactions.py +274 -0
  201. pyoq/query/sqlite/writes.py +35 -0
  202. pyoq/query/statements.py +27 -0
  203. pyoq/query/values.py +23 -0
  204. pyoq/query/vendor.py +162 -0
  205. pyoq/query/windows.py +424 -0
  206. pyoq/query/write_nodes.py +174 -0
  207. pyoq/query/writes.py +628 -0
  208. pyoq/relations/__init__.py +66 -0
  209. pyoq/relations/batching.py +219 -0
  210. pyoq/relations/derivation.py +111 -0
  211. pyoq/relations/fetching.py +355 -0
  212. pyoq/relations/graph.py +245 -0
  213. pyoq/relations/loading.py +74 -0
  214. pyoq/relations/model.py +75 -0
  215. pyoq/relations/planning.py +206 -0
  216. pyoq/runtime/__init__.py +9 -0
  217. pyoq/runtime/kernels.py +25 -0
  218. pyoq/runtime/python.py +43 -0
  219. pyoq/runtime/selection.py +73 -0
  220. pyoq/sanic/__init__.py +32 -0
  221. pyoq/sanic/scope.py +197 -0
  222. pyoq/sanic/workers.py +129 -0
  223. pyoq/schema/__init__.py +108 -0
  224. pyoq/schema/codec.py +711 -0
  225. pyoq/schema/models.py +604 -0
  226. pyoq/schema/mysql/__init__.py +16 -0
  227. pyoq/schema/mysql/connection.py +72 -0
  228. pyoq/schema/mysql/dsn.py +72 -0
  229. pyoq/schema/mysql/records.py +354 -0
  230. pyoq/schema/mysql/reflection.py +309 -0
  231. pyoq/schema/mysql/source.py +30 -0
  232. pyoq/schema/mysql/sql.py +128 -0
  233. pyoq/schema/mysql/types.py +105 -0
  234. pyoq/schema/postgres/__init__.py +13 -0
  235. pyoq/schema/postgres/connection.py +62 -0
  236. pyoq/schema/postgres/records.py +384 -0
  237. pyoq/schema/postgres/reflection.py +466 -0
  238. pyoq/schema/postgres/source.py +30 -0
  239. pyoq/schema/postgres/sql.py +246 -0
  240. pyoq/schema/postgres/types.py +98 -0
  241. pyoq/schema/registry.py +45 -0
  242. pyoq/schema/source.py +15 -0
  243. pyoq/schema/sqlite/__init__.py +6 -0
  244. pyoq/schema/sqlite/connection.py +54 -0
  245. pyoq/schema/sqlite/records.py +167 -0
  246. pyoq/schema/sqlite/reflection.py +393 -0
  247. pyoq/schema/sqlite/source.py +30 -0
  248. pyoq/schema/sqlite/sql.py +254 -0
  249. pyoq/schema/sqlite/types.py +74 -0
  250. pyoq/serving/__init__.py +19 -0
  251. pyoq/serving/databases.py +107 -0
  252. pyoq/snapshots/__init__.py +20 -0
  253. pyoq/snapshots/drift.py +312 -0
  254. pyoq/snapshots/files.py +96 -0
  255. pyoq/snapshots/routing.py +40 -0
  256. pyoq/snapshots/source.py +33 -0
  257. pyoq/tracing/__init__.py +5 -0
  258. pyoq/tracing/spans.py +89 -0
  259. pyoq/unset.py +14 -0
  260. pyoq_sql-1.0.0.dist-info/METADATA +3034 -0
  261. pyoq_sql-1.0.0.dist-info/RECORD +264 -0
  262. pyoq_sql-1.0.0.dist-info/WHEEL +4 -0
  263. pyoq_sql-1.0.0.dist-info/entry_points.txt +3 -0
  264. pyoq_sql-1.0.0.dist-info/licenses/LICENSE +373 -0
@@ -0,0 +1,136 @@
1
+ """What one scope is allowed to ask a database to do.
2
+
3
+ A repeated query that is only reported is a repeated query that still ships. A
4
+ budget turns the same observation into a refusal, at the point where the scope
5
+ exceeds what it was allowed rather than after the fact.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import cast
12
+
13
+ from pyoq.diagnostics.fingerprint import QueryShape, query_shape
14
+ from pyoq.diagnostics.observation import QueryObserver, RepeatedQuery
15
+ from pyoq.errors import QueryBudgetExceededError, QueryValidationError
16
+ from pyoq.query.execution import CompiledQuery
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class QueryBudget:
21
+ """A ceiling on one scope's database work.
22
+
23
+ ``maximum_repeats`` is the one that catches an N+1 access, because the shape
24
+ executed once per row of an earlier result is the shape that repeats.
25
+ """
26
+
27
+ maximum_queries: int | None = None
28
+ maximum_repeats: int | None = None
29
+
30
+ def __post_init__(self) -> None:
31
+ _require_optional_positive(self.maximum_queries, "maximum queries")
32
+ _require_optional_positive(self.maximum_repeats, "maximum repeats")
33
+
34
+ @property
35
+ def unlimited(self) -> bool:
36
+ return self.maximum_queries is None and self.maximum_repeats is None
37
+
38
+
39
+ class QueryScope:
40
+ """One request's worth of database work, watched and bounded.
41
+
42
+ The scope holds an observer so that a refusal can say which shape ran too
43
+ often and where from, which is what makes the refusal actionable rather than
44
+ merely correct.
45
+ """
46
+
47
+ __slots__ = ("_budget", "_observer")
48
+
49
+ def __init__(
50
+ self,
51
+ budget: QueryBudget | None = None,
52
+ *,
53
+ observer: QueryObserver | None = None,
54
+ ) -> None:
55
+ self._budget = budget or QueryBudget()
56
+ self._observer = observer or QueryObserver()
57
+
58
+ @property
59
+ def budget(self) -> QueryBudget:
60
+ return self._budget
61
+
62
+ @property
63
+ def observer(self) -> QueryObserver:
64
+ return self._observer
65
+
66
+ @property
67
+ def executions(self) -> int:
68
+ return self._observer.executions
69
+
70
+ def record(self, statement: CompiledQuery, /) -> None:
71
+ """Record a statement, refusing the one that exceeds the budget.
72
+
73
+ The statement is recorded before it is judged, so a report taken after
74
+ a refusal includes the execution that caused it.
75
+ """
76
+ executions = self._observer.record(statement)
77
+ if self._budget.unlimited:
78
+ return
79
+ self._require_within_total()
80
+ self._require_within_repeats(statement, executions)
81
+
82
+ def repeated(self, *, threshold: int = 2) -> tuple[RepeatedQuery, ...]:
83
+ return self._observer.repeated(threshold=threshold)
84
+
85
+ def _require_within_total(self) -> None:
86
+ limit = self._budget.maximum_queries
87
+ if limit is None or self._observer.executions <= limit:
88
+ return
89
+ message = (
90
+ f"this scope executed {self._observer.executions} statements, beyond "
91
+ f"the {limit} it was allowed"
92
+ )
93
+ raise QueryBudgetExceededError(message)
94
+
95
+ def _require_within_repeats(
96
+ self,
97
+ statement: CompiledQuery,
98
+ executions: int,
99
+ ) -> None:
100
+ """Judge only the shape just recorded, which is the only one that moved.
101
+
102
+ Scanning every shape on every statement would make the cost of holding a
103
+ budget grow with the variety of a scope's queries.
104
+ """
105
+ limit = self._budget.maximum_repeats
106
+ if limit is None or executions <= limit:
107
+ return
108
+ digest = query_shape(statement.sql).digest
109
+ # A count above the limit means the observer kept this shape, and it
110
+ # never lets one go, so the report is there to be read.
111
+ repeated = cast("RepeatedQuery", self._observer.report(digest))
112
+ raise QueryBudgetExceededError(_repeat_message(repeated, limit))
113
+
114
+
115
+ def _repeat_message(repeated: RepeatedQuery, limit: int) -> str:
116
+ places = "; ".join(str(site) for site in repeated.sites)
117
+ origin = f" from {places}" if places else ""
118
+ return (
119
+ f"one statement ran {repeated.executions} times in this scope, beyond "
120
+ f"the {limit} it was allowed{origin}: {_shape_text(repeated.shape)}"
121
+ )
122
+
123
+
124
+ def _shape_text(shape: QueryShape) -> str:
125
+ return shape.sql
126
+
127
+
128
+ def _require_optional_positive(value: int | None, label: str) -> None:
129
+ if value is None:
130
+ return
131
+ if isinstance(value, bool) or value < 1:
132
+ message = f"{label} must be a positive integer or None"
133
+ raise QueryValidationError(message)
134
+
135
+
136
+ __all__ = ("QueryBudget", "QueryScope")
@@ -0,0 +1,137 @@
1
+ """What a statement did, described without what it was asked about.
2
+
3
+ A shape carries the SQL with its values taken out, which is safe to log. A bound
4
+ value is not, and neither is the message a driver raises: PostgreSQL states the
5
+ offending value inside it, so a failure that says only its type is the one that
6
+ can be reported anywhere.
7
+
8
+ A project that has decided otherwise says so through a policy, one field at a
9
+ time. A value marked sensitive when the statement was compiled is never included
10
+ whatever the policy says.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Protocol, TypeAlias
17
+
18
+ if TYPE_CHECKING:
19
+ from pyoq.diagnostics.fingerprint import QueryShape
20
+ from pyoq.query.execution import CompiledQuery, StatementKind
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class EventPolicy:
25
+ """What an event may carry beyond the shape of a statement.
26
+
27
+ Every field is off, so instrumentation added without a decision reports
28
+ nothing a query was asked about.
29
+ """
30
+
31
+ include_values: bool = False
32
+ include_failure_detail: bool = False
33
+ slow_after: float | None = None
34
+
35
+ def __post_init__(self) -> None:
36
+ if self.slow_after is not None and self.slow_after <= 0:
37
+ message = "a slow statement threshold must be positive"
38
+ raise ValueError(message)
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class StatementStarted:
43
+ """A statement about to reach a driver."""
44
+
45
+ shape: QueryShape
46
+ kind: StatementKind
47
+ parameters: int
48
+ sensitive_parameters: int
49
+ values: tuple[object, ...] = ()
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class StatementFinished:
54
+ """A statement the database answered."""
55
+
56
+ shape: QueryShape
57
+ kind: StatementKind
58
+ parameters: int
59
+ sensitive_parameters: int
60
+ duration: float
61
+ rows: int | None = None
62
+ slow: bool = False
63
+ values: tuple[object, ...] = ()
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class StatementFailed:
68
+ """A statement the database refused.
69
+
70
+ `failure` names the error's type. Its message is only carried when a policy
71
+ says so, because a driver states the value that caused the failure in it.
72
+ """
73
+
74
+ shape: QueryShape
75
+ kind: StatementKind
76
+ parameters: int
77
+ sensitive_parameters: int
78
+ duration: float
79
+ failure: str
80
+ detail: str | None = None
81
+ values: tuple[object, ...] = ()
82
+
83
+
84
+ StatementEvent: TypeAlias = StatementStarted | StatementFinished | StatementFailed
85
+
86
+
87
+ class EventSink(Protocol):
88
+ """Somewhere for events to go."""
89
+
90
+ def record(self, event: StatementEvent, /) -> None: ...
91
+
92
+
93
+ class CollectingSink:
94
+ """Keeps what it is given, for a caller that wants to look afterwards."""
95
+
96
+ __slots__ = ("events",)
97
+ events: list[StatementEvent]
98
+
99
+ def __init__(self) -> None:
100
+ self.events = []
101
+
102
+ def record(self, event: StatementEvent, /) -> None:
103
+ self.events.append(event)
104
+
105
+ def clear(self) -> None:
106
+ self.events.clear()
107
+
108
+
109
+ def reportable_values(
110
+ statement: CompiledQuery,
111
+ policy: EventPolicy,
112
+ /,
113
+ ) -> tuple[object, ...]:
114
+ """The values an event may carry, which is none of them by default.
115
+
116
+ A value the compiler marked sensitive is left out even when a policy asks
117
+ for values, because marking it was the decision that it must not be shown.
118
+ """
119
+ if not policy.include_values:
120
+ return ()
121
+ return tuple(
122
+ value
123
+ for index, value in enumerate(statement.parameters)
124
+ if index not in statement.sensitive_parameter_indexes
125
+ )
126
+
127
+
128
+ __all__ = (
129
+ "CollectingSink",
130
+ "EventPolicy",
131
+ "EventSink",
132
+ "StatementEvent",
133
+ "StatementFailed",
134
+ "StatementFinished",
135
+ "StatementStarted",
136
+ "reportable_values",
137
+ )
@@ -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
+ )