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.
- pyoq/__init__.py +10 -0
- pyoq/__main__.py +5 -0
- pyoq/_native.pyi +5 -0
- pyoq/cli/__init__.py +5 -0
- pyoq/cli/commands.py +270 -0
- pyoq/cli/defaults.py +98 -0
- pyoq/cli/services.py +97 -0
- pyoq/config/__init__.py +31 -0
- pyoq/config/connection.py +161 -0
- pyoq/config/loader.py +289 -0
- pyoq/config/models.py +245 -0
- pyoq/config/values.py +142 -0
- pyoq/descriptors.py +165 -0
- pyoq/diagnostics/__init__.py +68 -0
- pyoq/diagnostics/budget.py +136 -0
- pyoq/diagnostics/events.py +137 -0
- pyoq/diagnostics/fingerprint.py +267 -0
- pyoq/diagnostics/instrumented.py +237 -0
- pyoq/diagnostics/metrics.py +61 -0
- pyoq/diagnostics/observation.py +227 -0
- pyoq/diagnostics/scoped.py +103 -0
- pyoq/django/__init__.py +15 -0
- pyoq/django/apps.py +17 -0
- pyoq/django/execution.py +317 -0
- pyoq/django/generation.py +59 -0
- pyoq/django/management/__init__.py +0 -0
- pyoq/django/management/commands/__init__.py +0 -0
- pyoq/django/management/commands/makemigrations.py +53 -0
- pyoq/django/management/commands/pyoq_codegen.py +75 -0
- pyoq/django/parameters.py +101 -0
- pyoq/django/schema.py +379 -0
- pyoq/django/settings.py +87 -0
- pyoq/django/timeouts.py +105 -0
- pyoq/dsl/__init__.py +64 -0
- pyoq/dsl/aio/__init__.py +31 -0
- pyoq/dsl/aio/context.py +295 -0
- pyoq/dsl/aio/queries.py +335 -0
- pyoq/dsl/aio/writes.py +368 -0
- pyoq/dsl/context.py +326 -0
- pyoq/dsl/entry.py +37 -0
- pyoq/dsl/labels.py +36 -0
- pyoq/dsl/queries.py +339 -0
- pyoq/dsl/result.py +164 -0
- pyoq/dsl/writes.py +360 -0
- pyoq/errors.py +317 -0
- pyoq/fastapi/__init__.py +32 -0
- pyoq/fastapi/dependencies.py +167 -0
- pyoq/fastapi/lifespan.py +119 -0
- pyoq/fetching/__init__.py +55 -0
- pyoq/fetching/collections.py +136 -0
- pyoq/fetching/execution.py +587 -0
- pyoq/fetching/joined.py +79 -0
- pyoq/fetching/nesting.py +183 -0
- pyoq/fetching/plans.py +541 -0
- pyoq/fetching/select_in.py +149 -0
- pyoq/fetching/tables.py +110 -0
- pyoq/generation/__init__.py +54 -0
- pyoq/generation/cleanup.py +44 -0
- pyoq/generation/contracts.py +248 -0
- pyoq/generation/drift.py +169 -0
- pyoq/generation/lock.py +33 -0
- pyoq/generation/manifest.py +114 -0
- pyoq/generation/model.py +1001 -0
- pyoq/generation/pipeline.py +119 -0
- pyoq/generation/rendering/__init__.py +5 -0
- pyoq/generation/rendering/domains.py +51 -0
- pyoq/generation/rendering/enums.py +29 -0
- pyoq/generation/rendering/exports.py +70 -0
- pyoq/generation/rendering/imports.py +63 -0
- pyoq/generation/rendering/package.py +56 -0
- pyoq/generation/rendering/relations.py +133 -0
- pyoq/generation/rendering/routines.py +396 -0
- pyoq/generation/rendering/rows.py +79 -0
- pyoq/generation/rendering/source.py +121 -0
- pyoq/generation/rendering/tables.py +300 -0
- pyoq/generation/rendering/writes.py +514 -0
- pyoq/generation/validation.py +27 -0
- pyoq/generation/writer.py +184 -0
- pyoq/hydration/__init__.py +24 -0
- pyoq/hydration/engine.py +155 -0
- pyoq/hydration/identity.py +194 -0
- pyoq/hydration/plan.py +116 -0
- pyoq/migrations/__init__.py +9 -0
- pyoq/migrations/alembic.py +106 -0
- pyoq/migrations/hooks.py +75 -0
- pyoq/naming.py +261 -0
- pyoq/policies/__init__.py +47 -0
- pyoq/policies/bypass.py +122 -0
- pyoq/policies/governed.py +430 -0
- pyoq/policies/model.py +242 -0
- pyoq/policies/rewriting.py +263 -0
- pyoq/py.typed +1 -0
- pyoq/query/__init__.py +312 -0
- pyoq/query/aggregates.py +172 -0
- pyoq/query/arrays.py +65 -0
- pyoq/query/binding.py +52 -0
- pyoq/query/capabilities.py +317 -0
- pyoq/query/casts.py +73 -0
- pyoq/query/choices.py +185 -0
- pyoq/query/decoding.py +360 -0
- pyoq/query/documents.py +56 -0
- pyoq/query/execution/__init__.py +63 -0
- pyoq/query/execution/aio/__init__.py +31 -0
- pyoq/query/execution/aio/operations.py +228 -0
- pyoq/query/execution/aio/pooling.py +233 -0
- pyoq/query/execution/aio/streaming.py +161 -0
- pyoq/query/execution/aio/transactions.py +105 -0
- pyoq/query/execution/batch.py +96 -0
- pyoq/query/execution/binding_style.py +30 -0
- pyoq/query/execution/compilation.py +48 -0
- pyoq/query/execution/context.py +61 -0
- pyoq/query/execution/control.py +50 -0
- pyoq/query/execution/operations.py +224 -0
- pyoq/query/execution/planning.py +107 -0
- pyoq/query/execution/pooling.py +279 -0
- pyoq/query/execution/results.py +36 -0
- pyoq/query/execution/streaming.py +178 -0
- pyoq/query/execution/transactions.py +95 -0
- pyoq/query/expressions.py +1200 -0
- pyoq/query/fields.py +60 -0
- pyoq/query/mysql/__init__.py +59 -0
- pyoq/query/mysql/aio/__init__.py +38 -0
- pyoq/query/mysql/aio/commands.py +389 -0
- pyoq/query/mysql/aio/driver.py +196 -0
- pyoq/query/mysql/aio/executor.py +123 -0
- pyoq/query/mysql/aio/factory.py +26 -0
- pyoq/query/mysql/aio/operations.py +38 -0
- pyoq/query/mysql/aio/pool.py +53 -0
- pyoq/query/mysql/aio/transactions.py +313 -0
- pyoq/query/mysql/commands.py +354 -0
- pyoq/query/mysql/compiler.py +134 -0
- pyoq/query/mysql/context.py +20 -0
- pyoq/query/mysql/executor.py +126 -0
- pyoq/query/mysql/expressions.py +244 -0
- pyoq/query/mysql/factory.py +46 -0
- pyoq/query/mysql/health.py +66 -0
- pyoq/query/mysql/identifiers.py +9 -0
- pyoq/query/mysql/model.py +79 -0
- pyoq/query/mysql/operations.py +43 -0
- pyoq/query/mysql/parameters.py +69 -0
- pyoq/query/mysql/planning.py +20 -0
- pyoq/query/mysql/pool.py +67 -0
- pyoq/query/mysql/transactions.py +331 -0
- pyoq/query/mysql/writes.py +73 -0
- pyoq/query/nodes.py +750 -0
- pyoq/query/postgres/__init__.py +48 -0
- pyoq/query/postgres/aio/__init__.py +25 -0
- pyoq/query/postgres/aio/bulk.py +56 -0
- pyoq/query/postgres/aio/commands.py +264 -0
- pyoq/query/postgres/aio/executor.py +152 -0
- pyoq/query/postgres/aio/factory.py +26 -0
- pyoq/query/postgres/aio/operations.py +26 -0
- pyoq/query/postgres/aio/pool.py +40 -0
- pyoq/query/postgres/aio/transactions.py +295 -0
- pyoq/query/postgres/bulk.py +62 -0
- pyoq/query/postgres/commands.py +238 -0
- pyoq/query/postgres/compiler.py +114 -0
- pyoq/query/postgres/context.py +20 -0
- pyoq/query/postgres/executor.py +147 -0
- pyoq/query/postgres/expressions.py +311 -0
- pyoq/query/postgres/factory.py +24 -0
- pyoq/query/postgres/health.py +24 -0
- pyoq/query/postgres/identifiers.py +9 -0
- pyoq/query/postgres/model.py +81 -0
- pyoq/query/postgres/operations.py +25 -0
- pyoq/query/postgres/parameters.py +71 -0
- pyoq/query/postgres/planning.py +20 -0
- pyoq/query/postgres/pool.py +52 -0
- pyoq/query/postgres/transactions.py +295 -0
- pyoq/query/postgres/writes.py +37 -0
- pyoq/query/projections.py +105 -0
- pyoq/query/raw.py +90 -0
- pyoq/query/recursion.py +265 -0
- pyoq/query/rendering/__init__.py +1 -0
- pyoq/query/rendering/expressions.py +913 -0
- pyoq/query/rendering/identifiers.py +40 -0
- pyoq/query/rendering/projections.py +63 -0
- pyoq/query/rendering/queries.py +334 -0
- pyoq/query/rendering/sources.py +66 -0
- pyoq/query/rendering/writes.py +176 -0
- pyoq/query/results.py +459 -0
- pyoq/query/routines.py +196 -0
- pyoq/query/rows.py +156 -0
- pyoq/query/select.py +793 -0
- pyoq/query/select_nodes.py +277 -0
- pyoq/query/sources.py +236 -0
- pyoq/query/sqlite/__init__.py +43 -0
- pyoq/query/sqlite/commands.py +201 -0
- pyoq/query/sqlite/compiler.py +139 -0
- pyoq/query/sqlite/context.py +20 -0
- pyoq/query/sqlite/executor.py +119 -0
- pyoq/query/sqlite/expressions.py +224 -0
- pyoq/query/sqlite/factory.py +32 -0
- pyoq/query/sqlite/health.py +28 -0
- pyoq/query/sqlite/identifiers.py +9 -0
- pyoq/query/sqlite/model.py +73 -0
- pyoq/query/sqlite/operations.py +36 -0
- pyoq/query/sqlite/parameters.py +50 -0
- pyoq/query/sqlite/planning.py +20 -0
- pyoq/query/sqlite/pool.py +50 -0
- pyoq/query/sqlite/streaming.py +13 -0
- pyoq/query/sqlite/transactions.py +274 -0
- pyoq/query/sqlite/writes.py +35 -0
- pyoq/query/statements.py +27 -0
- pyoq/query/values.py +23 -0
- pyoq/query/vendor.py +162 -0
- pyoq/query/windows.py +424 -0
- pyoq/query/write_nodes.py +174 -0
- pyoq/query/writes.py +628 -0
- pyoq/relations/__init__.py +66 -0
- pyoq/relations/batching.py +219 -0
- pyoq/relations/derivation.py +111 -0
- pyoq/relations/fetching.py +355 -0
- pyoq/relations/graph.py +245 -0
- pyoq/relations/loading.py +74 -0
- pyoq/relations/model.py +75 -0
- pyoq/relations/planning.py +206 -0
- pyoq/runtime/__init__.py +9 -0
- pyoq/runtime/kernels.py +25 -0
- pyoq/runtime/python.py +43 -0
- pyoq/runtime/selection.py +73 -0
- pyoq/sanic/__init__.py +32 -0
- pyoq/sanic/scope.py +197 -0
- pyoq/sanic/workers.py +129 -0
- pyoq/schema/__init__.py +108 -0
- pyoq/schema/codec.py +711 -0
- pyoq/schema/models.py +604 -0
- pyoq/schema/mysql/__init__.py +16 -0
- pyoq/schema/mysql/connection.py +73 -0
- pyoq/schema/mysql/dsn.py +72 -0
- pyoq/schema/mysql/records.py +354 -0
- pyoq/schema/mysql/reflection.py +309 -0
- pyoq/schema/mysql/source.py +30 -0
- pyoq/schema/mysql/sql.py +128 -0
- pyoq/schema/mysql/types.py +105 -0
- pyoq/schema/postgres/__init__.py +13 -0
- pyoq/schema/postgres/connection.py +63 -0
- pyoq/schema/postgres/records.py +384 -0
- pyoq/schema/postgres/reflection.py +466 -0
- pyoq/schema/postgres/source.py +30 -0
- pyoq/schema/postgres/sql.py +246 -0
- pyoq/schema/postgres/types.py +98 -0
- pyoq/schema/registry.py +45 -0
- pyoq/schema/source.py +15 -0
- pyoq/schema/sqlite/__init__.py +6 -0
- pyoq/schema/sqlite/connection.py +54 -0
- pyoq/schema/sqlite/records.py +167 -0
- pyoq/schema/sqlite/reflection.py +393 -0
- pyoq/schema/sqlite/source.py +30 -0
- pyoq/schema/sqlite/sql.py +254 -0
- pyoq/schema/sqlite/types.py +74 -0
- pyoq/serving/__init__.py +23 -0
- pyoq/serving/databases.py +107 -0
- pyoq/serving/opening.py +331 -0
- pyoq/snapshots/__init__.py +20 -0
- pyoq/snapshots/drift.py +312 -0
- pyoq/snapshots/files.py +96 -0
- pyoq/snapshots/routing.py +40 -0
- pyoq/snapshots/source.py +33 -0
- pyoq/tracing/__init__.py +5 -0
- pyoq/tracing/spans.py +89 -0
- pyoq/unset.py +14 -0
- pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
- pyoq_sql-1.0.2.dist-info/RECORD +267 -0
- pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
- pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|
pyoq/hydration/engine.py
ADDED
|
@@ -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
|
+
)
|