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,587 @@
1
+ """Carrying out a resolved fetch plan against a database.
2
+
3
+ A plan is decided before anything runs and says how each relation is reached.
4
+ This runs it: one statement for the root and everything that can ride along
5
+ with it, and one statement per batch for each relation that cannot.
6
+
7
+ What comes back is uniform. However a row arrived, joined into its parent's
8
+ row, gathered as JSON beside it, or read by a statement of its own, it is a
9
+ row of its table in that table's own column order, carrying its own relations
10
+ the same way. Nothing above has to know which way a relation was fetched,
11
+ which is the reason for deciding it below them.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING, TypeAlias, TypeVar, cast
18
+
19
+ from pyoq.descriptors import TableDescriptor
20
+ from pyoq.errors import RelationNotLoadedError, RowIdentityError
21
+ from pyoq.fetching.collections import decode_collection
22
+ from pyoq.fetching.plans import batch_statement, root_statement
23
+ from pyoq.relations.batching import plan_key_batches
24
+ from pyoq.relations.loading import RelationValue
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Generator, Iterable, Mapping, Sequence
28
+
29
+ from pyoq.descriptors import RelationshipDescriptor
30
+ from pyoq.fetching.plans import (
31
+ DeferredRelation,
32
+ FetchStatement,
33
+ JoinedRelation,
34
+ NestedRelation,
35
+ Positions,
36
+ RelationBlock,
37
+ Values,
38
+ )
39
+ from pyoq.query.execution import QueryOperations
40
+ from pyoq.query.execution.aio import AsyncQueryOperations
41
+ from pyoq.query.expressions import Condition
42
+ from pyoq.query.select import SelectQuery
43
+ from pyoq.relations.graph import RelationGraph
44
+ from pyoq.relations.model import TypedRelation
45
+ from pyoq.relations.planning import ResolvedFetch, ResolvedFetchPlan
46
+ from pyoq.schema.models import Identifier, ObjectReference
47
+
48
+ Loaded = RelationValue[tuple["FetchedRow", ...]]
49
+
50
+
51
+ Row = TypeVar("Row")
52
+ Target = TypeVar("Target")
53
+ Held = TypeVar("Held")
54
+
55
+ Work: TypeAlias = "Generator[SelectQuery[Values], Sequence[Values], Held]"
56
+ """Work that asks for the rows of a query and is handed them back."""
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class FetchedRow:
61
+ """One row and everything fetched through it.
62
+
63
+ Every relation answers with rows, whatever its cardinality. A to-one holds
64
+ the single row it resolved to, or reads as absent. A to-many holds however
65
+ many there were, and none is an empty collection rather than an absence,
66
+ because a relation with no children was answered rather than unanswered.
67
+ """
68
+
69
+ table: ObjectReference
70
+ """The table these values came out of, which is what says how to read them.
71
+
72
+ A row carries it rather than being told it, because a caller naming a
73
+ table is making a claim and a row is the only thing that knows whether it
74
+ is true.
75
+ """
76
+
77
+ values: Values
78
+ related: Mapping[TypedRelation, RelationValue[tuple[FetchedRow, ...]]]
79
+
80
+ def read(self, table: TableDescriptor[Row, object, object], /) -> Row:
81
+ """This row as the class the schema generated to hold it.
82
+
83
+ The values came back in the order the table declares its columns,
84
+ which is the order that class names its fields, so the row it builds
85
+ holds each value under the name and the type the column was declared
86
+ with. A table written by hand names no row class and is told so.
87
+
88
+ The descriptor has to name the table this row came from. Reading a row
89
+ as a table it did not come from would put its values under names that
90
+ were never theirs, and the type checker would believe it.
91
+ """
92
+ _require_same_table(self.table, table)
93
+ return _built(table.row_type, table.database_name, self.values)
94
+
95
+ def read_all(
96
+ self,
97
+ relation: RelationshipDescriptor[object, Target],
98
+ /,
99
+ ) -> tuple[Target, ...]:
100
+ """The rows a relation resolved to, as the class that holds them.
101
+
102
+ None where a to-one resolved to nothing, and none where a to-many
103
+ resolved to no children, which are the two the relation itself tells
104
+ apart.
105
+ """
106
+ return tuple(
107
+ held.read(_target_of(relation))
108
+ for held in self.rows_of(_matching(self.related, relation))
109
+ )
110
+
111
+ def rows_of(self, relation: TypedRelation, /) -> tuple[FetchedRow, ...]:
112
+ """The rows a relation resolved to, which is none where it resolved to none.
113
+
114
+ A relation nobody fetched raises rather than reading as empty, which is
115
+ the difference `RelationValue` exists to keep.
116
+ """
117
+ held = self.related.get(relation)
118
+ if held is None:
119
+ return _unloaded().value or ()
120
+ return held.value or ()
121
+
122
+
123
+ class FetchAssembly:
124
+ """Carrying out a resolved plan, apart from running anything.
125
+
126
+ It asks for the rows of a query and is handed them back, so one traversal
127
+ serves a database that blocks and one that does not. Nothing here awaits
128
+ and nothing here connects, which is why there is one of it rather than two
129
+ that have to be kept saying the same thing.
130
+
131
+ The parameter limit is given rather than read from a compiler, because a
132
+ compiler is asked to compile and nothing else. It is the same limit a bulk
133
+ planner is built with, and it decides how many parents one select-in
134
+ statement can ask about.
135
+ """
136
+
137
+ __slots__ = ("_graph", "_maximum_parameters")
138
+
139
+ def __init__(self, graph: RelationGraph, /, *, maximum_parameters: int) -> None:
140
+ self._graph = graph
141
+ self._maximum_parameters = maximum_parameters
142
+
143
+ def rows_for(
144
+ self,
145
+ plan: ResolvedFetchPlan,
146
+ where: Condition | None,
147
+ ) -> Work[tuple[FetchedRow, ...]]:
148
+ """Every row the plan's root describes, with its relations loaded."""
149
+ statement = root_statement(plan, self._graph, where=where)
150
+ rows = yield statement.query
151
+ return (yield from self._rows(statement.block, rows))
152
+
153
+ def _rows(
154
+ self,
155
+ block: RelationBlock,
156
+ rows: Sequence[Values],
157
+ ) -> Work[tuple[FetchedRow, ...]]:
158
+ """One table's rows out of a result, each with its relations loaded.
159
+
160
+ Every relation is loaded across all the rows at once rather than row by
161
+ row. A relation reached from a joined table is still one statement for
162
+ the whole result, which is the difference between this and the access
163
+ pattern it exists to avoid.
164
+ """
165
+ values = tuple(_picked(row, block.positions) for row in rows)
166
+ held: tuple[dict[TypedRelation, Loaded], ...] = tuple({} for _ in rows)
167
+ for joined in block.joined:
168
+ yield from self._load_joined(joined, rows, held)
169
+ for nested in block.nested:
170
+ self._load_nested(nested, rows, held)
171
+ for deferred in block.deferred:
172
+ yield from self._load_deferred(deferred, values, held)
173
+ return tuple(
174
+ FetchedRow(block.table, own, entry)
175
+ for own, entry in zip(values, held, strict=True)
176
+ )
177
+
178
+ def _load_joined(
179
+ self,
180
+ joined: JoinedRelation,
181
+ rows: Sequence[Values],
182
+ held: Sequence[dict[TypedRelation, Loaded]],
183
+ ) -> Work[None]:
184
+ """A to-one relation, and whatever is fetched through it in turn.
185
+
186
+ A row that matched nothing reads as absent. The rest are carried on
187
+ together, so a relation hanging off the joined table is one statement
188
+ for every row that matched rather than one for each.
189
+ """
190
+ matched = tuple(
191
+ index
192
+ for index, row in enumerate(rows)
193
+ if not all(row[position] is None for position in joined.key_positions)
194
+ )
195
+ for entry in held:
196
+ entry[joined.fetch.relation] = RelationValue.absent()
197
+ children = yield from self._rows(
198
+ joined.block, tuple(rows[index] for index in matched)
199
+ )
200
+ for index, child in zip(matched, children, strict=True):
201
+ held[index][joined.fetch.relation] = RelationValue.loaded((child,))
202
+
203
+ def _load_nested(
204
+ self,
205
+ nested: NestedRelation,
206
+ rows: Sequence[Values],
207
+ held: Sequence[dict[TypedRelation, Loaded]],
208
+ ) -> None:
209
+ """A collection, read back out of the JSON it arrived as.
210
+
211
+ A collection is bounded and ordered where it is built, so its rows are
212
+ already the ones its limit kept. It carries no deeper fetch of its own:
213
+ a plan that reaches through a collection is resolved to select-in,
214
+ because a deeper fetch is keyed on rows rather than on JSON.
215
+
216
+ What a join added is inside the same value, after the collection's own
217
+ columns, so it is read out of each row rather than fetched again.
218
+ """
219
+ keys = nested.keys + tuple(key for join in nested.joined for key in join.keys)
220
+ own = len(nested.keys)
221
+ for entry, row in zip(held, rows, strict=True):
222
+ gathered = decode_collection(row[nested.position], keys)
223
+ entry[nested.fetch.relation] = _resolved(
224
+ nested.fetch.relation,
225
+ tuple(
226
+ FetchedRow(
227
+ nested.fetch.table,
228
+ values[:own],
229
+ _joined_into(nested, values, own),
230
+ )
231
+ for values in gathered
232
+ ),
233
+ )
234
+
235
+ def _load_deferred(
236
+ self,
237
+ deferred: DeferredRelation,
238
+ values: Sequence[Values],
239
+ held: Sequence[dict[TypedRelation, Loaded]],
240
+ ) -> Work[None]:
241
+ """A relation read by statements of its own, for every parent at once."""
242
+ gathered = yield from self._deferred(deferred, values)
243
+ for entry, own in zip(held, values, strict=True):
244
+ entry[deferred.fetch.relation] = _resolved(
245
+ deferred.fetch.relation,
246
+ gathered.get(_picked(own, deferred.key_positions), ()),
247
+ )
248
+
249
+ def _deferred(
250
+ self,
251
+ deferred: DeferredRelation,
252
+ parents: Sequence[Values],
253
+ ) -> Work[dict[Values, tuple[FetchedRow, ...]]]:
254
+ """One relation's rows for every parent, in as few statements as allowed.
255
+
256
+ A parent whose key holds null is not asked about. Null matches no row,
257
+ including another null, so asking would spend a parameter to be told
258
+ what is already known, and the predicate refuses one rather than
259
+ quietly returning nothing.
260
+ """
261
+ keys = _matchable(_picked(row, deferred.key_positions) for row in parents)
262
+ if not keys:
263
+ return {}
264
+ gathered: dict[Values, list[FetchedRow]] = {key: [] for key in keys}
265
+ for batch in plan_key_batches(
266
+ deferred.fetch.relation,
267
+ keys,
268
+ maximum_parameters=self._maximum_parameters,
269
+ ):
270
+ yield from self._gather(deferred.fetch, batch.keys, gathered)
271
+ return {
272
+ key: _limited(rows, deferred.fetch.limit) for key, rows in gathered.items()
273
+ }
274
+
275
+ def _gather(
276
+ self,
277
+ fetch: ResolvedFetch,
278
+ keys: Sequence[Values],
279
+ gathered: dict[Values, list[FetchedRow]],
280
+ ) -> Work[None]:
281
+ """Run one batch, and file each row under the parent key it belongs to.
282
+
283
+ Every row a batch returns matched one of the keys it asked about, so
284
+ every row has somewhere to go. A row that did not would be a key that
285
+ read back as a different value than it was sent as, and raising says
286
+ so rather than dropping the row and answering with less.
287
+ """
288
+ statement = batch_statement(fetch, self._graph, keys)
289
+ rows = yield statement.query
290
+ owners = _owners(statement, fetch, rows)
291
+ children = yield from self._rows(statement.block, rows)
292
+ for owner, child in zip(owners, children, strict=True):
293
+ gathered[owner].append(child)
294
+
295
+
296
+ class FetchExecutor:
297
+ """Runs the statements a resolved plan owes against a database.
298
+
299
+ The assembly decides what to run and what the answers mean. This hands it
300
+ each query and gives back the rows, which is the whole of the difference
301
+ between running a plan here and running one that does not block.
302
+ """
303
+
304
+ __slots__ = ("_assembly", "_operations")
305
+
306
+ def __init__(
307
+ self,
308
+ operations: QueryOperations,
309
+ graph: RelationGraph,
310
+ /,
311
+ *,
312
+ maximum_parameters: int,
313
+ ) -> None:
314
+ self._operations = operations
315
+ self._assembly = FetchAssembly(graph, maximum_parameters=maximum_parameters)
316
+
317
+ def fetch(
318
+ self,
319
+ plan: ResolvedFetchPlan,
320
+ /,
321
+ *,
322
+ where: Condition | None = None,
323
+ ) -> tuple[FetchedRow, ...]:
324
+ """Every row the plan's root describes, with its relations loaded."""
325
+ work = self._assembly.rows_for(plan, where)
326
+ try:
327
+ query = next(work)
328
+ while True:
329
+ query = work.send(self._operations.many(query))
330
+ except StopIteration as finished:
331
+ return _finished(finished)
332
+
333
+
334
+ class AsyncFetchExecutor:
335
+ """The same, against a database that does not block.
336
+
337
+ It shares the assembly rather than the code, so there is one answer to
338
+ what a plan means and two ways of asking a database.
339
+ """
340
+
341
+ __slots__ = ("_assembly", "_operations")
342
+
343
+ def __init__(
344
+ self,
345
+ operations: AsyncQueryOperations,
346
+ graph: RelationGraph,
347
+ /,
348
+ *,
349
+ maximum_parameters: int,
350
+ ) -> None:
351
+ self._operations = operations
352
+ self._assembly = FetchAssembly(graph, maximum_parameters=maximum_parameters)
353
+
354
+ async def fetch(
355
+ self,
356
+ plan: ResolvedFetchPlan,
357
+ /,
358
+ *,
359
+ where: Condition | None = None,
360
+ ) -> tuple[FetchedRow, ...]:
361
+ """Every row the plan's root describes, with its relations loaded."""
362
+ work = self._assembly.rows_for(plan, where)
363
+ try:
364
+ query = next(work)
365
+ while True:
366
+ query = work.send(await self._operations.many(query))
367
+ except StopIteration as finished:
368
+ return _finished(finished)
369
+
370
+
371
+ def _finished(finished: StopIteration) -> tuple[FetchedRow, ...]:
372
+ """What the assembly returned once it stopped asking for rows.
373
+
374
+ A generator's return value is untyped where it is caught, and this is the
375
+ one place that catches one.
376
+ """
377
+ return cast("tuple[FetchedRow, ...]", finished.value)
378
+
379
+
380
+ def _owners(
381
+ statement: FetchStatement,
382
+ fetch: ResolvedFetch,
383
+ rows: Sequence[Values],
384
+ ) -> tuple[Values, ...]:
385
+ """The parent key each row of a batch belongs to."""
386
+ order = {column.name: index for index, column in enumerate(statement.columns)}
387
+ positions = tuple(
388
+ order[name] for name in fetch.relation.target.columns if name in order
389
+ )
390
+ return tuple(_picked(row, positions) for row in rows)
391
+
392
+
393
+ def _unloaded() -> RelationValue[tuple[FetchedRow, ...]]:
394
+ return RelationValue[tuple["FetchedRow", ...]].unloaded()
395
+
396
+
397
+ def _matching(
398
+ related: Mapping[TypedRelation, RelationValue[tuple[FetchedRow, ...]]],
399
+ descriptor: RelationshipDescriptor[object, object],
400
+ ) -> TypedRelation:
401
+ """The relation a generated descriptor names, among the ones fetched.
402
+
403
+ A descriptor and a relation are the same fact written for two audiences,
404
+ so they are matched on what identifies one: which columns of which table
405
+ reach which columns of which other, and in which direction.
406
+ """
407
+ for relation in related:
408
+ if _same(relation, descriptor):
409
+ return relation
410
+ source = _written_parts(
411
+ descriptor.source_catalog, descriptor.source_schema, descriptor.source_table
412
+ )
413
+ target = _written_parts(
414
+ descriptor.target_catalog, descriptor.target_schema, descriptor.target_table
415
+ )
416
+ message = (
417
+ f"the relation from {source} to {target} was not fetched; include it "
418
+ "in the query's fetch plan to read it"
419
+ )
420
+ raise RelationNotLoadedError(message)
421
+
422
+
423
+ def _same(
424
+ relation: TypedRelation,
425
+ descriptor: RelationshipDescriptor[object, object],
426
+ ) -> bool:
427
+ """Whether a descriptor names this relation and no other.
428
+
429
+ Both tables are compared whole, schema and catalog included. Two schemas
430
+ may each hold a `parent` reaching a `child` over the same columns, and a
431
+ descriptor that named only the tables would describe both of them.
432
+ """
433
+ return (
434
+ _at(relation.source.table, descriptor.source_catalog, descriptor.source_schema)
435
+ and _at(
436
+ relation.target.table, descriptor.target_catalog, descriptor.target_schema
437
+ )
438
+ and relation.source.table.name.value == descriptor.source_table
439
+ and relation.target.table.name.value == descriptor.target_table
440
+ and _names(relation.source.columns) == descriptor.source_columns
441
+ and _names(relation.target.columns) == descriptor.target_columns
442
+ and relation.direction is descriptor.direction
443
+ )
444
+
445
+
446
+ def _at(table: ObjectReference, catalog: str | None, schema: str | None) -> bool:
447
+ return _named(table.catalog) == catalog and _named(table.schema) == schema
448
+
449
+
450
+ def _names(columns: tuple[Identifier, ...]) -> tuple[str, ...]:
451
+ return tuple(column.value for column in columns)
452
+
453
+
454
+ def _require_same_table(
455
+ produced: ObjectReference,
456
+ claimed: TableDescriptor[object, object, object],
457
+ ) -> None:
458
+ """Refuse a descriptor that names a table other than the one this came from.
459
+
460
+ Compared whole, catalog and schema included, because two schemas may hold
461
+ a table of one name and reading one as the other would be exactly the
462
+ mistake this is here to refuse.
463
+ """
464
+ if _describes(produced, claimed):
465
+ return
466
+ named = _written_parts(
467
+ claimed.catalog_name, claimed.schema_name, claimed.database_name
468
+ )
469
+ message = f"this row came from {_written(produced)} and is not a row of {named}"
470
+ raise RowIdentityError(message)
471
+
472
+
473
+ def _describes(
474
+ produced: ObjectReference,
475
+ claimed: TableDescriptor[object, object, object],
476
+ ) -> bool:
477
+ return (
478
+ produced.name.value == claimed.database_name
479
+ and _named(produced.schema) == claimed.schema_name
480
+ and _named(produced.catalog) == claimed.catalog_name
481
+ )
482
+
483
+
484
+ def _written(reference: ObjectReference) -> str:
485
+ return _written_parts(
486
+ _named(reference.catalog), _named(reference.schema), reference.name.value
487
+ )
488
+
489
+
490
+ def _written_parts(catalog: str | None, schema: str | None, table: str) -> str:
491
+ return ".".join(part for part in (catalog, schema, table) if part is not None)
492
+
493
+
494
+ def _named(value: Identifier | None) -> str | None:
495
+ return None if value is None else value.value
496
+
497
+
498
+ def _target_of(
499
+ relation: RelationshipDescriptor[object, Target],
500
+ ) -> TableDescriptor[Target, object, object]:
501
+ """The table a relation reaches, as the descriptor for reading its rows."""
502
+ return TableDescriptor(
503
+ relation.target_table,
504
+ relation.target_schema,
505
+ relation.target_catalog,
506
+ relation.target_row_type,
507
+ )
508
+
509
+
510
+ def _built(row_type: type[Row] | None, table: str, values: Values) -> Row:
511
+ """One row as its class, where the schema generated one to hold it."""
512
+ if row_type is None:
513
+ message = (
514
+ f"{table!r} names no row class, so a row of it reads back as the "
515
+ "values it came with; generate the package to read it as a row"
516
+ )
517
+ raise RowIdentityError(message)
518
+ return row_type(*values)
519
+
520
+
521
+ def _joined_into(
522
+ nested: NestedRelation,
523
+ values: Values,
524
+ own: int,
525
+ ) -> dict[TypedRelation, RelationValue[tuple[FetchedRow, ...]]]:
526
+ """What each row of a collection holds through the tables it was read with.
527
+
528
+ A join that matched nothing reads as nulls across its key, which is an
529
+ absence rather than a row of nulls, the same answer a join in the parent's
530
+ own statement gives.
531
+ """
532
+ held: dict[TypedRelation, RelationValue[tuple[FetchedRow, ...]]] = {}
533
+ taken = own
534
+ for join in nested.joined:
535
+ found = values[taken : taken + len(join.keys)]
536
+ taken += len(join.keys)
537
+ matched = any(found[position] is not None for position in join.key_positions)
538
+ held[join.relation] = _resolved(
539
+ join.relation,
540
+ (FetchedRow(join.relation.target.table, found, {}),) if matched else (),
541
+ )
542
+ return held
543
+
544
+
545
+ def _resolved(
546
+ relation: TypedRelation,
547
+ rows: tuple[FetchedRow, ...],
548
+ ) -> RelationValue[tuple[FetchedRow, ...]]:
549
+ """What a relation holds, once the rows it resolved to are known.
550
+
551
+ No rows means two different things. A to-one resolved to nothing, which is
552
+ an absence a caller can ask about. A to-many resolved to no children,
553
+ which is an answer: the collection is empty. Reading either as the other
554
+ loses the distinction `RelationValue` exists to keep.
555
+ """
556
+ if not rows and relation.to_one:
557
+ return RelationValue[tuple["FetchedRow", ...]].absent()
558
+ return RelationValue.loaded(rows)
559
+
560
+
561
+ def _matchable(keys: Iterable[Values]) -> tuple[Values, ...]:
562
+ """Every key that could match a row, once, in the order it was first seen.
563
+
564
+ A key holding null matches nothing, so it is not worth a parameter and is
565
+ not one the predicate accepts.
566
+ """
567
+ return _distinct(key for key in keys if all(value is not None for value in key))
568
+
569
+
570
+ def _limited(rows: Sequence[FetchedRow], limit: int | None) -> tuple[FetchedRow, ...]:
571
+ """The rows a limit keeps, which are the ones the order put first."""
572
+ return tuple(rows) if limit is None else tuple(rows[:limit])
573
+
574
+
575
+ def _distinct(keys: Iterable[Values]) -> tuple[Values, ...]:
576
+ """Every key once, in the order it was first seen."""
577
+ seen: dict[Values, None] = {}
578
+ for key in keys:
579
+ seen.setdefault(key, None)
580
+ return tuple(seen)
581
+
582
+
583
+ def _picked(row: Values, positions: Positions) -> Values:
584
+ return tuple(row[index] for index in positions)
585
+
586
+
587
+ __all__ = ("AsyncFetchExecutor", "FetchAssembly", "FetchExecutor", "FetchedRow")
@@ -0,0 +1,79 @@
1
+ """Fetching a to-one relation in the query that already reads its parent.
2
+
3
+ A to-one relation is one row the join carries anyway, so it costs no extra
4
+ statement. What it does cost is knowing where each table's values sit in the
5
+ result, which is what a projection layout records.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ from pyoq.errors import QueryValidationError
13
+ from pyoq.query.expressions import Condition
14
+ from pyoq.query.nodes import (
15
+ BinaryNode,
16
+ BinaryOperator,
17
+ ExpressionNode,
18
+ VariadicNode,
19
+ VariadicOperator,
20
+ )
21
+
22
+
23
+ def join_condition(
24
+ source: Sequence[ExpressionNode],
25
+ target: Sequence[ExpressionNode],
26
+ /,
27
+ ) -> Condition:
28
+ """Match a row to the one it points at, across every column of the key."""
29
+ _require_aligned(source, target)
30
+ equalities = tuple(
31
+ BinaryNode(BinaryOperator.EQUAL, left, right)
32
+ for left, right in zip(source, target, strict=True)
33
+ )
34
+ if len(equalities) == 1:
35
+ return Condition(equalities[0])
36
+ return Condition(VariadicNode(VariadicOperator.AND, equalities))
37
+
38
+
39
+ def projection_layout(widths: Sequence[int], /) -> tuple[tuple[int, ...], ...]:
40
+ """Where each table's values sit once its columns are laid end to end.
41
+
42
+ Hydration reads a result by position, so laying the projection out and
43
+ reading it back have to agree. Deriving one from the other keeps them from
44
+ drifting apart as a plan grows.
45
+ """
46
+ _require_widths(widths)
47
+ positions: list[tuple[int, ...]] = []
48
+ start = 0
49
+ for width in widths:
50
+ positions.append(tuple(range(start, start + width)))
51
+ start += width
52
+ return tuple(positions)
53
+
54
+
55
+ def _require_aligned(
56
+ source: Sequence[ExpressionNode],
57
+ target: Sequence[ExpressionNode],
58
+ ) -> None:
59
+ if not source:
60
+ message = "a join needs at least one column on each side"
61
+ raise QueryValidationError(message)
62
+ if len(source) != len(target):
63
+ message = (
64
+ f"a join matches {len(source)} columns against {len(target)}, "
65
+ f"which cannot line up"
66
+ )
67
+ raise QueryValidationError(message)
68
+
69
+
70
+ def _require_widths(widths: Sequence[int]) -> None:
71
+ if not widths:
72
+ message = "a projection layout needs at least one table"
73
+ raise QueryValidationError(message)
74
+ if any(width < 1 for width in widths):
75
+ message = "every table in a projection contributes at least one column"
76
+ raise QueryValidationError(message)
77
+
78
+
79
+ __all__ = ("join_condition", "projection_layout")