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,107 @@
1
+ """Bulk write planning bounded by a dialect parameter budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+
7
+ from pyoq.errors import CompilationError, ParameterLimitError, QueryValidationError
8
+ from pyoq.query.binding import (
9
+ bound_value_count,
10
+ projection_bound_value_count,
11
+ row_bound_value_count,
12
+ )
13
+ from pyoq.query.execution.batch import BulkChunk, BulkPlan, WriteProvider
14
+ from pyoq.query.nodes import ExpressionNode
15
+ from pyoq.query.statements import StatementCompiler
16
+ from pyoq.query.write_nodes import DeleteNode, InsertNode, UpdateNode, WriteNode
17
+
18
+
19
+ class BulkPlanner:
20
+ __slots__ = ("_compiler", "_dialect", "_maximum_parameters")
21
+
22
+ def __init__(
23
+ self,
24
+ compiler: StatementCompiler,
25
+ maximum_parameters: int,
26
+ dialect: str,
27
+ ) -> None:
28
+ self._compiler = compiler
29
+ self._maximum_parameters = maximum_parameters
30
+ self._dialect = dialect
31
+
32
+ def plan(self, statement: WriteProvider | WriteNode, /) -> BulkPlan:
33
+ node = _insert_node(statement)
34
+ if not node.rows:
35
+ message = "a bulk INSERT requires at least one row of values"
36
+ raise CompilationError(message)
37
+ limit = self._maximum_parameters
38
+ overhead = _suffix_bound_values(node)
39
+ if overhead > limit:
40
+ message = f"INSERT clauses alone exceed the {self._dialect} parameter limit"
41
+ raise ParameterLimitError(message)
42
+ return BulkPlan(tuple(self._chunks(node, limit, overhead)))
43
+
44
+ def _chunks(
45
+ self,
46
+ node: InsertNode,
47
+ limit: int,
48
+ overhead: int,
49
+ ) -> list[BulkChunk]:
50
+ chunks: list[BulkChunk] = []
51
+ start = 0
52
+ while start < len(node.rows):
53
+ length = _chunk_length(node.rows, start, limit, overhead, self._dialect)
54
+ rows = node.rows[start : start + length]
55
+ statement = self._compiler.compile(replace(node, rows=rows))
56
+ chunks.append(BulkChunk(statement, start, length))
57
+ start += length
58
+ return chunks
59
+
60
+
61
+ def _insert_node(statement: WriteProvider | WriteNode) -> InsertNode:
62
+ node = (
63
+ statement
64
+ if isinstance(statement, (InsertNode, UpdateNode, DeleteNode))
65
+ else statement.node
66
+ )
67
+ if not isinstance(node, InsertNode):
68
+ message = "bulk planning requires an INSERT statement"
69
+ raise QueryValidationError(message)
70
+ return node
71
+
72
+
73
+ def _chunk_length(
74
+ rows: tuple[tuple[ExpressionNode, ...], ...],
75
+ start: int,
76
+ limit: int,
77
+ overhead: int,
78
+ dialect: str,
79
+ ) -> int:
80
+ total = overhead
81
+ length = 0
82
+ for row in rows[start:]:
83
+ cost = row_bound_value_count(row)
84
+ if total + cost > limit:
85
+ if length == 0:
86
+ message = f"a single INSERT row exceeds the {dialect} parameter limit"
87
+ raise ParameterLimitError(message)
88
+ break
89
+ total += cost
90
+ length += 1
91
+ return length
92
+
93
+
94
+ def _suffix_bound_values(node: InsertNode) -> int:
95
+ total = projection_bound_value_count(node.returning)
96
+ conflict = node.conflict
97
+ if conflict is None:
98
+ return total
99
+ total += sum(
100
+ bound_value_count(assignment.value) for assignment in conflict.assignments
101
+ )
102
+ if conflict.condition is not None:
103
+ total += bound_value_count(conflict.condition)
104
+ return total
105
+
106
+
107
+ __all__ = ("BulkPlanner",)
@@ -0,0 +1,279 @@
1
+ """Exclusive synchronous connection pooling shared by every dialect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections import deque
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from threading import Condition, Lock
10
+ from time import monotonic
11
+ from typing import Generic, Self, TypeVar
12
+
13
+ from pyoq.errors import (
14
+ ConnectionPoolClosedError,
15
+ ConnectionPoolError,
16
+ ConnectionPoolTimeoutError,
17
+ QueryValidationError,
18
+ )
19
+
20
+ ConnectionType = TypeVar("ConnectionType")
21
+ ConnectionFactory = Callable[[], ConnectionType]
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class PoolPolicy:
26
+ maximum_size: int = 5
27
+ minimum_size: int = 0
28
+ checkout_timeout: float | None = 30.0
29
+
30
+ def __post_init__(self) -> None:
31
+ if not _positive_integer(self.maximum_size):
32
+ message = "connection pool maximum size must be a positive integer"
33
+ raise QueryValidationError(message)
34
+ if not _non_negative_integer(self.minimum_size):
35
+ message = "connection pool minimum size must be a non-negative integer"
36
+ raise QueryValidationError(message)
37
+ if self.minimum_size > self.maximum_size:
38
+ message = "connection pool minimum size cannot exceed maximum size"
39
+ raise QueryValidationError(message)
40
+ if not _valid_timeout(self.checkout_timeout):
41
+ message = "connection pool checkout timeout must be positive or None"
42
+ raise QueryValidationError(message)
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class PoolStats:
47
+ open_connections: int
48
+ idle_connections: int
49
+ checked_out_connections: int
50
+ closed: bool
51
+
52
+
53
+ class ConnectionLease(Generic[ConnectionType]):
54
+ __slots__ = ("_connection", "_entered", "_invalid", "_pool")
55
+
56
+ def __init__(self, pool: ConnectionPool[ConnectionType]) -> None:
57
+ self._pool = pool
58
+ self._connection: ConnectionType | None = None
59
+ self._entered = False
60
+ self._invalid = False
61
+
62
+ @property
63
+ def connection(self) -> ConnectionType:
64
+ if self._connection is None or not self._entered:
65
+ message = "connection lease is not active"
66
+ raise ConnectionPoolError(message)
67
+ return self._connection
68
+
69
+ def invalidate(self) -> None:
70
+ if not self._entered:
71
+ message = "inactive connection lease cannot be invalidated"
72
+ raise ConnectionPoolError(message)
73
+ self._invalid = True
74
+
75
+ def __enter__(self) -> ConnectionType:
76
+ if self._entered:
77
+ message = "connection lease cannot be entered twice"
78
+ raise ConnectionPoolError(message)
79
+ connection = self._pool.acquire_for_lease()
80
+ self._connection = connection
81
+ self._entered = True
82
+ return connection
83
+
84
+ def __exit__(
85
+ self,
86
+ exception_type: type[BaseException] | None,
87
+ exception: BaseException | None,
88
+ traceback: object,
89
+ ) -> None:
90
+ connection = self._connection
91
+ if connection is None or not self._entered:
92
+ return
93
+ self._entered = False
94
+ self._connection = None
95
+ self._pool.release_from_lease(connection, invalid=self._invalid)
96
+
97
+
98
+ class ConnectionPool(ABC, Generic[ConnectionType]):
99
+ __slots__ = (
100
+ "_checked_out",
101
+ "_closed",
102
+ "_condition",
103
+ "_factory",
104
+ "_idle",
105
+ "_open_connections",
106
+ "_policy",
107
+ )
108
+
109
+ def __init__(
110
+ self,
111
+ factory: ConnectionFactory[ConnectionType],
112
+ policy: PoolPolicy | None = None,
113
+ ) -> None:
114
+ self._factory = factory
115
+ self._policy = policy or PoolPolicy()
116
+ self._condition = Condition(Lock())
117
+ self._idle: deque[ConnectionType] = deque()
118
+ self._checked_out: set[int] = set()
119
+ self._open_connections = 0
120
+ self._closed = False
121
+ self._initialize_minimum()
122
+
123
+ @abstractmethod
124
+ def reset(self, connection: ConnectionType) -> bool: ...
125
+
126
+ @abstractmethod
127
+ def validate(self, value: object) -> ConnectionType: ...
128
+
129
+ @abstractmethod
130
+ def dispose(self, connection: ConnectionType) -> None: ...
131
+
132
+ @property
133
+ def policy(self) -> PoolPolicy:
134
+ return self._policy
135
+
136
+ @property
137
+ def stats(self) -> PoolStats:
138
+ with self._condition:
139
+ return PoolStats(
140
+ self._open_connections,
141
+ len(self._idle),
142
+ len(self._checked_out),
143
+ self._closed,
144
+ )
145
+
146
+ def checkout(self) -> ConnectionLease[ConnectionType]:
147
+ return ConnectionLease(self)
148
+
149
+ def close(self) -> None:
150
+ with self._condition:
151
+ if self._closed:
152
+ return
153
+ self._closed = True
154
+ idle = tuple(self._idle)
155
+ self._idle.clear()
156
+ self._open_connections -= len(idle)
157
+ self._condition.notify_all()
158
+ for connection in idle:
159
+ self.dispose(connection)
160
+
161
+ def __enter__(self) -> Self:
162
+ return self
163
+
164
+ def __exit__(
165
+ self,
166
+ exception_type: type[BaseException] | None,
167
+ exception: BaseException | None,
168
+ traceback: object,
169
+ ) -> None:
170
+ self.close()
171
+
172
+ def acquire_for_lease(self) -> ConnectionType:
173
+ deadline = _deadline(self.policy.checkout_timeout)
174
+ with self._condition:
175
+ while True:
176
+ self._require_open()
177
+ if self._idle:
178
+ connection = self._idle.popleft()
179
+ self._checked_out.add(id(connection))
180
+ return connection
181
+ if self._open_connections < self.policy.maximum_size:
182
+ self._open_connections += 1
183
+ break
184
+ self._wait_for_connection(deadline)
185
+ return self._create_reserved_connection()
186
+
187
+ def release_from_lease(
188
+ self,
189
+ connection: ConnectionType,
190
+ *,
191
+ invalid: bool,
192
+ ) -> None:
193
+ reusable = not invalid and self.reset(connection)
194
+ with self._condition:
195
+ self._checked_out.discard(id(connection))
196
+ if reusable and not self._closed:
197
+ self._idle.append(connection)
198
+ else:
199
+ self._open_connections -= 1
200
+ self._condition.notify()
201
+ if not reusable or self._closed:
202
+ self.dispose(connection)
203
+
204
+ def _initialize_minimum(self) -> None:
205
+ created: list[ConnectionType] = []
206
+ try:
207
+ for _ in range(self.policy.minimum_size):
208
+ created.append(self._create_connection())
209
+ except Exception:
210
+ for connection in created:
211
+ self.dispose(connection)
212
+ raise
213
+ self._idle.extend(created)
214
+ self._open_connections = len(created)
215
+
216
+ def _create_reserved_connection(self) -> ConnectionType:
217
+ try:
218
+ connection = self._create_connection()
219
+ except Exception:
220
+ with self._condition:
221
+ self._open_connections -= 1
222
+ self._condition.notify()
223
+ raise
224
+ with self._condition:
225
+ if self._closed:
226
+ self._open_connections -= 1
227
+ self.dispose(connection)
228
+ raise ConnectionPoolClosedError("connection pool is closed")
229
+ self._checked_out.add(id(connection))
230
+ return connection
231
+
232
+ def _create_connection(self) -> ConnectionType:
233
+ try:
234
+ connection_value: object = self._factory()
235
+ except ConnectionPoolError:
236
+ raise
237
+ except Exception as error:
238
+ message = f"unable to create pooled connection: {error}"
239
+ raise ConnectionPoolError(message) from error
240
+ return self.validate(connection_value)
241
+
242
+ def _wait_for_connection(self, deadline: float | None) -> None:
243
+ remaining = None if deadline is None else deadline - monotonic()
244
+ if remaining is not None and remaining <= 0:
245
+ message = "connection checkout exceeded its timeout"
246
+ raise ConnectionPoolTimeoutError(message)
247
+ self._condition.wait(remaining)
248
+
249
+ def _require_open(self) -> None:
250
+ if self._closed:
251
+ message = "connection pool is closed"
252
+ raise ConnectionPoolClosedError(message)
253
+
254
+
255
+ def _deadline(timeout: float | None) -> float | None:
256
+ return None if timeout is None else monotonic() + timeout
257
+
258
+
259
+ def _positive_integer(value: object) -> bool:
260
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
261
+
262
+
263
+ def _non_negative_integer(value: object) -> bool:
264
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
265
+
266
+
267
+ def _valid_timeout(value: object) -> bool:
268
+ return value is None or (
269
+ isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
270
+ )
271
+
272
+
273
+ __all__ = (
274
+ "ConnectionFactory",
275
+ "ConnectionLease",
276
+ "ConnectionPool",
277
+ "PoolPolicy",
278
+ "PoolStats",
279
+ )
@@ -0,0 +1,36 @@
1
+ """Immutable execution result contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+ from pyoq.errors import QueryValidationError
9
+
10
+
11
+ class StatementKind(Enum):
12
+ UNKNOWN = "unknown"
13
+ SELECT = "select"
14
+ INSERT = "insert"
15
+ UPDATE = "update"
16
+ DELETE = "delete"
17
+ DEFINITION = "definition"
18
+ CALL = "call"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ExecuteResult:
23
+ rows_affected: int
24
+ last_inserted_id: int | None = None
25
+
26
+ def __post_init__(self) -> None:
27
+ if not _is_non_negative_integer(self.rows_affected):
28
+ message = "affected row count must be a non-negative integer"
29
+ raise QueryValidationError(message)
30
+
31
+
32
+ def _is_non_negative_integer(value: object) -> bool:
33
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
34
+
35
+
36
+ __all__ = ("ExecuteResult", "StatementKind")
@@ -0,0 +1,178 @@
1
+ """Bounded synchronous row streaming shared by every dialect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from collections.abc import Callable, Iterator
7
+ from dataclasses import dataclass
8
+ from threading import get_ident
9
+ from typing import Generic, Protocol, Self, TypeVar, cast
10
+
11
+ from pyoq.errors import QueryValidationError, StreamStateError
12
+ from pyoq.query.decoding import decode_rows
13
+ from pyoq.query.execution.control import ExecutionControl
14
+
15
+ ResultRow = TypeVar("ResultRow")
16
+
17
+
18
+ class CursorSession(Protocol):
19
+ def checkpoint(self) -> None: ...
20
+
21
+ def fetch_batch(self, size: int) -> list[tuple[object, ...]]: ...
22
+
23
+ def close(self) -> None: ...
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class StreamPolicy:
28
+ batch_size: int = 256
29
+ control: ExecutionControl | None = None
30
+
31
+ def __post_init__(self) -> None:
32
+ if not _positive_integer(self.batch_size):
33
+ message = "stream batch size must be a positive integer"
34
+ raise QueryValidationError(message)
35
+
36
+
37
+ class StreamResource:
38
+ __slots__ = ("_closed", "_release", "session")
39
+
40
+ def __init__(
41
+ self,
42
+ session: CursorSession,
43
+ release: Callable[[], None],
44
+ ) -> None:
45
+ self.session = session
46
+ self._release = release
47
+ self._closed = False
48
+
49
+ def close(self) -> None:
50
+ if self._closed:
51
+ return
52
+ self._closed = True
53
+ try:
54
+ self.session.close()
55
+ finally:
56
+ self._release()
57
+
58
+
59
+ class RowStream(Iterator[ResultRow], Generic[ResultRow]):
60
+ __slots__ = (
61
+ "_buffer",
62
+ "_closed",
63
+ "_opened",
64
+ "_opener",
65
+ "_owner_thread",
66
+ "_policy",
67
+ "_resource",
68
+ "_row_types",
69
+ )
70
+
71
+ def __init__(
72
+ self,
73
+ opener: Callable[[], StreamResource],
74
+ policy: StreamPolicy,
75
+ row_types: tuple[type[object] | None, ...] = (),
76
+ ) -> None:
77
+ self._opener = opener
78
+ self._policy = policy
79
+ self._row_types = row_types
80
+ self._buffer: deque[tuple[object, ...]] = deque()
81
+ self._resource: StreamResource | None = None
82
+ self._owner_thread: int | None = None
83
+ self._opened = False
84
+ self._closed = False
85
+
86
+ @property
87
+ def closed(self) -> bool:
88
+ return self._closed
89
+
90
+ @property
91
+ def buffered_rows(self) -> int:
92
+ return len(self._buffer)
93
+
94
+ def __enter__(self) -> Self:
95
+ if self._closed:
96
+ raise StreamStateError("closed row stream cannot be entered")
97
+ self._ensure_open()
98
+ return self
99
+
100
+ def __exit__(
101
+ self,
102
+ exception_type: type[BaseException] | None,
103
+ exception: BaseException | None,
104
+ traceback: object,
105
+ ) -> None:
106
+ self.close()
107
+
108
+ def __iter__(self) -> Self:
109
+ return self
110
+
111
+ def __next__(self) -> ResultRow:
112
+ if self._closed:
113
+ raise StopIteration
114
+ self._ensure_open()
115
+ self._require_owner()
116
+ try:
117
+ resource = self._require_resource()
118
+ resource.session.checkpoint()
119
+ if not self._buffer:
120
+ self._load_batch()
121
+ return cast(ResultRow, self._buffer.popleft())
122
+ except BaseException:
123
+ self.close()
124
+ raise
125
+
126
+ def close(self) -> None:
127
+ if self._closed:
128
+ return
129
+ if self._opened:
130
+ self._require_owner()
131
+ self._closed = True
132
+ self._buffer.clear()
133
+ resource = self._resource
134
+ self._resource = None
135
+ if resource is not None:
136
+ resource.close()
137
+
138
+ def _ensure_open(self) -> None:
139
+ if self._opened:
140
+ self._require_owner()
141
+ return
142
+ self._owner_thread = get_ident()
143
+ try:
144
+ self._resource = self._opener()
145
+ except BaseException:
146
+ self._closed = True
147
+ raise
148
+ self._opened = True
149
+
150
+ def _load_batch(self) -> None:
151
+ resource = self._require_resource()
152
+ rows = resource.session.fetch_batch(self._policy.batch_size)
153
+ if not rows:
154
+ raise StopIteration
155
+ self._buffer.extend(
156
+ cast("list[tuple[object, ...]]", decode_rows(rows, self._row_types))
157
+ )
158
+
159
+ def _require_resource(self) -> StreamResource:
160
+ if self._resource is None:
161
+ raise StreamStateError("row stream has no active cursor")
162
+ return self._resource
163
+
164
+ def _require_owner(self) -> None:
165
+ if self._owner_thread != get_ident():
166
+ raise StreamStateError("row stream used outside its owner thread")
167
+
168
+
169
+ def _positive_integer(value: object) -> bool:
170
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
171
+
172
+
173
+ __all__ = (
174
+ "CursorSession",
175
+ "RowStream",
176
+ "StreamPolicy",
177
+ "StreamResource",
178
+ )
@@ -0,0 +1,95 @@
1
+ """Transaction scope, ownership, and savepoint state shared by every dialect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from threading import get_ident
6
+ from typing import Generic, TypeVar, cast
7
+
8
+ from pyoq.errors import TransactionStateError
9
+ from pyoq.query.execution.pooling import ConnectionLease
10
+ from pyoq.query.execution.streaming import RowStream
11
+
12
+ ConnectionType = TypeVar("ConnectionType")
13
+ ScopeType = TypeVar("ScopeType")
14
+
15
+ _SAVEPOINT_PREFIX = "pyoq_savepoint_"
16
+
17
+
18
+ class TransactionState(Generic[ConnectionType, ScopeType]):
19
+ __slots__ = (
20
+ "active_stream",
21
+ "broken",
22
+ "connection",
23
+ "current_scope",
24
+ "lease",
25
+ "owner_thread",
26
+ "savepoint_sequence",
27
+ )
28
+
29
+ def __init__(self) -> None:
30
+ self.lease: ConnectionLease[ConnectionType] | None = None
31
+ self.connection: ConnectionType | None = None
32
+ self.owner_thread: int | None = None
33
+ self.current_scope: ScopeType | None = None
34
+ self.active_stream: RowStream[object] | None = None
35
+ self.savepoint_sequence = 0
36
+ self.broken = False
37
+
38
+ def require_current(
39
+ self,
40
+ scope: ScopeType,
41
+ *,
42
+ allow_stream: bool = False,
43
+ ) -> ConnectionType:
44
+ connection = self.require_scope(scope)
45
+ if self.broken:
46
+ raise TransactionStateError("transaction connection is unusable")
47
+ if self.active_stream is not None and not allow_stream:
48
+ raise TransactionStateError("transaction has an active row stream")
49
+ return connection
50
+
51
+ def require_scope(self, scope: ScopeType) -> ConnectionType:
52
+ if self.connection is None or self.current_scope is not scope:
53
+ message = "only the active innermost transaction may be used"
54
+ raise TransactionStateError(message)
55
+ if self.owner_thread != get_ident():
56
+ raise TransactionStateError("transaction used outside its owner thread")
57
+ return self.connection
58
+
59
+ def require_owner(self) -> ConnectionType:
60
+ if self.owner_thread != get_ident():
61
+ raise TransactionStateError("transaction is not active")
62
+ return cast(ConnectionType, self.connection)
63
+
64
+ def invalidate(self) -> None:
65
+ cast(ConnectionLease[ConnectionType], self.lease).invalidate()
66
+ self.broken = True
67
+
68
+ def next_savepoint(self) -> str:
69
+ self.savepoint_sequence += 1
70
+ return f"{_SAVEPOINT_PREFIX}{self.savepoint_sequence}"
71
+
72
+ def close_stream(self) -> None:
73
+ stream = self.active_stream
74
+ if stream is not None:
75
+ stream.close()
76
+
77
+ def release_stream(self) -> None:
78
+ self.active_stream = None
79
+
80
+ def adopt(self, lease: ConnectionLease[ConnectionType], scope: ScopeType) -> None:
81
+ self.lease = lease
82
+ self.connection = lease.__enter__()
83
+ self.owner_thread = get_ident()
84
+ self.current_scope = scope
85
+
86
+ def release(self) -> None:
87
+ lease = self.lease
88
+ self.current_scope = None
89
+ self.connection = None
90
+ self.lease = None
91
+ self.owner_thread = None
92
+ cast(ConnectionLease[ConnectionType], lease).__exit__(None, None, None)
93
+
94
+
95
+ __all__ = ("TransactionState",)