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,167 @@
1
+ """Typed dependencies a route asks for.
2
+
3
+ Each one is a factory, so a route names the database it wants and gets back
4
+ something with a type rather than whatever was on the application object.
5
+
6
+ Asking twice for the same thing gives back the same dependency. FastAPI matches
7
+ an override by identity, so a factory that built a new function every call could
8
+ never be overridden, and a test would quietly keep talking to the real database.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import AsyncGenerator, Callable, Generator
14
+ from functools import cache
15
+ from typing import TYPE_CHECKING
16
+
17
+ from fastapi import Request
18
+ from pyoq.diagnostics.scoped import AsyncScopedOperations, ScopedOperations
19
+ from pyoq.errors import OperationUnavailableError
20
+ from pyoq.fastapi.lifespan import databases_of
21
+ from pyoq.query.execution import QueryOperations
22
+ from pyoq.query.execution.aio import AsyncQueryOperations
23
+ from pyoq.serving import (
24
+ DEFAULT,
25
+ SupportsAsyncTransaction,
26
+ SupportsTransaction,
27
+ )
28
+
29
+ # FastAPI resolves a dependency's annotations when the route is built, so the
30
+ # names in them have to exist at run time rather than only for a checker.
31
+ if TYPE_CHECKING:
32
+ from pyoq.diagnostics.budget import QueryBudget, QueryScope
33
+
34
+
35
+ @cache
36
+ def database(
37
+ name: str = DEFAULT,
38
+ /,
39
+ ) -> Callable[[Request], QueryOperations]:
40
+ """The database itself, for a route that runs no transaction of its own."""
41
+
42
+ def dependency(request: Request) -> QueryOperations:
43
+ return databases_of(request.app).synchronous(name)
44
+
45
+ return dependency
46
+
47
+
48
+ @cache
49
+ def async_database(
50
+ name: str = DEFAULT,
51
+ /,
52
+ ) -> Callable[[Request], AsyncQueryOperations]:
53
+ def dependency(request: Request) -> AsyncQueryOperations:
54
+ return databases_of(request.app).asynchronous(name)
55
+
56
+ return dependency
57
+
58
+
59
+ @cache
60
+ def transaction(
61
+ name: str = DEFAULT,
62
+ /,
63
+ ) -> Callable[[Request], Generator[QueryOperations]]:
64
+ """One transaction for the whole request, ended by how the request ended.
65
+
66
+ A route that returns commits. A route that raises rolls back, and so does a
67
+ request the client gave up on, because a dependency is torn down either way
68
+ and the transaction sees what tore it down.
69
+ """
70
+
71
+ def dependency(request: Request) -> Generator[QueryOperations]:
72
+ operations = databases_of(request.app).synchronous(name)
73
+ with _transactional(operations, name).transaction() as active:
74
+ yield active
75
+
76
+ return dependency
77
+
78
+
79
+ @cache
80
+ def async_transaction(
81
+ name: str = DEFAULT,
82
+ /,
83
+ ) -> Callable[[Request], AsyncGenerator[AsyncQueryOperations]]:
84
+ async def dependency(
85
+ request: Request,
86
+ ) -> AsyncGenerator[AsyncQueryOperations]:
87
+ operations = databases_of(request.app).asynchronous(name)
88
+ async with _async_transactional(operations, name).transaction() as active:
89
+ yield active
90
+
91
+ return dependency
92
+
93
+
94
+ @cache
95
+ def scoped(
96
+ budget: QueryBudget,
97
+ /,
98
+ name: str = DEFAULT,
99
+ ) -> Callable[[Request], QueryOperations]:
100
+ """A database that counts what this request runs, and refuses beyond it.
101
+
102
+ The scope belongs to the request, so one request cannot spend another's
103
+ budget. A statement the budget refuses never reaches the database.
104
+ """
105
+
106
+ def dependency(request: Request) -> QueryOperations:
107
+ return ScopedOperations(
108
+ databases_of(request.app).synchronous(name), _scope(budget)
109
+ )
110
+
111
+ return dependency
112
+
113
+
114
+ @cache
115
+ def async_scoped(
116
+ budget: QueryBudget,
117
+ /,
118
+ name: str = DEFAULT,
119
+ ) -> Callable[[Request], AsyncQueryOperations]:
120
+ def dependency(request: Request) -> AsyncQueryOperations:
121
+ return AsyncScopedOperations(
122
+ databases_of(request.app).asynchronous(name), _scope(budget)
123
+ )
124
+
125
+ return dependency
126
+
127
+
128
+ def _scope(budget: QueryBudget) -> QueryScope:
129
+ from pyoq.diagnostics.budget import QueryScope
130
+
131
+ return QueryScope(budget)
132
+
133
+
134
+ def _transactional(
135
+ operations: QueryOperations,
136
+ name: str,
137
+ ) -> SupportsTransaction:
138
+ if not isinstance(operations, SupportsTransaction):
139
+ raise _no_transactions(name)
140
+ return operations
141
+
142
+
143
+ def _async_transactional(
144
+ operations: AsyncQueryOperations,
145
+ name: str,
146
+ ) -> SupportsAsyncTransaction:
147
+ if not isinstance(operations, SupportsAsyncTransaction):
148
+ raise _no_transactions(name)
149
+ return operations
150
+
151
+
152
+ def _no_transactions(name: str) -> OperationUnavailableError:
153
+ message = (
154
+ f"the database named {name!r} opens no transactions of its own, so a "
155
+ f"request transaction cannot be started against it"
156
+ )
157
+ return OperationUnavailableError(message)
158
+
159
+
160
+ __all__ = (
161
+ "async_database",
162
+ "async_scoped",
163
+ "async_transaction",
164
+ "database",
165
+ "scoped",
166
+ "transaction",
167
+ )
@@ -0,0 +1,119 @@
1
+ """Opening the databases once, and giving them back once.
2
+
3
+ A pool opened per request is a pool that is never warm and never bounded. The
4
+ application owns them for as long as it runs, which is what a lifespan is for.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
10
+ from typing import TYPE_CHECKING
11
+
12
+ from pyoq.errors import PyOQError
13
+ from pyoq.serving import AsyncDatabase, Databases, SyncDatabase
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
17
+
18
+ from fastapi import FastAPI
19
+
20
+ STATE_ATTRIBUTE = "pyoq_databases"
21
+
22
+
23
+ def database_lifespan(
24
+ *,
25
+ synchronous: Mapping[str, Callable[[], SyncDatabase]] | None = None,
26
+ asynchronous: Mapping[str, Callable[[], Awaitable[AsyncDatabase]]] | None = None,
27
+ ) -> Callable[[FastAPI], AbstractAsyncContextManager[None]]:
28
+ """A lifespan that opens every named database and closes every one it opened.
29
+
30
+ A database that fails to open takes the application down, and the ones
31
+ already open are given back first. Starting up halfway and serving requests
32
+ against a partly opened application is worse than not starting.
33
+ """
34
+
35
+ @asynccontextmanager
36
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
37
+ opened_sync: dict[str, SyncDatabase] = {}
38
+ opened_async: dict[str, AsyncDatabase] = {}
39
+ try:
40
+ _open_synchronous(synchronous or {}, opened_sync)
41
+ await _open_asynchronous(asynchronous or {}, opened_async)
42
+ except BaseException:
43
+ await _close_all(opened_sync, opened_async)
44
+ raise
45
+ setattr(app.state, STATE_ATTRIBUTE, Databases(opened_sync, opened_async))
46
+ try:
47
+ yield
48
+ finally:
49
+ await _close_all(opened_sync, opened_async)
50
+
51
+ return lifespan
52
+
53
+
54
+ def _open_synchronous(
55
+ factories: Mapping[str, Callable[[], SyncDatabase]],
56
+ opened: dict[str, SyncDatabase],
57
+ ) -> None:
58
+ for name, factory in factories.items():
59
+ opened[name] = factory()
60
+
61
+
62
+ async def _open_asynchronous(
63
+ factories: Mapping[str, Callable[[], Awaitable[AsyncDatabase]]],
64
+ opened: dict[str, AsyncDatabase],
65
+ ) -> None:
66
+ for name, factory in factories.items():
67
+ opened[name] = await factory()
68
+
69
+
70
+ async def _close_all(
71
+ synchronous: dict[str, SyncDatabase],
72
+ asynchronous: dict[str, AsyncDatabase],
73
+ ) -> None:
74
+ """Give every database back, even when one of them refuses.
75
+
76
+ Stopping at the first failure would leave the rest open, and the process is
77
+ on its way out either way. The failures are collected so none is silent.
78
+ """
79
+ failures: list[BaseException] = []
80
+ for waiting in reversed(list(asynchronous.values())):
81
+ await _closed_async(waiting, failures)
82
+ for immediate in reversed(list(synchronous.values())):
83
+ _closed(immediate, failures)
84
+ synchronous.clear()
85
+ asynchronous.clear()
86
+ if failures:
87
+ raise BaseExceptionGroup("closing databases failed", failures)
88
+
89
+
90
+ def _closed(database: SyncDatabase, failures: list[BaseException]) -> None:
91
+ try:
92
+ database.close()
93
+ except PyOQError as error:
94
+ failures.append(error)
95
+
96
+
97
+ async def _closed_async(
98
+ database: AsyncDatabase,
99
+ failures: list[BaseException],
100
+ ) -> None:
101
+ try:
102
+ await database.close()
103
+ except PyOQError as error:
104
+ failures.append(error)
105
+
106
+
107
+ def databases_of(app: FastAPI, /) -> Databases:
108
+ """What the lifespan opened, or a clear word about it not having run."""
109
+ found = getattr(app.state, STATE_ATTRIBUTE, None)
110
+ if not isinstance(found, Databases):
111
+ message = (
112
+ "no PyOQ databases are open; give the application the lifespan "
113
+ "returned by database_lifespan()"
114
+ )
115
+ raise LookupError(message)
116
+ return found
117
+
118
+
119
+ __all__ = ("STATE_ATTRIBUTE", "database_lifespan", "databases_of")
@@ -0,0 +1,55 @@
1
+ """Carrying out a fetch plan against a database."""
2
+
3
+ from pyoq.fetching.collections import decode_collection, hydrate_collection
4
+ from pyoq.fetching.execution import (
5
+ AsyncFetchExecutor,
6
+ FetchAssembly,
7
+ FetchedRow,
8
+ FetchExecutor,
9
+ )
10
+ from pyoq.fetching.joined import join_condition, projection_layout
11
+ from pyoq.fetching.nesting import collection_for
12
+ from pyoq.fetching.plans import (
13
+ DeferredRelation,
14
+ FetchColumn,
15
+ FetchStatement,
16
+ JoinedRelation,
17
+ NestedJoin,
18
+ NestedRelation,
19
+ RelationBlock,
20
+ batch_statement,
21
+ columns_of,
22
+ root_statement,
23
+ )
24
+ from pyoq.fetching.select_in import (
25
+ KeyColumn,
26
+ batch_conditions,
27
+ key_condition,
28
+ select_in_conditions,
29
+ )
30
+
31
+ __all__ = (
32
+ "AsyncFetchExecutor",
33
+ "DeferredRelation",
34
+ "FetchAssembly",
35
+ "FetchColumn",
36
+ "FetchExecutor",
37
+ "FetchStatement",
38
+ "FetchedRow",
39
+ "JoinedRelation",
40
+ "KeyColumn",
41
+ "NestedJoin",
42
+ "NestedRelation",
43
+ "RelationBlock",
44
+ "batch_conditions",
45
+ "batch_statement",
46
+ "collection_for",
47
+ "columns_of",
48
+ "decode_collection",
49
+ "hydrate_collection",
50
+ "join_condition",
51
+ "key_condition",
52
+ "projection_layout",
53
+ "root_statement",
54
+ "select_in_conditions",
55
+ )
@@ -0,0 +1,136 @@
1
+ """Reading a nested collection back into rows.
2
+
3
+ The value a collection returns is JSON, and what a driver hands over differs:
4
+ two of the three return it as text and one has already decoded it. Both arrive
5
+ here, and neither is trusted to hold what it promised.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Sequence
12
+ from itertools import zip_longest
13
+ from typing import cast
14
+
15
+ from pyoq.errors import CollectionDecodeError
16
+ from pyoq.hydration.plan import RowConstructor
17
+ from pyoq.query.decoding import decode_value
18
+ from pyoq.relations import RelationValue
19
+
20
+ _MISSING = object()
21
+
22
+
23
+ def decode_collection(
24
+ payload: object,
25
+ keys: Sequence[str],
26
+ /,
27
+ types: tuple[type[object] | None, ...] = (),
28
+ ) -> tuple[tuple[object, ...], ...]:
29
+ """Read one collection into a row of values per element.
30
+
31
+ Values come back in the order the keys are given, so a caller reads them
32
+ the same way it reads any other row rather than by looking names up.
33
+
34
+ JSON spells a date as a string and a decimal as a number, so a caller that
35
+ says what its columns hold gets them back as that, the same as it would from
36
+ a column read directly. A caller that says nothing gets what JSON spelled.
37
+ """
38
+ _require_keys(keys)
39
+ elements = _elements(payload)
40
+ rows = tuple(_row(element, keys, index) for index, element in enumerate(elements))
41
+ if not types:
42
+ return rows
43
+ return tuple(_decoded(row, types) for row in rows)
44
+
45
+
46
+ def _elements(payload: object) -> list[object]:
47
+ value = _parsed(payload)
48
+ if value is None:
49
+ return []
50
+ if not isinstance(value, list):
51
+ message = (
52
+ f"a collection must hold a list of rows, and this holds "
53
+ f"{type(value).__name__}"
54
+ )
55
+ raise CollectionDecodeError(message)
56
+ return cast("list[object]", value)
57
+
58
+
59
+ def _parsed(payload: object) -> object:
60
+ """One driver returns text and another returns what it already decoded."""
61
+ if payload is None:
62
+ return None
63
+ if isinstance(payload, (str, bytes, bytearray)):
64
+ return _loaded(payload)
65
+ return payload
66
+
67
+
68
+ def _loaded(payload: str | bytes | bytearray) -> object:
69
+ try:
70
+ return json.loads(payload)
71
+ except ValueError as error:
72
+ message = f"a collection did not hold readable JSON: {error}"
73
+ raise CollectionDecodeError(message) from error
74
+
75
+
76
+ def _row(
77
+ element: object,
78
+ keys: Sequence[str],
79
+ index: int,
80
+ ) -> tuple[object, ...]:
81
+ if not isinstance(element, dict):
82
+ message = (
83
+ f"row {index} of a collection must be an object, and this is "
84
+ f"{type(element).__name__}"
85
+ )
86
+ raise CollectionDecodeError(message)
87
+ entries = cast("dict[str, object]", element)
88
+ return tuple(_value(entries, key, index) for key in keys)
89
+
90
+
91
+ def _value(entries: dict[str, object], key: str, index: int) -> object:
92
+ value = entries.get(key, _MISSING)
93
+ if value is _MISSING:
94
+ message = f"row {index} of a collection has no {key!r}"
95
+ raise CollectionDecodeError(message)
96
+ return value
97
+
98
+
99
+ def _require_keys(keys: Sequence[str]) -> None:
100
+ if not keys:
101
+ message = "a collection is read by naming at least one value"
102
+ raise CollectionDecodeError(message)
103
+ if len(set(keys)) != len(keys):
104
+ message = "a collection cannot be read by one name twice"
105
+ raise CollectionDecodeError(message)
106
+
107
+
108
+ def _decoded(
109
+ row: tuple[object, ...],
110
+ types: tuple[type[object] | None, ...],
111
+ ) -> tuple[object, ...]:
112
+ return tuple(
113
+ decode_value(value, target)
114
+ for value, target in zip_longest(row, types[: len(row)])
115
+ )
116
+
117
+
118
+ def hydrate_collection(
119
+ payload: object,
120
+ keys: Sequence[str],
121
+ construct: RowConstructor,
122
+ /,
123
+ types: tuple[type[object] | None, ...] = (),
124
+ ) -> RelationValue[object]:
125
+ """Read a collection into built rows, loaded and ready.
126
+
127
+ A collection that came back empty is a loaded relation holding nothing,
128
+ which is not the same as a relation nobody fetched.
129
+ """
130
+ rows = tuple(
131
+ construct(values, {}) for values in decode_collection(payload, keys, types)
132
+ )
133
+ return RelationValue.loaded(rows)
134
+
135
+
136
+ __all__ = ("decode_collection", "hydrate_collection")