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,245 @@
1
+ """Navigating a schema's relations from any table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ from types import MappingProxyType
8
+
9
+ from pyoq.errors import RelationResolutionError, SchemaValidationError
10
+ from pyoq.relations.derivation import table_relations
11
+ from pyoq.relations.model import (
12
+ RelationCardinality,
13
+ RelationEndpoint,
14
+ TypedRelation,
15
+ )
16
+ from pyoq.schema import Identifier, ObjectReference, SchemaSnapshot, Table
17
+
18
+
19
+ @dataclass(frozen=True, slots=True, eq=False)
20
+ class RelationGraph:
21
+ """Every relation in a snapshot, indexed by the table it starts from.
22
+
23
+ Endpoints and lookups are both resolved to the canonical reference of the
24
+ table they name, so a partly qualified name reaches the table it means
25
+ without the caller having to know how the snapshot qualified it.
26
+
27
+ A graph is an index over a snapshot rather than a value, so it compares and
28
+ hashes by identity. Comparing two indexes of the same snapshot is not a
29
+ question worth asking, and claiming a hash it could not produce would be
30
+ worse than not offering one.
31
+ """
32
+
33
+ _tables: Mapping[ObjectReference, Table]
34
+ _relations: Mapping[ObjectReference, tuple[TypedRelation, ...]]
35
+ _unresolved: tuple[ObjectReference, ...]
36
+
37
+ @property
38
+ def tables(self) -> tuple[ObjectReference, ...]:
39
+ return tuple(self._tables)
40
+
41
+ @property
42
+ def unresolved_targets(self) -> tuple[ObjectReference, ...]:
43
+ """Tables a relation names that this snapshot does not describe.
44
+
45
+ A snapshot covers one database, and a foreign key may point outside it,
46
+ so these are reported rather than treated as corruption.
47
+ """
48
+ return self._unresolved
49
+
50
+ def table(self, reference: ObjectReference, /) -> Table:
51
+ table = self._tables.get(self.resolve(reference))
52
+ if table is None:
53
+ raise RelationResolutionError(_missing_table_message(reference))
54
+ return table
55
+
56
+ def resolve(self, reference: ObjectReference, /) -> ObjectReference:
57
+ """Name a table the way this snapshot names it."""
58
+ return _canonical_reference(reference, self._tables)
59
+
60
+ def relations_from(
61
+ self,
62
+ reference: ObjectReference,
63
+ /,
64
+ ) -> tuple[TypedRelation, ...]:
65
+ return self._relations.get(self.resolve(reference), ())
66
+
67
+ def to_one_from(
68
+ self,
69
+ reference: ObjectReference,
70
+ /,
71
+ ) -> tuple[TypedRelation, ...]:
72
+ return self._with_cardinality(reference, RelationCardinality.TO_ONE)
73
+
74
+ def to_many_from(
75
+ self,
76
+ reference: ObjectReference,
77
+ /,
78
+ ) -> tuple[TypedRelation, ...]:
79
+ return self._with_cardinality(reference, RelationCardinality.TO_MANY)
80
+
81
+ def target_table(self, relation: TypedRelation, /) -> Table:
82
+ return self.table(relation.target.table)
83
+
84
+ def _with_cardinality(
85
+ self,
86
+ reference: ObjectReference,
87
+ cardinality: RelationCardinality,
88
+ ) -> tuple[TypedRelation, ...]:
89
+ return tuple(
90
+ relation
91
+ for relation in self.relations_from(reference)
92
+ if relation.cardinality is cardinality
93
+ )
94
+
95
+
96
+ def build_relation_graph(snapshot: SchemaSnapshot) -> RelationGraph:
97
+ tables = _table_index(snapshot)
98
+ relations = tuple(
99
+ _canonical(relation, tables)
100
+ for catalog in snapshot.catalogs
101
+ for schema in catalog.schemas
102
+ for table in schema.tables
103
+ for relation in table_relations(catalog, schema, table)
104
+ )
105
+ _require_known_columns(relations, tables)
106
+ return RelationGraph(
107
+ MappingProxyType(dict(tables)),
108
+ MappingProxyType(_grouped(relations)),
109
+ _unresolved_targets(relations, tables),
110
+ )
111
+
112
+
113
+ def _table_index(snapshot: SchemaSnapshot) -> dict[ObjectReference, Table]:
114
+ return {
115
+ ObjectReference(table.name, schema.name, catalog.name): table
116
+ for catalog in snapshot.catalogs
117
+ for schema in catalog.schemas
118
+ for table in schema.tables
119
+ }
120
+
121
+
122
+ def _canonical(
123
+ relation: TypedRelation,
124
+ tables: Mapping[ObjectReference, Table],
125
+ ) -> TypedRelation:
126
+ source = _canonical_endpoint(relation.source, tables)
127
+ target = _canonical_endpoint(relation.target, tables)
128
+ if source is relation.source and target is relation.target:
129
+ return relation
130
+ return TypedRelation(
131
+ relation.direction,
132
+ relation.cardinality,
133
+ source,
134
+ target,
135
+ optional=relation.optional,
136
+ constraint=relation.constraint,
137
+ on_update=relation.on_update,
138
+ on_delete=relation.on_delete,
139
+ )
140
+
141
+
142
+ def _canonical_endpoint(
143
+ endpoint: RelationEndpoint,
144
+ tables: Mapping[ObjectReference, Table],
145
+ ) -> RelationEndpoint:
146
+ reference = _canonical_reference(endpoint.table, tables)
147
+ if reference is endpoint.table:
148
+ return endpoint
149
+ return RelationEndpoint(reference, endpoint.columns)
150
+
151
+
152
+ def _canonical_reference(
153
+ reference: ObjectReference,
154
+ tables: Mapping[ObjectReference, Table],
155
+ ) -> ObjectReference:
156
+ if reference in tables:
157
+ return reference
158
+ matches = tuple(
159
+ candidate
160
+ for candidate in tables
161
+ if candidate.name == reference.name
162
+ and _qualification_matches(reference, candidate)
163
+ )
164
+ return matches[0] if len(matches) == 1 else reference
165
+
166
+
167
+ def _qualification_matches(
168
+ reference: ObjectReference,
169
+ candidate: ObjectReference,
170
+ ) -> bool:
171
+ """An unqualified name means whichever table carries it, if only one does."""
172
+ return (reference.schema is None or reference.schema == candidate.schema) and (
173
+ reference.catalog is None or reference.catalog == candidate.catalog
174
+ )
175
+
176
+
177
+ def _require_known_columns(
178
+ relations: Iterable[TypedRelation],
179
+ tables: Mapping[ObjectReference, Table],
180
+ ) -> None:
181
+ for relation in relations:
182
+ for endpoint in (relation.source, relation.target):
183
+ table = tables.get(endpoint.table)
184
+ if table is not None:
185
+ _require_columns(table, endpoint)
186
+
187
+
188
+ def _require_columns(table: Table, endpoint: RelationEndpoint) -> None:
189
+ known = frozenset(column.name for column in table.columns)
190
+ missing = tuple(column for column in endpoint.columns if column not in known)
191
+ if missing:
192
+ raise SchemaValidationError(_missing_column_message(table.name, missing))
193
+
194
+
195
+ def _unresolved_targets(
196
+ relations: Iterable[TypedRelation],
197
+ tables: Mapping[ObjectReference, Table],
198
+ ) -> tuple[ObjectReference, ...]:
199
+ unresolved = {
200
+ relation.target.table
201
+ for relation in relations
202
+ if relation.target.table not in tables
203
+ }
204
+ return tuple(sorted(unresolved, key=_reference_order))
205
+
206
+
207
+ def _grouped(
208
+ relations: Iterable[TypedRelation],
209
+ ) -> dict[ObjectReference, tuple[TypedRelation, ...]]:
210
+ grouped: dict[ObjectReference, list[TypedRelation]] = {}
211
+ for relation in relations:
212
+ grouped.setdefault(relation.source.table, []).append(relation)
213
+ return {reference: tuple(items) for reference, items in grouped.items()}
214
+
215
+
216
+ def _reference_order(reference: ObjectReference) -> tuple[str, str, str]:
217
+ return (
218
+ reference.catalog.value if reference.catalog is not None else "",
219
+ reference.schema.value if reference.schema is not None else "",
220
+ reference.name.value,
221
+ )
222
+
223
+
224
+ def _missing_table_message(reference: ObjectReference) -> str:
225
+ return f"the relation graph holds no table named {_describe(reference)}"
226
+
227
+
228
+ def _missing_column_message(
229
+ table: Identifier,
230
+ missing: tuple[Identifier, ...],
231
+ ) -> str:
232
+ names = ", ".join(column.value for column in missing)
233
+ return f"table {table.value!r} has no column named {names}"
234
+
235
+
236
+ def _describe(reference: ObjectReference) -> str:
237
+ parts = tuple(
238
+ part.value
239
+ for part in (reference.catalog, reference.schema, reference.name)
240
+ if part is not None
241
+ )
242
+ return ".".join(parts)
243
+
244
+
245
+ __all__ = ("RelationGraph", "build_relation_graph")
@@ -0,0 +1,74 @@
1
+ """What a relation holds after a fetch, including having not been fetched."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import Generic, TypeVar
8
+
9
+ from pyoq.errors import RelationNotLoadedError
10
+
11
+ Value = TypeVar("Value")
12
+ Loadable = TypeVar("Loadable")
13
+
14
+
15
+ class RelationState(StrEnum):
16
+ LOADED = "loaded"
17
+ ABSENT = "absent"
18
+ UNLOADED = "unloaded"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class RelationValue(Generic[Value]):
23
+ """A relation is loaded, known to be absent, or was never fetched.
24
+
25
+ Rows are detached values, so reading a relation never reaches a database.
26
+ That makes the third state necessary: without it, a relation nobody asked
27
+ for would be indistinguishable from one that resolved to nothing, and the
28
+ caller would read absence where it should read ignorance.
29
+ """
30
+
31
+ state: RelationState
32
+ _value: Value | None = None
33
+
34
+ @staticmethod
35
+ def loaded(value: Loadable, /) -> RelationValue[Loadable]:
36
+ return RelationValue(RelationState.LOADED, value)
37
+
38
+ @staticmethod
39
+ def absent() -> RelationValue[Value]:
40
+ return RelationValue(RelationState.ABSENT)
41
+
42
+ @staticmethod
43
+ def unloaded() -> RelationValue[Value]:
44
+ return RelationValue(RelationState.UNLOADED)
45
+
46
+ @property
47
+ def is_loaded(self) -> bool:
48
+ return self.state is RelationState.LOADED
49
+
50
+ @property
51
+ def is_absent(self) -> bool:
52
+ return self.state is RelationState.ABSENT
53
+
54
+ @property
55
+ def is_unloaded(self) -> bool:
56
+ return self.state is RelationState.UNLOADED
57
+
58
+ @property
59
+ def value(self) -> Value | None:
60
+ """The related value, or nothing when the relation resolved to nothing.
61
+
62
+ Reading a relation nobody fetched raises rather than answering, because
63
+ any answer would be a guess and no answer can be produced without I/O.
64
+ """
65
+ if self.state is RelationState.UNLOADED:
66
+ message = (
67
+ "this relation was not fetched; include it in the query's fetch "
68
+ "plan to read it"
69
+ )
70
+ raise RelationNotLoadedError(message)
71
+ return self._value
72
+
73
+
74
+ __all__ = ("RelationState", "RelationValue")
@@ -0,0 +1,75 @@
1
+ """Typed relation facts derived from a schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+ from pyoq.errors import SchemaValidationError
9
+ from pyoq.schema import Identifier, ObjectReference, ReferentialAction
10
+
11
+
12
+ class RelationCardinality(StrEnum):
13
+ TO_ONE = "to-one"
14
+ TO_MANY = "to-many"
15
+
16
+
17
+ class RelationDirection(StrEnum):
18
+ """Which way a foreign key is being followed.
19
+
20
+ A foreign key is one fact that can be read from either side, so both
21
+ directions are derived from it rather than declared separately.
22
+ """
23
+
24
+ FORWARD = "forward"
25
+ REVERSE = "reverse"
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class RelationEndpoint:
30
+ table: ObjectReference
31
+ columns: tuple[Identifier, ...]
32
+
33
+ def __post_init__(self) -> None:
34
+ if not self.columns:
35
+ message = "relation endpoint requires at least one column"
36
+ raise SchemaValidationError(message)
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class TypedRelation:
41
+ """One navigable direction of one foreign key.
42
+
43
+ ``optional`` describes whether a to-one relation may resolve to nothing. A
44
+ to-many relation is never optional, because the absence of children is an
45
+ empty collection rather than a missing value.
46
+ """
47
+
48
+ direction: RelationDirection
49
+ cardinality: RelationCardinality
50
+ source: RelationEndpoint
51
+ target: RelationEndpoint
52
+ optional: bool = False
53
+ constraint: Identifier | None = None
54
+ on_update: ReferentialAction = ReferentialAction.NO_ACTION
55
+ on_delete: ReferentialAction = ReferentialAction.NO_ACTION
56
+
57
+ def __post_init__(self) -> None:
58
+ if len(self.source.columns) != len(self.target.columns):
59
+ message = "relation source and target column counts must match"
60
+ raise SchemaValidationError(message)
61
+ if self.cardinality is RelationCardinality.TO_MANY and self.optional:
62
+ message = "a to-many relation cannot be optional"
63
+ raise SchemaValidationError(message)
64
+
65
+ @property
66
+ def to_one(self) -> bool:
67
+ return self.cardinality is RelationCardinality.TO_ONE
68
+
69
+
70
+ __all__ = (
71
+ "RelationCardinality",
72
+ "RelationDirection",
73
+ "RelationEndpoint",
74
+ "TypedRelation",
75
+ )
@@ -0,0 +1,206 @@
1
+ """Choosing how a relation is fetched, and saying why.
2
+
3
+ ``AUTO`` is a request rather than an instruction, so a plan carries intent and
4
+ this turns it into a decision against what a dialect can actually do. The
5
+ decision is recorded with its reason, because a plan that cannot be read back
6
+ is a plan nobody can review.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from pyoq.errors import FetchPlanError
14
+ from pyoq.relations.fetching import (
15
+ FetchJoin,
16
+ FetchOrder,
17
+ FetchPlan,
18
+ FetchRequest,
19
+ FetchStrategy,
20
+ )
21
+ from pyoq.relations.model import RelationCardinality, TypedRelation
22
+ from pyoq.schema import ObjectReference
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class FetchCapabilities:
27
+ """What one dialect can do when fetching a collection in one query.
28
+
29
+ Ordering is separate from aggregation because a dialect can offer the second
30
+ without the first, and a collection aggregated in the wrong order is worse
31
+ than one fetched in a second query.
32
+ """
33
+
34
+ nested_collections: bool = False
35
+ ordered_nested_collections: bool = False
36
+
37
+ def __post_init__(self) -> None:
38
+ if self.ordered_nested_collections and not self.nested_collections:
39
+ message = "a dialect that cannot aggregate a collection cannot order one"
40
+ raise FetchPlanError(message)
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class ResolvedFetch:
45
+ """One relation, the way it will actually be fetched, and why."""
46
+
47
+ relation: TypedRelation
48
+ strategy: FetchStrategy
49
+ reason: str
50
+ order_by: tuple[FetchOrder, ...] = ()
51
+ joins: tuple[FetchJoin, ...] = ()
52
+ limit: int | None = None
53
+ nested: tuple[ResolvedFetch, ...] = field(default_factory=tuple)
54
+
55
+ @property
56
+ def table(self) -> ObjectReference:
57
+ return self.relation.target.table
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class ResolvedFetchPlan:
62
+ root: ObjectReference
63
+ fetches: tuple[ResolvedFetch, ...] = field(default_factory=tuple)
64
+
65
+ def explain(self) -> tuple[str, ...]:
66
+ """Read the decision back, one line per relation."""
67
+ lines: list[str] = []
68
+ pending = [(fetch, 0) for fetch in reversed(self.fetches)]
69
+ while pending:
70
+ fetch, level = pending.pop()
71
+ lines.append(_explain(fetch, level))
72
+ pending.extend((nested, level + 1) for nested in reversed(fetch.nested))
73
+ return tuple(lines)
74
+
75
+
76
+ def resolve_fetch_plan(
77
+ plan: FetchPlan,
78
+ capabilities: FetchCapabilities,
79
+ /,
80
+ ) -> ResolvedFetchPlan:
81
+ return ResolvedFetchPlan(
82
+ plan.root,
83
+ tuple(_resolve(request, capabilities) for request in plan.requests),
84
+ )
85
+
86
+
87
+ def _resolve(
88
+ request: FetchRequest,
89
+ capabilities: FetchCapabilities,
90
+ ) -> ResolvedFetch:
91
+ strategy, reason = _decide(request, capabilities)
92
+ return ResolvedFetch(
93
+ request.relation,
94
+ strategy,
95
+ reason,
96
+ request.order_by,
97
+ request.joins,
98
+ request.limit,
99
+ tuple(_resolve(nested, capabilities) for nested in request.nested),
100
+ )
101
+
102
+
103
+ def _decide(
104
+ request: FetchRequest,
105
+ capabilities: FetchCapabilities,
106
+ ) -> tuple[FetchStrategy, str]:
107
+ if request.relation.cardinality is RelationCardinality.TO_ONE:
108
+ return _decide_to_one(request)
109
+ if request.strategy is FetchStrategy.AUTO:
110
+ return _decide_collection(request, capabilities)
111
+ _require_possible(request, capabilities)
112
+ return request.strategy, "the plan asked for it"
113
+
114
+
115
+ def _decide_to_one(request: FetchRequest) -> tuple[FetchStrategy, str]:
116
+ """A to-one relation is one row, which a join brings back for free."""
117
+ if request.strategy in (FetchStrategy.AUTO, FetchStrategy.JOINED):
118
+ return FetchStrategy.JOINED, "a to-one relation is one row of the join"
119
+ return request.strategy, "the plan asked for it"
120
+
121
+
122
+ def _decide_collection(
123
+ request: FetchRequest,
124
+ capabilities: FetchCapabilities,
125
+ ) -> tuple[FetchStrategy, str]:
126
+ if request.nested:
127
+ # A collection comes back as JSON holding the columns that were asked
128
+ # for. Fetching through its rows needs their keys, and a second query
129
+ # keyed on them is how they are had.
130
+ return (
131
+ FetchStrategy.SELECT_IN,
132
+ "this collection is fetched through, and its own rows are what "
133
+ "the deeper fetch is keyed on",
134
+ )
135
+ if not capabilities.nested_collections:
136
+ return (
137
+ FetchStrategy.SELECT_IN,
138
+ "this dialect cannot aggregate a collection in one query",
139
+ )
140
+ if request.order_by and not capabilities.ordered_nested_collections:
141
+ # A limited collection is always ordered, so this covers one too: the
142
+ # rows a limit keeps are the ones the order put first.
143
+ return (
144
+ FetchStrategy.SELECT_IN,
145
+ "this dialect cannot order inside an aggregate, and this "
146
+ "collection's order matters",
147
+ )
148
+ return FetchStrategy.NESTED, "one query returns the collection with its parent"
149
+
150
+
151
+ def _require_possible(
152
+ request: FetchRequest,
153
+ capabilities: FetchCapabilities,
154
+ ) -> None:
155
+ """Refuse a collection this plan cannot be carried out as asked.
156
+
157
+ Falling back quietly would be worse: a caller who asked for an ordered
158
+ nested collection and received an unordered one has no way to notice.
159
+ """
160
+ table = request.relation.target.table.name.value
161
+ if request.strategy is FetchStrategy.JOINED:
162
+ message = (
163
+ f"the collection of {table!r} cannot be joined into its parent's "
164
+ f"row, because a parent with several would arrive as several "
165
+ f"parents; fetch it nested or with select-in"
166
+ )
167
+ raise FetchPlanError(message)
168
+ if request.strategy is not FetchStrategy.NESTED:
169
+ return
170
+ if request.nested:
171
+ message = (
172
+ f"the collection of {table!r} is fetched through, and a nested "
173
+ f"collection comes back as the columns asked for rather than as "
174
+ f"rows to key from; fetch it with select-in instead"
175
+ )
176
+ raise FetchPlanError(message)
177
+ if not capabilities.nested_collections:
178
+ message = (
179
+ f"this dialect cannot aggregate the collection of {table!r} in one "
180
+ f"query; fetch it with select-in instead"
181
+ )
182
+ raise FetchPlanError(message)
183
+ if request.order_by and not capabilities.ordered_nested_collections:
184
+ message = (
185
+ f"this dialect cannot order inside an aggregate, so the collection "
186
+ f"of {table!r} cannot be nested in the order asked for; fetch it "
187
+ f"with select-in instead"
188
+ )
189
+ raise FetchPlanError(message)
190
+
191
+
192
+ def _explain(fetch: ResolvedFetch, level: int) -> str:
193
+ names = ", ".join(term.describe() for term in fetch.order_by)
194
+ order = f" ordered by {names}" if names else ""
195
+ return (
196
+ f"{' ' * level}{fetch.table.name.value}{order} "
197
+ f"via {fetch.strategy.value}: {fetch.reason}"
198
+ )
199
+
200
+
201
+ __all__ = (
202
+ "FetchCapabilities",
203
+ "ResolvedFetch",
204
+ "ResolvedFetchPlan",
205
+ "resolve_fetch_plan",
206
+ )
@@ -0,0 +1,9 @@
1
+ """Runtime engine information."""
2
+
3
+ from os import getenv
4
+
5
+ from pyoq.runtime.selection import RuntimeEngine, select_runtime
6
+
7
+ engine = select_runtime(getenv("PYOQ_RUNTIME"))
8
+
9
+ __all__ = ("RuntimeEngine", "engine")
@@ -0,0 +1,25 @@
1
+ """Batch-oriented runtime kernel facade."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from pyoq import runtime
6
+ from pyoq.runtime.selection import (
7
+ RuntimeBackend,
8
+ RuntimeEngine,
9
+ load_native_runtime,
10
+ load_python_runtime,
11
+ )
12
+
13
+ _backend: RuntimeBackend = (
14
+ load_native_runtime()
15
+ if runtime.engine is RuntimeEngine.NATIVE
16
+ else load_python_runtime()
17
+ )
18
+
19
+
20
+ def fingerprint_fragments(fragments: Sequence[bytes]) -> int:
21
+ return _backend.fingerprint_fragments(list(fragments))
22
+
23
+
24
+ def group_integer_keys(keys: Sequence[int | None]) -> list[tuple[int, int]]:
25
+ return _backend.group_integer_keys(list(keys))
pyoq/runtime/python.py ADDED
@@ -0,0 +1,43 @@
1
+ """Portable runtime kernel implementations."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ _FINGERPRINT_OFFSET = 0xCBF29CE484222325
6
+ _FINGERPRINT_PRIME = 0x100000001B3
7
+ _FINGERPRINT_MASK = (1 << 64) - 1
8
+
9
+
10
+ def runtime_marker() -> str:
11
+ return "python"
12
+
13
+
14
+ def fingerprint_fragments(fragments: Sequence[bytes]) -> int:
15
+ fingerprint = _FINGERPRINT_OFFSET
16
+ for fragment in fragments:
17
+ fingerprint = _fingerprint_fragment(fingerprint, fragment)
18
+ return fingerprint
19
+
20
+
21
+ def group_integer_keys(keys: Sequence[int | None]) -> list[tuple[int, int]]:
22
+ group_indexes: dict[int, int] = {}
23
+ groups: list[tuple[int, int]] = []
24
+ for key in keys:
25
+ if key is None:
26
+ continue
27
+ group_index = group_indexes.get(key)
28
+ if group_index is None:
29
+ group_indexes[key] = len(groups)
30
+ groups.append((key, 1))
31
+ continue
32
+ grouped_key, count = groups[group_index]
33
+ groups[group_index] = (grouped_key, count + 1)
34
+ return groups
35
+
36
+
37
+ def _fingerprint_fragment(fingerprint: int, fragment: bytes) -> int:
38
+ length = len(fragment).to_bytes(8, byteorder="little")
39
+ for data in (length, fragment):
40
+ for byte in data:
41
+ fingerprint ^= byte
42
+ fingerprint = (fingerprint * _FINGERPRINT_PRIME) & _FINGERPRINT_MASK
43
+ return fingerprint