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,219 @@
|
|
|
1
|
+
"""Collecting the keys a relation needs, so one query serves many parents.
|
|
2
|
+
|
|
3
|
+
An N+1 access asks for one parent's children at a time. The fix is to gather
|
|
4
|
+
the keys first and ask once, which a dialect's parameter limit turns into
|
|
5
|
+
asking a bounded number of times rather than once per parent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from pyoq.errors import ParameterLimitError, QueryValidationError
|
|
14
|
+
from pyoq.relations.model import TypedRelation
|
|
15
|
+
|
|
16
|
+
DEFAULT_KEY_LIMIT = 10_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class KeyBatch:
|
|
21
|
+
"""Keys for one relation that fit in one statement."""
|
|
22
|
+
|
|
23
|
+
relation: TypedRelation
|
|
24
|
+
keys: tuple[tuple[object, ...], ...]
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def parameters(self) -> int:
|
|
28
|
+
"""How many bound values this batch costs.
|
|
29
|
+
|
|
30
|
+
A key spanning several columns costs one value per column, which is what
|
|
31
|
+
a limit counts rather than the number of keys.
|
|
32
|
+
"""
|
|
33
|
+
return sum(len(key) for key in self.keys)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def plan_key_batches(
|
|
37
|
+
relation: TypedRelation,
|
|
38
|
+
keys: Iterable[tuple[object, ...]],
|
|
39
|
+
/,
|
|
40
|
+
*,
|
|
41
|
+
maximum_parameters: int,
|
|
42
|
+
) -> tuple[KeyBatch, ...]:
|
|
43
|
+
"""Split keys into the fewest statements a parameter limit allows."""
|
|
44
|
+
_require_positive(maximum_parameters, "maximum parameters")
|
|
45
|
+
ordered = tuple(keys)
|
|
46
|
+
if not ordered:
|
|
47
|
+
return ()
|
|
48
|
+
width = _uniform_width(relation, ordered)
|
|
49
|
+
per_batch = maximum_parameters // width
|
|
50
|
+
if per_batch < 1:
|
|
51
|
+
message = (
|
|
52
|
+
f"one key of {width} columns exceeds the {maximum_parameters} "
|
|
53
|
+
f"parameters a statement is allowed"
|
|
54
|
+
)
|
|
55
|
+
raise ParameterLimitError(message)
|
|
56
|
+
return tuple(
|
|
57
|
+
KeyBatch(relation, ordered[start : start + per_batch])
|
|
58
|
+
for start in range(0, len(ordered), per_batch)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RelationBatch:
|
|
63
|
+
"""The keys one relation is waiting on, in order and without repeats.
|
|
64
|
+
|
|
65
|
+
A parent whose key is null reaches no children, so such a key is counted
|
|
66
|
+
rather than collected: asking for it would return nothing and cost a
|
|
67
|
+
parameter to do so.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
__slots__ = ("_keys", "_limit", "_relation", "_skipped")
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
relation: TypedRelation,
|
|
75
|
+
*,
|
|
76
|
+
key_limit: int = DEFAULT_KEY_LIMIT,
|
|
77
|
+
) -> None:
|
|
78
|
+
_require_positive(key_limit, "key limit")
|
|
79
|
+
self._relation = relation
|
|
80
|
+
self._limit = key_limit
|
|
81
|
+
self._keys: dict[tuple[object, ...], None] = {}
|
|
82
|
+
self._skipped = 0
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def relation(self) -> TypedRelation:
|
|
86
|
+
return self._relation
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def skipped_keys(self) -> int:
|
|
90
|
+
"""Keys not collected because they reach nothing."""
|
|
91
|
+
return self._skipped
|
|
92
|
+
|
|
93
|
+
def __len__(self) -> int:
|
|
94
|
+
return len(self._keys)
|
|
95
|
+
|
|
96
|
+
def __iter__(self) -> Iterator[tuple[object, ...]]:
|
|
97
|
+
return iter(self._keys)
|
|
98
|
+
|
|
99
|
+
def add(self, key: tuple[object, ...], /) -> bool:
|
|
100
|
+
"""Collect a key, reporting whether it will be asked about."""
|
|
101
|
+
_require_width(self._relation, key)
|
|
102
|
+
if any(value is None for value in key):
|
|
103
|
+
self._skipped += 1
|
|
104
|
+
return False
|
|
105
|
+
if key in self._keys:
|
|
106
|
+
return True
|
|
107
|
+
if len(self._keys) >= self._limit:
|
|
108
|
+
message = (
|
|
109
|
+
f"a relation batch holds {self._limit} keys, which is all it "
|
|
110
|
+
f"was allowed; drain it before collecting more"
|
|
111
|
+
)
|
|
112
|
+
raise QueryValidationError(message)
|
|
113
|
+
self._keys[key] = None
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def batches(self, *, maximum_parameters: int) -> tuple[KeyBatch, ...]:
|
|
117
|
+
return plan_key_batches(
|
|
118
|
+
self._relation,
|
|
119
|
+
self._keys,
|
|
120
|
+
maximum_parameters=maximum_parameters,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def drain(self) -> tuple[tuple[object, ...], ...]:
|
|
124
|
+
"""Take the keys collected so far and start again."""
|
|
125
|
+
keys = tuple(self._keys)
|
|
126
|
+
self._keys = {}
|
|
127
|
+
self._skipped = 0
|
|
128
|
+
return keys
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class RelationBatchLoader:
|
|
132
|
+
"""Every relation a scope is waiting on, each bounded on its own.
|
|
133
|
+
|
|
134
|
+
A loader belongs to one scope. Keys gathered for one request answer that
|
|
135
|
+
request, so a loader is not shared the way an observer is.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
__slots__ = ("_batches", "_key_limit")
|
|
139
|
+
|
|
140
|
+
def __init__(self, *, key_limit: int = DEFAULT_KEY_LIMIT) -> None:
|
|
141
|
+
_require_positive(key_limit, "key limit")
|
|
142
|
+
self._key_limit = key_limit
|
|
143
|
+
self._batches: dict[TypedRelation, RelationBatch] = {}
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def relations(self) -> tuple[TypedRelation, ...]:
|
|
147
|
+
return tuple(self._batches)
|
|
148
|
+
|
|
149
|
+
def __len__(self) -> int:
|
|
150
|
+
return sum(len(batch) for batch in self._batches.values())
|
|
151
|
+
|
|
152
|
+
def enqueue(self, relation: TypedRelation, key: tuple[object, ...], /) -> bool:
|
|
153
|
+
return self._batch_for(relation).add(key)
|
|
154
|
+
|
|
155
|
+
def batch_for(self, relation: TypedRelation, /) -> RelationBatch:
|
|
156
|
+
return self._batch_for(relation)
|
|
157
|
+
|
|
158
|
+
def batches(self, *, maximum_parameters: int) -> tuple[KeyBatch, ...]:
|
|
159
|
+
"""Every statement this scope now owes, across every relation."""
|
|
160
|
+
return tuple(
|
|
161
|
+
batch
|
|
162
|
+
for pending in self._batches.values()
|
|
163
|
+
for batch in pending.batches(maximum_parameters=maximum_parameters)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def drain(self, relation: TypedRelation, /) -> tuple[tuple[object, ...], ...]:
|
|
167
|
+
"""Take one relation's keys and start it again."""
|
|
168
|
+
return self._batch_for(relation).drain()
|
|
169
|
+
|
|
170
|
+
def drain_all(self) -> tuple[KeyBatch, ...]:
|
|
171
|
+
"""Take every relation's keys at once, as one statement each."""
|
|
172
|
+
drained = tuple(
|
|
173
|
+
KeyBatch(relation, batch.drain())
|
|
174
|
+
for relation, batch in self._batches.items()
|
|
175
|
+
)
|
|
176
|
+
return tuple(batch for batch in drained if batch.keys)
|
|
177
|
+
|
|
178
|
+
def _batch_for(self, relation: TypedRelation) -> RelationBatch:
|
|
179
|
+
batch = self._batches.get(relation)
|
|
180
|
+
if batch is None:
|
|
181
|
+
batch = RelationBatch(relation, key_limit=self._key_limit)
|
|
182
|
+
self._batches[relation] = batch
|
|
183
|
+
return batch
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _uniform_width(
|
|
187
|
+
relation: TypedRelation,
|
|
188
|
+
keys: tuple[tuple[object, ...], ...],
|
|
189
|
+
) -> int:
|
|
190
|
+
width = len(relation.target.columns)
|
|
191
|
+
for key in keys:
|
|
192
|
+
_require_width(relation, key)
|
|
193
|
+
return width
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _require_width(relation: TypedRelation, key: tuple[object, ...]) -> None:
|
|
197
|
+
width = len(relation.target.columns)
|
|
198
|
+
if len(key) == width:
|
|
199
|
+
return
|
|
200
|
+
message = (
|
|
201
|
+
f"a key for {relation.target.table.name.value!r} spans {width} columns, "
|
|
202
|
+
f"and this one holds {len(key)}"
|
|
203
|
+
)
|
|
204
|
+
raise QueryValidationError(message)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _require_positive(value: int, label: str) -> None:
|
|
208
|
+
if isinstance(value, bool) or value < 1:
|
|
209
|
+
message = f"{label} must be a positive integer"
|
|
210
|
+
raise QueryValidationError(message)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
__all__ = (
|
|
214
|
+
"DEFAULT_KEY_LIMIT",
|
|
215
|
+
"KeyBatch",
|
|
216
|
+
"RelationBatch",
|
|
217
|
+
"RelationBatchLoader",
|
|
218
|
+
"plan_key_batches",
|
|
219
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Deriving both navigable directions of every foreign key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from pyoq.relations.model import (
|
|
8
|
+
RelationCardinality,
|
|
9
|
+
RelationDirection,
|
|
10
|
+
RelationEndpoint,
|
|
11
|
+
TypedRelation,
|
|
12
|
+
)
|
|
13
|
+
from pyoq.schema import (
|
|
14
|
+
Catalog,
|
|
15
|
+
Identifier,
|
|
16
|
+
ObjectReference,
|
|
17
|
+
Relation,
|
|
18
|
+
Schema,
|
|
19
|
+
SchemaSnapshot,
|
|
20
|
+
Table,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def derive_relations(snapshot: SchemaSnapshot) -> tuple[TypedRelation, ...]:
|
|
25
|
+
"""Read every foreign key in a snapshot from both of its sides."""
|
|
26
|
+
return tuple(
|
|
27
|
+
relation
|
|
28
|
+
for catalog in snapshot.catalogs
|
|
29
|
+
for schema in catalog.schemas
|
|
30
|
+
for table in schema.tables
|
|
31
|
+
for relation in table_relations(catalog, schema, table)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def table_relations(
|
|
36
|
+
catalog: Catalog,
|
|
37
|
+
schema: Schema,
|
|
38
|
+
table: Table,
|
|
39
|
+
) -> Iterator[TypedRelation]:
|
|
40
|
+
"""Read the foreign keys one table declares from both of their sides."""
|
|
41
|
+
reference = ObjectReference(table.name, schema.name, catalog.name)
|
|
42
|
+
for relation in table.relations:
|
|
43
|
+
yield _forward(reference, table, relation)
|
|
44
|
+
yield _reverse(reference, table, relation)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _forward(
|
|
48
|
+
reference: ObjectReference,
|
|
49
|
+
table: Table,
|
|
50
|
+
relation: Relation,
|
|
51
|
+
) -> TypedRelation:
|
|
52
|
+
"""A foreign key names at most one row of the table it points at."""
|
|
53
|
+
return TypedRelation(
|
|
54
|
+
RelationDirection.FORWARD,
|
|
55
|
+
RelationCardinality.TO_ONE,
|
|
56
|
+
RelationEndpoint(reference, relation.columns),
|
|
57
|
+
RelationEndpoint(relation.target, relation.target_columns),
|
|
58
|
+
optional=_any_nullable(table, relation.columns),
|
|
59
|
+
constraint=relation.name,
|
|
60
|
+
on_update=relation.on_update,
|
|
61
|
+
on_delete=relation.on_delete,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _reverse(
|
|
66
|
+
reference: ObjectReference,
|
|
67
|
+
table: Table,
|
|
68
|
+
relation: Relation,
|
|
69
|
+
) -> TypedRelation:
|
|
70
|
+
"""The referenced row owns however many rows point back at it.
|
|
71
|
+
|
|
72
|
+
That is one row when the referencing columns are themselves unique, and any
|
|
73
|
+
number of rows otherwise. A referenced row may own none either way, so the
|
|
74
|
+
unique case is optional and the collection case is not.
|
|
75
|
+
"""
|
|
76
|
+
unique = _uniquely_constrained(table, relation.columns)
|
|
77
|
+
cardinality = RelationCardinality.TO_ONE if unique else RelationCardinality.TO_MANY
|
|
78
|
+
return TypedRelation(
|
|
79
|
+
RelationDirection.REVERSE,
|
|
80
|
+
cardinality,
|
|
81
|
+
RelationEndpoint(relation.target, relation.target_columns),
|
|
82
|
+
RelationEndpoint(reference, relation.columns),
|
|
83
|
+
optional=unique,
|
|
84
|
+
constraint=relation.name,
|
|
85
|
+
on_update=relation.on_update,
|
|
86
|
+
on_delete=relation.on_delete,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _any_nullable(table: Table, columns: tuple[Identifier, ...]) -> bool:
|
|
91
|
+
"""A foreign key with any null part references nothing at all."""
|
|
92
|
+
names = frozenset(column.value for column in columns)
|
|
93
|
+
return any(
|
|
94
|
+
column.nullable for column in table.columns if column.name.value in names
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _uniquely_constrained(table: Table, columns: tuple[Identifier, ...]) -> bool:
|
|
99
|
+
"""Report whether these columns can repeat within the table.
|
|
100
|
+
|
|
101
|
+
A key covering a subset of the columns is enough, because a subset that is
|
|
102
|
+
already unique makes the wider set unique too.
|
|
103
|
+
"""
|
|
104
|
+
covered = frozenset(column.value for column in columns)
|
|
105
|
+
return any(
|
|
106
|
+
frozenset(column.value for column in key.columns) <= covered
|
|
107
|
+
for key in table.keys
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ("derive_relations", "table_relations")
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Saying which relations a query fetches, and how, before it runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from pyoq.errors import FetchPlanError, RelationResolutionError
|
|
9
|
+
from pyoq.relations.graph import RelationGraph
|
|
10
|
+
from pyoq.relations.model import RelationCardinality, TypedRelation
|
|
11
|
+
from pyoq.schema import Identifier, ObjectReference
|
|
12
|
+
|
|
13
|
+
MAXIMUM_FETCH_DEPTH = 8
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FetchStrategy(StrEnum):
|
|
17
|
+
"""How a relation's rows are to be brought back.
|
|
18
|
+
|
|
19
|
+
``AUTO`` is a request rather than an instruction. It is resolved against a
|
|
20
|
+
dialect's capabilities when the query is planned, so a plan can be written
|
|
21
|
+
once and still take the best route each database offers.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
JOINED = "joined"
|
|
25
|
+
NESTED = "nested"
|
|
26
|
+
SELECT_IN = "select-in"
|
|
27
|
+
AUTO = "auto"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class FetchOrder:
|
|
32
|
+
"""One term a collection is ordered by, and which way it runs.
|
|
33
|
+
|
|
34
|
+
A column is named, so a plan can check the related table has it. An
|
|
35
|
+
expression is named too, but only named: what it is finally written as
|
|
36
|
+
belongs to the layer that writes queries, and a plan does not reach up
|
|
37
|
+
into one. Naming it is still enough to plan with, because whether a
|
|
38
|
+
collection is ordered at all is what decides how it can be fetched.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
column: Identifier | None = None
|
|
43
|
+
descending: bool = False
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
if not self.name:
|
|
47
|
+
message = "a term a collection is ordered by needs a name"
|
|
48
|
+
raise FetchPlanError(message)
|
|
49
|
+
|
|
50
|
+
def describe(self) -> str:
|
|
51
|
+
"""How this term reads when a plan explains itself."""
|
|
52
|
+
return f"{self.name} descending" if self.descending else self.name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def by_column(name: str, /, *, descending: bool = False) -> FetchOrder:
|
|
56
|
+
"""Order a collection by one of the related table's own columns."""
|
|
57
|
+
return FetchOrder(name, Identifier(name), descending=descending)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def by_expression(name: str, /, *, descending: bool = False) -> FetchOrder:
|
|
61
|
+
"""Order a collection by an expression, under the name a plan reports.
|
|
62
|
+
|
|
63
|
+
The plan carries the name and not the expression, so it stays checkable
|
|
64
|
+
against a schema and readable when it explains itself. The expression is
|
|
65
|
+
given to whatever carries the plan out, which is the layer that knows how
|
|
66
|
+
to write one.
|
|
67
|
+
"""
|
|
68
|
+
return FetchOrder(name, descending=descending)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class FetchJoin:
|
|
73
|
+
"""A table a collection reads alongside the one its relation names.
|
|
74
|
+
|
|
75
|
+
The relation still decides which rows there are. A join decides what each
|
|
76
|
+
of them carries, which is why it is named here by the relation that
|
|
77
|
+
reaches it rather than by a table and a condition a caller writes out.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
relation: TypedRelation
|
|
81
|
+
carrying: tuple[Identifier, ...]
|
|
82
|
+
"""Columns of the joined table each row of the collection carries."""
|
|
83
|
+
|
|
84
|
+
def __post_init__(self) -> None:
|
|
85
|
+
if not self.carrying:
|
|
86
|
+
message = "a joined table carries at least one column"
|
|
87
|
+
raise FetchPlanError(message)
|
|
88
|
+
if not self.relation.to_one:
|
|
89
|
+
message = (
|
|
90
|
+
"a collection joins a table one of its rows points at, and "
|
|
91
|
+
f"{self.relation.target.table.name.value!r} is reached through "
|
|
92
|
+
"a to-many relation; fetch it as a collection of its own"
|
|
93
|
+
)
|
|
94
|
+
raise FetchPlanError(message)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class FetchRequest:
|
|
99
|
+
"""One relation to fetch, and whatever is to be fetched through it.
|
|
100
|
+
|
|
101
|
+
``order_by`` is the terms the collection is ordered by, each a column of
|
|
102
|
+
the related table or an expression over it. A collection whose order
|
|
103
|
+
matters cannot be fetched the same way on every dialect, so saying whether
|
|
104
|
+
it matters is what lets a plan be resolved rather than guessed.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
relation: TypedRelation
|
|
108
|
+
strategy: FetchStrategy = FetchStrategy.AUTO
|
|
109
|
+
nested: tuple[FetchRequest, ...] = ()
|
|
110
|
+
order_by: tuple[FetchOrder, ...] = ()
|
|
111
|
+
joins: tuple[FetchJoin, ...] = ()
|
|
112
|
+
limit: int | None = None
|
|
113
|
+
"""How many rows of the collection to keep, where only the first few matter.
|
|
114
|
+
|
|
115
|
+
The rows kept are the ones the order put first, so a limit without an
|
|
116
|
+
order would keep an arbitrary few. Asking for one is refused here rather
|
|
117
|
+
than answered with rows nobody chose.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __post_init__(self) -> None:
|
|
121
|
+
if self.limit is None:
|
|
122
|
+
return
|
|
123
|
+
if self.limit < 1:
|
|
124
|
+
message = "a collection keeps at least one row or is not limited"
|
|
125
|
+
raise FetchPlanError(message)
|
|
126
|
+
if not self.order_by:
|
|
127
|
+
message = (
|
|
128
|
+
"a limited collection is ordered, so the rows it keeps are "
|
|
129
|
+
"the ones the order put first"
|
|
130
|
+
)
|
|
131
|
+
raise FetchPlanError(message)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def table(self) -> ObjectReference:
|
|
135
|
+
return self.relation.target.table
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def depth(self) -> int:
|
|
139
|
+
"""How many levels this request reaches, counted without recursion.
|
|
140
|
+
|
|
141
|
+
A key that points back at its own table lets a plan nest as far as the
|
|
142
|
+
caller cares to build, and a depth that could not be measured without
|
|
143
|
+
exhausting the stack would leave the depth limit unable to refuse it.
|
|
144
|
+
"""
|
|
145
|
+
deepest = 0
|
|
146
|
+
pending: list[tuple[FetchRequest, int]] = [(self, 1)]
|
|
147
|
+
while pending:
|
|
148
|
+
request, level = pending.pop()
|
|
149
|
+
deepest = max(deepest, level)
|
|
150
|
+
pending.extend((nested, level + 1) for nested in request.nested)
|
|
151
|
+
return deepest
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class FetchPlan:
|
|
156
|
+
"""Everything one query fetches beyond its own rows."""
|
|
157
|
+
|
|
158
|
+
root: ObjectReference
|
|
159
|
+
requests: tuple[FetchRequest, ...] = field(default_factory=tuple)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def depth(self) -> int:
|
|
163
|
+
return max((request.depth for request in self.requests), default=0)
|
|
164
|
+
|
|
165
|
+
def describe(self) -> tuple[str, ...]:
|
|
166
|
+
"""Read the plan back as lines, so it can be seen before it runs."""
|
|
167
|
+
lines: list[str] = []
|
|
168
|
+
pending = [(request, 0) for request in reversed(self.requests)]
|
|
169
|
+
while pending:
|
|
170
|
+
request, level = pending.pop()
|
|
171
|
+
lines.append(_describe(request, level))
|
|
172
|
+
pending.extend((nested, level + 1) for nested in reversed(request.nested))
|
|
173
|
+
return tuple(lines)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_fetch_plan(
|
|
177
|
+
plan: FetchPlan,
|
|
178
|
+
graph: RelationGraph,
|
|
179
|
+
*,
|
|
180
|
+
maximum_depth: int = MAXIMUM_FETCH_DEPTH,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Refuse a plan that describes a fetch the schema cannot perform.
|
|
183
|
+
|
|
184
|
+
A plan is checked once, before any query runs, so a mistake is reported
|
|
185
|
+
against the schema rather than as a failure part-way through a fetch.
|
|
186
|
+
"""
|
|
187
|
+
if maximum_depth < 1:
|
|
188
|
+
message = "a fetch plan must be allowed at least one level"
|
|
189
|
+
raise FetchPlanError(message)
|
|
190
|
+
pending = [(plan.requests, graph.resolve(plan.root), 1)]
|
|
191
|
+
while pending:
|
|
192
|
+
requests, table, level = pending.pop()
|
|
193
|
+
_require_within_depth(level, maximum_depth)
|
|
194
|
+
pending.extend(_validate_level(requests, table, graph, level))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _validate_level(
|
|
198
|
+
requests: tuple[FetchRequest, ...],
|
|
199
|
+
table: ObjectReference,
|
|
200
|
+
graph: RelationGraph,
|
|
201
|
+
level: int,
|
|
202
|
+
) -> list[tuple[tuple[FetchRequest, ...], ObjectReference, int]]:
|
|
203
|
+
available = graph.relations_from(table)
|
|
204
|
+
seen: set[TypedRelation] = set()
|
|
205
|
+
deeper: list[tuple[tuple[FetchRequest, ...], ObjectReference, int]] = []
|
|
206
|
+
for request in requests:
|
|
207
|
+
_require_available(request, table, available)
|
|
208
|
+
_require_unrepeated(request, seen)
|
|
209
|
+
_require_order_columns(request, graph)
|
|
210
|
+
_require_joins(request, graph)
|
|
211
|
+
if request.nested:
|
|
212
|
+
deeper.append((request.nested, graph.resolve(request.table), level + 1))
|
|
213
|
+
return deeper
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _require_within_depth(level: int, maximum_depth: int) -> None:
|
|
217
|
+
if level <= maximum_depth:
|
|
218
|
+
return
|
|
219
|
+
message = f"fetch plan nests beyond the {maximum_depth} levels allowed"
|
|
220
|
+
raise FetchPlanError(message)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _require_available(
|
|
224
|
+
request: FetchRequest,
|
|
225
|
+
table: ObjectReference,
|
|
226
|
+
available: tuple[TypedRelation, ...],
|
|
227
|
+
) -> None:
|
|
228
|
+
if request.relation in available:
|
|
229
|
+
return
|
|
230
|
+
message = (
|
|
231
|
+
f"{_describe_table(table)} has no relation to "
|
|
232
|
+
f"{_describe_table(request.table)} matching this request"
|
|
233
|
+
)
|
|
234
|
+
raise FetchPlanError(message)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _require_order_columns(request: FetchRequest, graph: RelationGraph) -> None:
|
|
238
|
+
"""An order naming a column the table has not got is a mistake in the plan.
|
|
239
|
+
|
|
240
|
+
A term that only names an expression names no column to check.
|
|
241
|
+
"""
|
|
242
|
+
named = tuple(term.column for term in request.order_by if term.column is not None)
|
|
243
|
+
_require_columns(request.table, named, graph, "is ordered by")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _require_joins(request: FetchRequest, graph: RelationGraph) -> None:
|
|
247
|
+
"""A join reaches its table from the one being fetched, and carries columns of it.
|
|
248
|
+
|
|
249
|
+
Both are checked here rather than left to the query that is finally
|
|
250
|
+
written, because a join the schema cannot support is a mistake in the plan
|
|
251
|
+
and reads far better against the schema than as SQL a server rejects.
|
|
252
|
+
"""
|
|
253
|
+
available = graph.relations_from(request.table)
|
|
254
|
+
for join in request.joins:
|
|
255
|
+
_require_reachable(request, join, available)
|
|
256
|
+
_require_columns(join.relation.target.table, join.carrying, graph, "carries")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _require_reachable(
|
|
260
|
+
request: FetchRequest,
|
|
261
|
+
join: FetchJoin,
|
|
262
|
+
available: tuple[TypedRelation, ...],
|
|
263
|
+
) -> None:
|
|
264
|
+
if join.relation in available:
|
|
265
|
+
return
|
|
266
|
+
message = (
|
|
267
|
+
f"{_describe_table(request.table)} has no relation to "
|
|
268
|
+
f"{_describe_table(join.relation.target.table)} to join it by"
|
|
269
|
+
)
|
|
270
|
+
raise FetchPlanError(message)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _require_columns(
|
|
274
|
+
table: ObjectReference,
|
|
275
|
+
named: tuple[Identifier, ...],
|
|
276
|
+
graph: RelationGraph,
|
|
277
|
+
verb: str,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""A table the snapshot does not describe cannot be checked.
|
|
280
|
+
|
|
281
|
+
That is the same answer the graph gives everywhere else about a table it
|
|
282
|
+
does not hold.
|
|
283
|
+
"""
|
|
284
|
+
if not named:
|
|
285
|
+
return
|
|
286
|
+
try:
|
|
287
|
+
described = graph.table(table)
|
|
288
|
+
except RelationResolutionError:
|
|
289
|
+
return
|
|
290
|
+
known = frozenset(column.name for column in described.columns)
|
|
291
|
+
missing = tuple(column for column in named if column not in known)
|
|
292
|
+
if not missing:
|
|
293
|
+
return
|
|
294
|
+
names = ", ".join(column.value for column in missing)
|
|
295
|
+
message = f"{table.name.value!r} {verb} {names}, which it has not got"
|
|
296
|
+
raise FetchPlanError(message)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _require_unrepeated(request: FetchRequest, seen: set[TypedRelation]) -> None:
|
|
300
|
+
if request.relation in seen:
|
|
301
|
+
message = (
|
|
302
|
+
f"the relation to {_describe_table(request.table)} is fetched twice "
|
|
303
|
+
f"at the same level"
|
|
304
|
+
)
|
|
305
|
+
raise FetchPlanError(message)
|
|
306
|
+
seen.add(request.relation)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _describe(request: FetchRequest, level: int) -> str:
|
|
310
|
+
collection = (
|
|
311
|
+
" []" if request.relation.cardinality is RelationCardinality.TO_MANY else ""
|
|
312
|
+
)
|
|
313
|
+
return (
|
|
314
|
+
f"{' ' * level}{_describe_table(request.table)}{collection} "
|
|
315
|
+
f"via {request.strategy.value}"
|
|
316
|
+
f"{_describe_joins(request)}{_describe_limit(request)}"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _describe_joins(request: FetchRequest) -> str:
|
|
321
|
+
"""A plan explains what widens each row, not only which rows there are."""
|
|
322
|
+
if not request.joins:
|
|
323
|
+
return ""
|
|
324
|
+
joined = ", ".join(
|
|
325
|
+
_describe_table(join.relation.target.table) for join in request.joins
|
|
326
|
+
)
|
|
327
|
+
return f" joining {joined}"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _describe_limit(request: FetchRequest) -> str:
|
|
331
|
+
if request.limit is None:
|
|
332
|
+
return ""
|
|
333
|
+
return f" keeping the first {request.limit}"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _describe_table(reference: ObjectReference) -> str:
|
|
337
|
+
parts = tuple(
|
|
338
|
+
part.value
|
|
339
|
+
for part in (reference.catalog, reference.schema, reference.name)
|
|
340
|
+
if part is not None
|
|
341
|
+
)
|
|
342
|
+
return ".".join(parts)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
__all__ = (
|
|
346
|
+
"MAXIMUM_FETCH_DEPTH",
|
|
347
|
+
"FetchJoin",
|
|
348
|
+
"FetchOrder",
|
|
349
|
+
"FetchPlan",
|
|
350
|
+
"FetchRequest",
|
|
351
|
+
"FetchStrategy",
|
|
352
|
+
"by_column",
|
|
353
|
+
"by_expression",
|
|
354
|
+
"validate_fetch_plan",
|
|
355
|
+
)
|