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