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
pyoq/descriptors.py ADDED
@@ -0,0 +1,165 @@
1
+ """Runtime contracts used by generated database types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import ClassVar, Generic, TypeAlias, TypeVar
7
+
8
+ from pyoq.query import Expression, FieldNode, ScalarFamily
9
+ from pyoq.relations import RelationCardinality, RelationDirection
10
+ from pyoq.schema import JsonScalar as SchemaJsonScalar
11
+ from pyoq.schema import JsonValue as SchemaJsonValue
12
+ from pyoq.schema import KeyKind, ReferentialAction
13
+ from pyoq.unset import UNSET, UnsetType
14
+
15
+ Value = TypeVar("Value")
16
+ Row = TypeVar("Row", covariant=True)
17
+ Insert = TypeVar("Insert", covariant=True)
18
+ Update = TypeVar("Update", covariant=True)
19
+ SourceRow = TypeVar("SourceRow", covariant=True)
20
+ TargetRow = TypeVar("TargetRow", covariant=True)
21
+ JsonScalar: TypeAlias = SchemaJsonScalar
22
+ JsonValue: TypeAlias = SchemaJsonValue
23
+
24
+
25
+ class Missing:
26
+ __slots__ = ()
27
+
28
+
29
+ class Present:
30
+ __slots__ = ()
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ColumnDescriptor(Expression[Value], Generic[Value]):
35
+ database_name: str
36
+ nullable: bool
37
+ writable: bool
38
+ has_default: bool
39
+ generated: bool
40
+ scalar_family: ScalarFamily = ScalarFamily.OTHER
41
+ table_name: str | None = None
42
+ schema_name: str | None = None
43
+ catalog_name: str | None = None
44
+ field_name: str | None = None
45
+ value_type: type[object] | None = None
46
+
47
+ def __post_init__(self) -> None:
48
+ _require_name(self.database_name, "column")
49
+ self._initialize_expression(
50
+ FieldNode(
51
+ self.database_name,
52
+ self.table_name,
53
+ self.schema_name,
54
+ self.catalog_name,
55
+ self.scalar_family,
56
+ self.value_type,
57
+ self.nullable,
58
+ ),
59
+ self.scalar_family,
60
+ )
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class TableDescriptor(Generic[Row, Insert, Update]):
65
+ COLUMNS: ClassVar[tuple[object, ...]] = ()
66
+ """Every column this table has, in the order the table declares them.
67
+
68
+ Generation fills this in. A table written by hand names no columns, and
69
+ so cannot be selected from without saying which.
70
+ """
71
+
72
+ database_name: str
73
+ schema_name: str | None = None
74
+ catalog_name: str | None = None
75
+ row_type: type[Row] | None = None
76
+ """The class one row of this table reads back as.
77
+
78
+ Generation fills this in, so a table constant carries the type of what it
79
+ holds as well as its name. A table written by hand names no row class, and
80
+ so a row of it can only be read as the values it came back with.
81
+ """
82
+
83
+ def __post_init__(self) -> None:
84
+ _require_name(self.database_name, "table")
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class KeyDescriptor(Generic[Value]):
89
+ kind: KeyKind
90
+ column_names: tuple[str, ...]
91
+ database_name: str | None = None
92
+
93
+ def __post_init__(self) -> None:
94
+ if not self.column_names:
95
+ message = "key descriptor requires at least one column"
96
+ raise ValueError(message)
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class RelationshipDescriptor(Generic[SourceRow, TargetRow]):
101
+ source_table: str
102
+ source_columns: tuple[str, ...]
103
+ target_table: str
104
+ target_columns: tuple[str, ...]
105
+ source_schema: str | None = None
106
+ source_catalog: str | None = None
107
+ target_schema: str | None = None
108
+ target_catalog: str | None = None
109
+ """Where each side lives, because a table name alone names more than one.
110
+
111
+ Two schemas may each hold a `parent` and a `child` joined on the same
112
+ columns. Without these, a constant generated for one of them describes
113
+ both, and reading through it answers with whichever was found first.
114
+ """
115
+
116
+ database_name: str | None = None
117
+ on_update: ReferentialAction = ReferentialAction.NO_ACTION
118
+ on_delete: ReferentialAction = ReferentialAction.NO_ACTION
119
+ direction: RelationDirection = RelationDirection.FORWARD
120
+ cardinality: RelationCardinality = RelationCardinality.TO_ONE
121
+ optional: bool = False
122
+ target_row_type: type[TargetRow] | None = None
123
+ """The class one row of the table this reaches reads back as.
124
+
125
+ The same fact as a table constant's own, carried here so that following a
126
+ relation says what it arrives at without the caller naming it again.
127
+ """
128
+
129
+ def __post_init__(self) -> None:
130
+ _require_name(self.source_table, "relationship source table")
131
+ _require_name(self.target_table, "relationship target table")
132
+ if not self.source_columns or len(self.source_columns) != len(
133
+ self.target_columns
134
+ ):
135
+ message = "relationship descriptor columns must be non-empty and aligned"
136
+ raise ValueError(message)
137
+ if self.cardinality is RelationCardinality.TO_MANY and self.optional:
138
+ message = "a to-many relationship cannot be optional"
139
+ raise ValueError(message)
140
+
141
+ @property
142
+ def to_one(self) -> bool:
143
+ return self.cardinality is RelationCardinality.TO_ONE
144
+
145
+
146
+ def _require_name(value: str, label: str) -> None:
147
+ if not value:
148
+ message = f"{label} name cannot be empty"
149
+ raise ValueError(message)
150
+
151
+
152
+ __all__ = (
153
+ "UNSET",
154
+ "ColumnDescriptor",
155
+ "JsonScalar",
156
+ "JsonValue",
157
+ "KeyDescriptor",
158
+ "Missing",
159
+ "Present",
160
+ "RelationCardinality",
161
+ "RelationDirection",
162
+ "RelationshipDescriptor",
163
+ "TableDescriptor",
164
+ "UnsetType",
165
+ )
@@ -0,0 +1,68 @@
1
+ """Seeing what a scope asked a database to do."""
2
+
3
+ from pyoq.diagnostics.budget import QueryBudget, QueryScope
4
+ from pyoq.diagnostics.events import (
5
+ CollectingSink,
6
+ EventPolicy,
7
+ EventSink,
8
+ StatementEvent,
9
+ StatementFailed,
10
+ StatementFinished,
11
+ StatementStarted,
12
+ reportable_values,
13
+ )
14
+ from pyoq.diagnostics.fingerprint import (
15
+ CacheMetrics,
16
+ QueryShape,
17
+ forget_shapes,
18
+ normalize_sql,
19
+ query_shape,
20
+ shape_cache_metrics,
21
+ )
22
+ from pyoq.diagnostics.instrumented import (
23
+ AsyncInstrumentedOperations,
24
+ InstrumentedOperations,
25
+ )
26
+ from pyoq.diagnostics.metrics import DatabaseMetrics, metrics_of
27
+ from pyoq.diagnostics.observation import (
28
+ DEFAULT_REPEAT_THRESHOLD,
29
+ DEFAULT_SHAPE_LIMIT,
30
+ DEFAULT_SITE_LIMIT,
31
+ CallSite,
32
+ QueryObserver,
33
+ RepeatedQuery,
34
+ calling_site,
35
+ )
36
+ from pyoq.diagnostics.scoped import AsyncScopedOperations, ScopedOperations
37
+
38
+ __all__ = (
39
+ "DEFAULT_REPEAT_THRESHOLD",
40
+ "DEFAULT_SHAPE_LIMIT",
41
+ "DEFAULT_SITE_LIMIT",
42
+ "AsyncInstrumentedOperations",
43
+ "AsyncScopedOperations",
44
+ "CacheMetrics",
45
+ "CallSite",
46
+ "CollectingSink",
47
+ "DatabaseMetrics",
48
+ "EventPolicy",
49
+ "EventSink",
50
+ "InstrumentedOperations",
51
+ "QueryBudget",
52
+ "QueryObserver",
53
+ "QueryScope",
54
+ "QueryShape",
55
+ "RepeatedQuery",
56
+ "ScopedOperations",
57
+ "StatementEvent",
58
+ "StatementFailed",
59
+ "StatementFinished",
60
+ "StatementStarted",
61
+ "calling_site",
62
+ "forget_shapes",
63
+ "metrics_of",
64
+ "normalize_sql",
65
+ "query_shape",
66
+ "reportable_values",
67
+ "shape_cache_metrics",
68
+ )
@@ -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
+ )