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
pyoq/relations/graph.py
ADDED
|
@@ -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")
|
pyoq/relations/model.py
ADDED
|
@@ -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
|
+
)
|
pyoq/runtime/__init__.py
ADDED
pyoq/runtime/kernels.py
ADDED
|
@@ -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
|