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,184 @@
1
+ """Atomic generated package replacement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from hashlib import sha256
11
+ from pathlib import Path, PurePosixPath
12
+
13
+ from pyoq.errors import GenerationOwnershipError, GenerationWriteError
14
+ from pyoq.generation.cleanup import ManifestOwnedCleanup
15
+ from pyoq.generation.contracts import (
16
+ MANIFEST_NAME,
17
+ GenerationPlan,
18
+ ManifestStore,
19
+ OwnedFile,
20
+ OwnershipManifest,
21
+ output_path,
22
+ )
23
+
24
+ DirectoryReplacer = Callable[[Path, Path], None]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class AtomicGenerationWriter:
29
+ manifest_store: ManifestStore
30
+ cleanup: ManifestOwnedCleanup
31
+ replace_directory: DirectoryReplacer = os.replace
32
+
33
+ def write(
34
+ self,
35
+ package_root: Path,
36
+ plan: GenerationPlan,
37
+ current_manifest: OwnershipManifest | None,
38
+ desired_manifest: OwnershipManifest,
39
+ ) -> None:
40
+ parent = package_root.parent
41
+ parent.mkdir(parents=True, exist_ok=True)
42
+ if package_root.is_symlink() or (
43
+ package_root.exists() and not package_root.is_dir()
44
+ ):
45
+ message = f"generated package path is not a directory: {package_root}"
46
+ raise GenerationWriteError(message)
47
+ staging_root = Path(
48
+ tempfile.mkdtemp(prefix=f".{package_root.name}.pyoq-stage-", dir=parent)
49
+ )
50
+ try:
51
+ _copy_existing_package(package_root, staging_root)
52
+ _write_planned_files(staging_root, plan, current_manifest)
53
+ retained_paths = frozenset(file.path for file in plan.files)
54
+ self.cleanup.remove_stale(
55
+ staging_root,
56
+ current_manifest,
57
+ retained_paths,
58
+ )
59
+ manifest_path = staging_root / MANIFEST_NAME
60
+ manifest_path.write_bytes(self.manifest_store.encode(desired_manifest))
61
+ _replace_package(
62
+ package_root,
63
+ staging_root,
64
+ self.replace_directory,
65
+ )
66
+ except (GenerationOwnershipError, GenerationWriteError):
67
+ raise
68
+ except OSError as error:
69
+ message = f"unable to write generated package: {error}"
70
+ raise GenerationWriteError(message) from error
71
+ finally:
72
+ if staging_root.exists():
73
+ shutil.rmtree(staging_root, ignore_errors=True)
74
+
75
+
76
+ def _copy_existing_package(package_root: Path, staging_root: Path) -> None:
77
+ if package_root.exists():
78
+ shutil.copytree(package_root, staging_root, dirs_exist_ok=True, symlinks=True)
79
+
80
+
81
+ def _write_planned_files(
82
+ staging_root: Path,
83
+ plan: GenerationPlan,
84
+ current_manifest: OwnershipManifest | None,
85
+ ) -> None:
86
+ owned_files = _owned_file_map(current_manifest)
87
+ for generated_file in plan.files:
88
+ destination = output_path(staging_root, generated_file.path)
89
+ _verify_destination(
90
+ staging_root,
91
+ destination,
92
+ generated_file.path,
93
+ owned_files.get(generated_file.path),
94
+ )
95
+ destination.parent.mkdir(parents=True, exist_ok=True)
96
+ destination.write_bytes(generated_file.encoded_content)
97
+
98
+
99
+ def _owned_file_map(
100
+ manifest: OwnershipManifest | None,
101
+ ) -> dict[PurePosixPath, OwnedFile]:
102
+ if manifest is None:
103
+ return {}
104
+ return {owned_file.path: owned_file for owned_file in manifest.files}
105
+
106
+
107
+ def _verify_destination(
108
+ staging_root: Path,
109
+ destination: Path,
110
+ relative_path: PurePosixPath,
111
+ owned_file: OwnedFile | None,
112
+ ) -> None:
113
+ if not destination.exists() and not destination.is_symlink():
114
+ _verify_parent_chain(staging_root, destination.parent, relative_path)
115
+ return
116
+ if owned_file is None:
117
+ message = f"refusing to replace unowned file: {relative_path}"
118
+ raise GenerationOwnershipError(message)
119
+ valid_owned_file = (
120
+ destination.is_file()
121
+ and not destination.is_symlink()
122
+ and sha256(destination.read_bytes()).hexdigest() == owned_file.digest
123
+ )
124
+ if not valid_owned_file:
125
+ message = f"refusing to replace modified owned file: {relative_path}"
126
+ raise GenerationOwnershipError(message)
127
+
128
+
129
+ def _verify_parent_chain(
130
+ staging_root: Path,
131
+ parent: Path,
132
+ relative_path: PurePosixPath,
133
+ ) -> None:
134
+ current = parent
135
+ while current != staging_root:
136
+ if not current.exists():
137
+ current = current.parent
138
+ continue
139
+ if current.is_symlink() or not current.is_dir():
140
+ message = f"unsafe parent for generated file: {relative_path}"
141
+ raise GenerationOwnershipError(message)
142
+ current = current.parent
143
+
144
+
145
+ def _replace_package(
146
+ package_root: Path,
147
+ staging_root: Path,
148
+ replace_directory: DirectoryReplacer,
149
+ ) -> None:
150
+ if not package_root.exists():
151
+ replace_directory(staging_root, package_root)
152
+ return
153
+ backup_root = _unused_backup_path(package_root)
154
+ replace_directory(package_root, backup_root)
155
+ try:
156
+ replace_directory(staging_root, package_root)
157
+ except OSError as replacement_error:
158
+ _restore_package(backup_root, package_root, replace_directory)
159
+ message = f"package replacement failed and was rolled back: {replacement_error}"
160
+ raise GenerationWriteError(message) from replacement_error
161
+ shutil.rmtree(backup_root, ignore_errors=True)
162
+
163
+
164
+ def _restore_package(
165
+ backup_root: Path,
166
+ package_root: Path,
167
+ replace_directory: DirectoryReplacer,
168
+ ) -> None:
169
+ try:
170
+ replace_directory(backup_root, package_root)
171
+ except OSError:
172
+ shutil.copytree(backup_root, package_root, symlinks=True)
173
+ shutil.rmtree(backup_root, ignore_errors=True)
174
+
175
+
176
+ def _unused_backup_path(package_root: Path) -> Path:
177
+ backup_root = Path(
178
+ tempfile.mkdtemp(
179
+ prefix=f".{package_root.name}.pyoq-backup-",
180
+ dir=package_root.parent,
181
+ )
182
+ )
183
+ backup_root.rmdir()
184
+ return backup_root
@@ -0,0 +1,24 @@
1
+ """Turning result rows into typed values."""
2
+
3
+ from pyoq.hydration.engine import hydrate
4
+ from pyoq.hydration.identity import IdentityMap, RowIdentity, RowKey, row_key_for
5
+ from pyoq.hydration.plan import (
6
+ HydrationNode,
7
+ RelationValues,
8
+ RowConstructor,
9
+ TableProjection,
10
+ validate_hydration_node,
11
+ )
12
+
13
+ __all__ = (
14
+ "HydrationNode",
15
+ "IdentityMap",
16
+ "RelationValues",
17
+ "RowConstructor",
18
+ "RowIdentity",
19
+ "RowKey",
20
+ "TableProjection",
21
+ "hydrate",
22
+ "row_key_for",
23
+ "validate_hydration_node",
24
+ )
@@ -0,0 +1,155 @@
1
+ """Turning a flat result into rows that carry their children.
2
+
3
+ A join hands back one row per combination, so the same parent arrives once per
4
+ child and the same child can arrive under several parents. Rebuilding the shape
5
+ means reading the result twice: once to group it, once to construct it, because
6
+ a frozen row cannot be given its children after it exists.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable, Sequence
12
+ from dataclasses import dataclass
13
+ from typing import cast
14
+
15
+ from pyoq.errors import HydrationError
16
+ from pyoq.hydration.identity import IdentityMap, RowIdentity
17
+ from pyoq.hydration.plan import HydrationNode, validate_hydration_node
18
+ from pyoq.relations import (
19
+ MAXIMUM_FETCH_DEPTH,
20
+ RelationCardinality,
21
+ RelationValue,
22
+ TypedRelation,
23
+ )
24
+
25
+
26
+ def hydrate(
27
+ node: HydrationNode,
28
+ rows: Iterable[Sequence[object]],
29
+ /,
30
+ *,
31
+ maximum_depth: int = MAXIMUM_FETCH_DEPTH,
32
+ ) -> tuple[object, ...]:
33
+ """Read a result into root rows, each carrying whatever the plan asked for.
34
+
35
+ Rows come back in the order the result first mentioned them, and a row the
36
+ result mentions twice is built once.
37
+ """
38
+ validate_hydration_node(node, maximum_depth=maximum_depth)
39
+ state = _NodeState(node)
40
+ for row in rows:
41
+ state.absorb(row)
42
+ return state.build_all()
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class _Edge:
47
+ parent: RowIdentity
48
+ child_index: int
49
+
50
+
51
+ class _NodeState:
52
+ """What one table of a result has been seen to hold.
53
+
54
+ There is one of these per table in the plan rather than one per parent, so
55
+ a child that several parents share is built once. It is not shared across
56
+ plan positions: the same table read at two places carries different
57
+ children, and one row cannot hold both.
58
+ """
59
+
60
+ __slots__ = ("_children", "_edges", "_node", "_order", "_rows", "_values")
61
+
62
+ def __init__(self, node: HydrationNode) -> None:
63
+ self._node = node
64
+ self._children = tuple(_NodeState(child) for child in node.children)
65
+ self._order: list[RowIdentity] = []
66
+ self._values: dict[RowIdentity, tuple[object, ...]] = {}
67
+ self._edges: dict[_Edge, dict[RowIdentity, None]] = {}
68
+ self._rows = IdentityMap()
69
+
70
+ def absorb(self, row: Sequence[object], /) -> RowIdentity | None:
71
+ identity = self._node.projection.key.identify(row)
72
+ if identity is None:
73
+ return None
74
+ if identity not in self._values:
75
+ self._order.append(identity)
76
+ self._values[identity] = self._node.projection.select(row)
77
+ for index, child in enumerate(self._children):
78
+ child_identity = child.absorb(row)
79
+ if child_identity is not None:
80
+ self._record(identity, index, child_identity)
81
+ return identity
82
+
83
+ def build_all(self) -> tuple[object, ...]:
84
+ return tuple(self.build(identity) for identity in self._order)
85
+
86
+ def build(self, identity: RowIdentity, /) -> object:
87
+ return self._rows.resolve(identity, lambda: self._construct(identity))
88
+
89
+ def _construct(self, identity: RowIdentity) -> object:
90
+ relations = {
91
+ _require_relation(child): self._value_for(child, identity, index)
92
+ for index, child in enumerate(self._children)
93
+ }
94
+ return self._node.construct(self._values[identity], relations)
95
+
96
+ def _value_for(
97
+ self,
98
+ child: _NodeState,
99
+ parent: RowIdentity,
100
+ index: int,
101
+ ) -> RelationValue[object]:
102
+ """Read this parent's children, which only this parent recorded."""
103
+ identities = tuple(self._edges.get(_Edge(parent, index), {}))
104
+ rows = tuple(child.build(identity) for identity in identities)
105
+ relation = _require_relation(child)
106
+ if relation.cardinality is RelationCardinality.TO_MANY:
107
+ return RelationValue.loaded(rows)
108
+ _require_at_most_one(relation, rows)
109
+ if not rows:
110
+ return RelationValue[object].absent()
111
+ return RelationValue.loaded(rows[0])
112
+
113
+ def _record(
114
+ self,
115
+ parent: RowIdentity,
116
+ index: int,
117
+ child: RowIdentity,
118
+ ) -> None:
119
+ """Remember the order children arrived in, without repeating any."""
120
+ self._edges.setdefault(_Edge(parent, index), {})[child] = None
121
+
122
+ @property
123
+ def relation(self) -> TypedRelation | None:
124
+ return self._node.relation
125
+
126
+
127
+ def _require_relation(state: _NodeState) -> TypedRelation:
128
+ """Every table below the root is reached by a relation.
129
+
130
+ The plan is validated before a single row is read, so this holds by the
131
+ time any row is built. Checking it again here would be code no result could
132
+ reach.
133
+ """
134
+ return cast("TypedRelation", state.relation)
135
+
136
+
137
+ def _require_at_most_one(
138
+ relation: TypedRelation,
139
+ rows: tuple[object, ...],
140
+ ) -> None:
141
+ """A to-one relation that returned several rows is not a to-one relation.
142
+
143
+ The schema said these columns were unique, and the result disagreed, so the
144
+ disagreement is reported rather than resolved by discarding rows.
145
+ """
146
+ if len(rows) <= 1:
147
+ return
148
+ message = (
149
+ f"the relation to {relation.target.table.name.value!r} is to-one, but "
150
+ f"the result holds {len(rows)} distinct rows for one parent"
151
+ )
152
+ raise HydrationError(message)
153
+
154
+
155
+ __all__ = ("hydrate",)
@@ -0,0 +1,194 @@
1
+ """Deciding which result rows are the same row.
2
+
3
+ A join repeats a parent row once per child, and an outer join invents a child
4
+ row made entirely of nulls. Both are answered by asking what identifies a row,
5
+ which is why identity comes before hydration rather than inside it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Final, TypeVar, cast
13
+
14
+ from pyoq.errors import RowIdentityError
15
+ from pyoq.schema import Identifier, KeyKind, ObjectReference, Table
16
+
17
+ Row = TypeVar("Row")
18
+
19
+ _MISSING: Final = object()
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class RowIdentity:
24
+ """What makes one row of one table distinct from another."""
25
+
26
+ table: ObjectReference
27
+ values: tuple[object, ...]
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class RowKey:
32
+ """Where a row's identifying values sit in a result row.
33
+
34
+ The columns must be ones the database cannot leave null, because a null
35
+ among them is read as the row not being there at all.
36
+ """
37
+
38
+ table: ObjectReference
39
+ positions: tuple[int, ...]
40
+ columns: tuple[Identifier, ...] = ()
41
+
42
+ def __post_init__(self) -> None:
43
+ if not self.positions:
44
+ message = "a row key needs at least one column"
45
+ raise RowIdentityError(message)
46
+ if any(position < 0 for position in self.positions):
47
+ message = "row key positions cannot be negative"
48
+ raise RowIdentityError(message)
49
+
50
+ def identify(self, row: Sequence[object], /) -> RowIdentity | None:
51
+ """Identify a row, or report that the result holds no row here.
52
+
53
+ An outer join fills an absent row with nulls, so a null among the
54
+ identifying values means there is nothing to identify rather than a row
55
+ whose identity happens to be null.
56
+ """
57
+ values: list[object] = []
58
+ for position in self.positions:
59
+ value = _require_position(self, row, position)
60
+ if value is None:
61
+ return None
62
+ values.append(_hashable(value))
63
+ return RowIdentity(self.table, tuple(values))
64
+
65
+
66
+ def row_key_for(
67
+ table: Table,
68
+ reference: ObjectReference,
69
+ positions: Callable[[Identifier], int],
70
+ ) -> RowKey:
71
+ """Choose what identifies a table's rows in a result.
72
+
73
+ A primary key is preferred, then a unique key no part of which is nullable.
74
+ A table offering neither is identified by everything projected from it,
75
+ which is the only sound answer left: rows it cannot tell apart are rows
76
+ nobody can tell apart.
77
+ """
78
+ columns = _identifying_columns(table)
79
+ return RowKey(
80
+ reference,
81
+ tuple(_require_projected(table, column, positions) for column in columns),
82
+ columns,
83
+ )
84
+
85
+
86
+ def _require_projected(
87
+ table: Table,
88
+ column: Identifier,
89
+ positions: Callable[[Identifier], int],
90
+ ) -> int:
91
+ """A row cannot be identified by a column the query did not select."""
92
+ try:
93
+ return positions(column)
94
+ except KeyError as error:
95
+ message = (
96
+ f"{table.name.value!r} is identified by {column.value!r}, which this "
97
+ f"query does not select"
98
+ )
99
+ raise RowIdentityError(message) from error
100
+
101
+
102
+ def _identifying_columns(table: Table) -> tuple[Identifier, ...]:
103
+ nullable = frozenset(column.name for column in table.columns if column.nullable)
104
+ for kind in (KeyKind.PRIMARY, KeyKind.UNIQUE):
105
+ for key in table.keys:
106
+ if key.kind is kind and not nullable.intersection(key.columns):
107
+ return key.columns
108
+ return tuple(column.name for column in table.columns)
109
+
110
+
111
+ class IdentityMap:
112
+ """One fetch's view of which rows it has already built.
113
+
114
+ It lives for one fetch and is discarded with it, so a row built here can
115
+ never be a stale row from an earlier one.
116
+ """
117
+
118
+ __slots__ = ("_rows",)
119
+
120
+ def __init__(self) -> None:
121
+ self._rows: dict[RowIdentity, object] = {}
122
+
123
+ def __len__(self) -> int:
124
+ return len(self._rows)
125
+
126
+ def resolve(
127
+ self,
128
+ identity: RowIdentity,
129
+ build: Callable[[], Row],
130
+ /,
131
+ ) -> Row:
132
+ """Return the row already built for this identity, or build it once.
133
+
134
+ The identity names a table, so every row stored under one identity is a
135
+ row of one type, which is what the cast rests on.
136
+ """
137
+ existing = _lookup(self._rows, identity)
138
+ if existing is not _MISSING:
139
+ return cast("Row", existing)
140
+ built = build()
141
+ self._rows[identity] = built
142
+ return built
143
+
144
+ def contains(self, identity: RowIdentity, /) -> bool:
145
+ return _lookup(self._rows, identity) is not _MISSING
146
+
147
+
148
+ def _lookup(rows: dict[RowIdentity, object], identity: RowIdentity) -> object:
149
+ """Find a row, reporting a value no dictionary can hold.
150
+
151
+ A driver can hand back a value that cannot be hashed, and a raw type error
152
+ from deep inside a fetch says nothing about which row caused it.
153
+ """
154
+ try:
155
+ return rows.get(identity, _MISSING)
156
+ except TypeError as error:
157
+ message = (
158
+ f"a row of {identity.table.name.value!r} is identified by a value "
159
+ f"that cannot be compared: {error}"
160
+ )
161
+ raise RowIdentityError(message) from error
162
+
163
+
164
+ def _require_position(
165
+ key: RowKey,
166
+ row: Sequence[object],
167
+ position: int,
168
+ ) -> object:
169
+ if position < len(row):
170
+ return row[position]
171
+ message = (
172
+ f"identifying column {position} of {key.table.name.value!r} is beyond "
173
+ f"a result row of {len(row)} values"
174
+ )
175
+ raise RowIdentityError(message)
176
+
177
+
178
+ def _hashable(value: object) -> object:
179
+ """Identify by value, however a driver chose to spell it.
180
+
181
+ Buffers and arrays carry their meaning in their contents rather than in the
182
+ object holding them, so two rows spelled differently by one driver still
183
+ identify as one row.
184
+ """
185
+ if isinstance(value, memoryview):
186
+ return value.tobytes()
187
+ if isinstance(value, bytearray):
188
+ return bytes(value)
189
+ if isinstance(value, list):
190
+ return tuple(_hashable(item) for item in cast("list[object]", value))
191
+ return value
192
+
193
+
194
+ __all__ = ("IdentityMap", "RowIdentity", "RowKey", "row_key_for")
pyoq/hydration/plan.py ADDED
@@ -0,0 +1,116 @@
1
+ """Describing what a result holds, so hydration reads rather than guesses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import TypeAlias
8
+
9
+ from pyoq.errors import HydrationError
10
+ from pyoq.hydration.identity import RowKey
11
+ from pyoq.relations import MAXIMUM_FETCH_DEPTH, RelationValue, TypedRelation
12
+
13
+ RelationValues: TypeAlias = Mapping[TypedRelation, RelationValue[object]]
14
+ RowConstructor: TypeAlias = Callable[
15
+ [tuple[object, ...], RelationValues],
16
+ object,
17
+ ]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class TableProjection:
22
+ """Where one table's values sit in a result row."""
23
+
24
+ key: RowKey
25
+ positions: tuple[int, ...]
26
+
27
+ def __post_init__(self) -> None:
28
+ if not self.positions:
29
+ message = "a table projection needs at least one column"
30
+ raise HydrationError(message)
31
+
32
+ def select(self, row: Sequence[object], /) -> tuple[object, ...]:
33
+ """Take this table's values out of a result row.
34
+
35
+ A projection describing more columns than the result carries is a
36
+ mistake in the plan, and a bare index error from inside a fetch says
37
+ nothing about which table was being read.
38
+ """
39
+ try:
40
+ return tuple(row[position] for position in self.positions)
41
+ except IndexError as error:
42
+ message = (
43
+ f"the projection for {self.key.table.name.value!r} reads beyond "
44
+ f"a result row of {len(row)} values"
45
+ )
46
+ raise HydrationError(message) from error
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class HydrationNode:
51
+ """One table in a result, and whatever hangs off it.
52
+
53
+ ``relation`` says how this node reaches its parent, which is what decides
54
+ whether the parent receives one row or a collection. The root has none.
55
+ """
56
+
57
+ projection: TableProjection
58
+ construct: RowConstructor
59
+ relation: TypedRelation | None = None
60
+ children: tuple[HydrationNode, ...] = field(default_factory=tuple)
61
+
62
+ @property
63
+ def depth(self) -> int:
64
+ deepest = 0
65
+ pending: list[tuple[HydrationNode, int]] = [(self, 1)]
66
+ while pending:
67
+ node, level = pending.pop()
68
+ deepest = max(deepest, level)
69
+ pending.extend((child, level + 1) for child in node.children)
70
+ return deepest
71
+
72
+
73
+ def validate_hydration_node(
74
+ node: HydrationNode,
75
+ *,
76
+ maximum_depth: int = MAXIMUM_FETCH_DEPTH,
77
+ ) -> None:
78
+ """Refuse a shape hydration could not carry out.
79
+
80
+ Checked before a single row is read, so a mistake is reported against the
81
+ plan rather than part-way through a result.
82
+ """
83
+ if maximum_depth < 1:
84
+ message = "hydration must be allowed at least one level"
85
+ raise HydrationError(message)
86
+ if node.depth > maximum_depth:
87
+ message = f"hydration nests beyond the {maximum_depth} levels allowed"
88
+ raise HydrationError(message)
89
+ _require_root_without_relation(node)
90
+ _require_children_carry_relations(node)
91
+
92
+
93
+ def _require_root_without_relation(node: HydrationNode) -> None:
94
+ if node.relation is None:
95
+ return
96
+ message = "the root of a result is reached by no relation"
97
+ raise HydrationError(message)
98
+
99
+
100
+ def _require_children_carry_relations(root: HydrationNode) -> None:
101
+ pending = list(root.children)
102
+ while pending:
103
+ node = pending.pop()
104
+ if node.relation is None:
105
+ message = "every table below the root is reached by a relation"
106
+ raise HydrationError(message)
107
+ pending.extend(node.children)
108
+
109
+
110
+ __all__ = (
111
+ "HydrationNode",
112
+ "RelationValues",
113
+ "RowConstructor",
114
+ "TableProjection",
115
+ "validate_hydration_node",
116
+ )
@@ -0,0 +1,9 @@
1
+ """Bringing generated code back in step after a migration."""
2
+
3
+ from pyoq.migrations.hooks import (
4
+ MigrationOutcome,
5
+ after_migration,
6
+ after_migration_at,
7
+ )
8
+
9
+ __all__ = ("MigrationOutcome", "after_migration", "after_migration_at")