pyoq-sql 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. pyoq/__init__.py +10 -0
  2. pyoq/__main__.py +5 -0
  3. pyoq/_native.pyi +5 -0
  4. pyoq/cli/__init__.py +5 -0
  5. pyoq/cli/commands.py +270 -0
  6. pyoq/cli/defaults.py +98 -0
  7. pyoq/cli/services.py +97 -0
  8. pyoq/config/__init__.py +31 -0
  9. pyoq/config/connection.py +161 -0
  10. pyoq/config/loader.py +289 -0
  11. pyoq/config/models.py +245 -0
  12. pyoq/config/values.py +142 -0
  13. pyoq/descriptors.py +165 -0
  14. pyoq/diagnostics/__init__.py +68 -0
  15. pyoq/diagnostics/budget.py +136 -0
  16. pyoq/diagnostics/events.py +137 -0
  17. pyoq/diagnostics/fingerprint.py +267 -0
  18. pyoq/diagnostics/instrumented.py +237 -0
  19. pyoq/diagnostics/metrics.py +61 -0
  20. pyoq/diagnostics/observation.py +227 -0
  21. pyoq/diagnostics/scoped.py +103 -0
  22. pyoq/django/__init__.py +15 -0
  23. pyoq/django/apps.py +17 -0
  24. pyoq/django/execution.py +317 -0
  25. pyoq/django/generation.py +59 -0
  26. pyoq/django/management/__init__.py +0 -0
  27. pyoq/django/management/commands/__init__.py +0 -0
  28. pyoq/django/management/commands/makemigrations.py +53 -0
  29. pyoq/django/management/commands/pyoq_codegen.py +75 -0
  30. pyoq/django/parameters.py +101 -0
  31. pyoq/django/schema.py +379 -0
  32. pyoq/django/settings.py +87 -0
  33. pyoq/django/timeouts.py +105 -0
  34. pyoq/dsl/__init__.py +64 -0
  35. pyoq/dsl/aio/__init__.py +31 -0
  36. pyoq/dsl/aio/context.py +295 -0
  37. pyoq/dsl/aio/queries.py +335 -0
  38. pyoq/dsl/aio/writes.py +368 -0
  39. pyoq/dsl/context.py +326 -0
  40. pyoq/dsl/entry.py +37 -0
  41. pyoq/dsl/labels.py +36 -0
  42. pyoq/dsl/queries.py +339 -0
  43. pyoq/dsl/result.py +164 -0
  44. pyoq/dsl/writes.py +360 -0
  45. pyoq/errors.py +317 -0
  46. pyoq/fastapi/__init__.py +32 -0
  47. pyoq/fastapi/dependencies.py +167 -0
  48. pyoq/fastapi/lifespan.py +119 -0
  49. pyoq/fetching/__init__.py +55 -0
  50. pyoq/fetching/collections.py +136 -0
  51. pyoq/fetching/execution.py +587 -0
  52. pyoq/fetching/joined.py +79 -0
  53. pyoq/fetching/nesting.py +183 -0
  54. pyoq/fetching/plans.py +541 -0
  55. pyoq/fetching/select_in.py +149 -0
  56. pyoq/fetching/tables.py +110 -0
  57. pyoq/generation/__init__.py +54 -0
  58. pyoq/generation/cleanup.py +44 -0
  59. pyoq/generation/contracts.py +248 -0
  60. pyoq/generation/drift.py +169 -0
  61. pyoq/generation/lock.py +33 -0
  62. pyoq/generation/manifest.py +114 -0
  63. pyoq/generation/model.py +1001 -0
  64. pyoq/generation/pipeline.py +119 -0
  65. pyoq/generation/rendering/__init__.py +5 -0
  66. pyoq/generation/rendering/domains.py +51 -0
  67. pyoq/generation/rendering/enums.py +29 -0
  68. pyoq/generation/rendering/exports.py +70 -0
  69. pyoq/generation/rendering/imports.py +63 -0
  70. pyoq/generation/rendering/package.py +56 -0
  71. pyoq/generation/rendering/relations.py +133 -0
  72. pyoq/generation/rendering/routines.py +396 -0
  73. pyoq/generation/rendering/rows.py +79 -0
  74. pyoq/generation/rendering/source.py +121 -0
  75. pyoq/generation/rendering/tables.py +300 -0
  76. pyoq/generation/rendering/writes.py +514 -0
  77. pyoq/generation/validation.py +27 -0
  78. pyoq/generation/writer.py +184 -0
  79. pyoq/hydration/__init__.py +24 -0
  80. pyoq/hydration/engine.py +155 -0
  81. pyoq/hydration/identity.py +194 -0
  82. pyoq/hydration/plan.py +116 -0
  83. pyoq/migrations/__init__.py +9 -0
  84. pyoq/migrations/alembic.py +106 -0
  85. pyoq/migrations/hooks.py +75 -0
  86. pyoq/naming.py +261 -0
  87. pyoq/policies/__init__.py +47 -0
  88. pyoq/policies/bypass.py +122 -0
  89. pyoq/policies/governed.py +430 -0
  90. pyoq/policies/model.py +242 -0
  91. pyoq/policies/rewriting.py +263 -0
  92. pyoq/py.typed +1 -0
  93. pyoq/query/__init__.py +312 -0
  94. pyoq/query/aggregates.py +172 -0
  95. pyoq/query/arrays.py +65 -0
  96. pyoq/query/binding.py +52 -0
  97. pyoq/query/capabilities.py +317 -0
  98. pyoq/query/casts.py +73 -0
  99. pyoq/query/choices.py +185 -0
  100. pyoq/query/decoding.py +360 -0
  101. pyoq/query/documents.py +56 -0
  102. pyoq/query/execution/__init__.py +63 -0
  103. pyoq/query/execution/aio/__init__.py +31 -0
  104. pyoq/query/execution/aio/operations.py +228 -0
  105. pyoq/query/execution/aio/pooling.py +233 -0
  106. pyoq/query/execution/aio/streaming.py +161 -0
  107. pyoq/query/execution/aio/transactions.py +105 -0
  108. pyoq/query/execution/batch.py +96 -0
  109. pyoq/query/execution/binding_style.py +30 -0
  110. pyoq/query/execution/compilation.py +48 -0
  111. pyoq/query/execution/context.py +61 -0
  112. pyoq/query/execution/control.py +50 -0
  113. pyoq/query/execution/operations.py +224 -0
  114. pyoq/query/execution/planning.py +107 -0
  115. pyoq/query/execution/pooling.py +279 -0
  116. pyoq/query/execution/results.py +36 -0
  117. pyoq/query/execution/streaming.py +178 -0
  118. pyoq/query/execution/transactions.py +95 -0
  119. pyoq/query/expressions.py +1200 -0
  120. pyoq/query/fields.py +60 -0
  121. pyoq/query/mysql/__init__.py +59 -0
  122. pyoq/query/mysql/aio/__init__.py +38 -0
  123. pyoq/query/mysql/aio/commands.py +389 -0
  124. pyoq/query/mysql/aio/driver.py +196 -0
  125. pyoq/query/mysql/aio/executor.py +123 -0
  126. pyoq/query/mysql/aio/factory.py +26 -0
  127. pyoq/query/mysql/aio/operations.py +38 -0
  128. pyoq/query/mysql/aio/pool.py +53 -0
  129. pyoq/query/mysql/aio/transactions.py +313 -0
  130. pyoq/query/mysql/commands.py +354 -0
  131. pyoq/query/mysql/compiler.py +134 -0
  132. pyoq/query/mysql/context.py +20 -0
  133. pyoq/query/mysql/executor.py +126 -0
  134. pyoq/query/mysql/expressions.py +244 -0
  135. pyoq/query/mysql/factory.py +46 -0
  136. pyoq/query/mysql/health.py +66 -0
  137. pyoq/query/mysql/identifiers.py +9 -0
  138. pyoq/query/mysql/model.py +79 -0
  139. pyoq/query/mysql/operations.py +43 -0
  140. pyoq/query/mysql/parameters.py +69 -0
  141. pyoq/query/mysql/planning.py +20 -0
  142. pyoq/query/mysql/pool.py +67 -0
  143. pyoq/query/mysql/transactions.py +331 -0
  144. pyoq/query/mysql/writes.py +73 -0
  145. pyoq/query/nodes.py +750 -0
  146. pyoq/query/postgres/__init__.py +48 -0
  147. pyoq/query/postgres/aio/__init__.py +25 -0
  148. pyoq/query/postgres/aio/bulk.py +56 -0
  149. pyoq/query/postgres/aio/commands.py +264 -0
  150. pyoq/query/postgres/aio/executor.py +152 -0
  151. pyoq/query/postgres/aio/factory.py +26 -0
  152. pyoq/query/postgres/aio/operations.py +26 -0
  153. pyoq/query/postgres/aio/pool.py +40 -0
  154. pyoq/query/postgres/aio/transactions.py +295 -0
  155. pyoq/query/postgres/bulk.py +62 -0
  156. pyoq/query/postgres/commands.py +238 -0
  157. pyoq/query/postgres/compiler.py +114 -0
  158. pyoq/query/postgres/context.py +20 -0
  159. pyoq/query/postgres/executor.py +147 -0
  160. pyoq/query/postgres/expressions.py +311 -0
  161. pyoq/query/postgres/factory.py +24 -0
  162. pyoq/query/postgres/health.py +24 -0
  163. pyoq/query/postgres/identifiers.py +9 -0
  164. pyoq/query/postgres/model.py +81 -0
  165. pyoq/query/postgres/operations.py +25 -0
  166. pyoq/query/postgres/parameters.py +71 -0
  167. pyoq/query/postgres/planning.py +20 -0
  168. pyoq/query/postgres/pool.py +52 -0
  169. pyoq/query/postgres/transactions.py +295 -0
  170. pyoq/query/postgres/writes.py +37 -0
  171. pyoq/query/projections.py +105 -0
  172. pyoq/query/raw.py +90 -0
  173. pyoq/query/recursion.py +265 -0
  174. pyoq/query/rendering/__init__.py +1 -0
  175. pyoq/query/rendering/expressions.py +913 -0
  176. pyoq/query/rendering/identifiers.py +40 -0
  177. pyoq/query/rendering/projections.py +63 -0
  178. pyoq/query/rendering/queries.py +334 -0
  179. pyoq/query/rendering/sources.py +66 -0
  180. pyoq/query/rendering/writes.py +176 -0
  181. pyoq/query/results.py +459 -0
  182. pyoq/query/routines.py +196 -0
  183. pyoq/query/rows.py +156 -0
  184. pyoq/query/select.py +793 -0
  185. pyoq/query/select_nodes.py +277 -0
  186. pyoq/query/sources.py +236 -0
  187. pyoq/query/sqlite/__init__.py +43 -0
  188. pyoq/query/sqlite/commands.py +201 -0
  189. pyoq/query/sqlite/compiler.py +139 -0
  190. pyoq/query/sqlite/context.py +20 -0
  191. pyoq/query/sqlite/executor.py +119 -0
  192. pyoq/query/sqlite/expressions.py +224 -0
  193. pyoq/query/sqlite/factory.py +32 -0
  194. pyoq/query/sqlite/health.py +28 -0
  195. pyoq/query/sqlite/identifiers.py +9 -0
  196. pyoq/query/sqlite/model.py +73 -0
  197. pyoq/query/sqlite/operations.py +36 -0
  198. pyoq/query/sqlite/parameters.py +50 -0
  199. pyoq/query/sqlite/planning.py +20 -0
  200. pyoq/query/sqlite/pool.py +50 -0
  201. pyoq/query/sqlite/streaming.py +13 -0
  202. pyoq/query/sqlite/transactions.py +274 -0
  203. pyoq/query/sqlite/writes.py +35 -0
  204. pyoq/query/statements.py +27 -0
  205. pyoq/query/values.py +23 -0
  206. pyoq/query/vendor.py +162 -0
  207. pyoq/query/windows.py +424 -0
  208. pyoq/query/write_nodes.py +174 -0
  209. pyoq/query/writes.py +628 -0
  210. pyoq/relations/__init__.py +66 -0
  211. pyoq/relations/batching.py +219 -0
  212. pyoq/relations/derivation.py +111 -0
  213. pyoq/relations/fetching.py +355 -0
  214. pyoq/relations/graph.py +245 -0
  215. pyoq/relations/loading.py +74 -0
  216. pyoq/relations/model.py +75 -0
  217. pyoq/relations/planning.py +206 -0
  218. pyoq/runtime/__init__.py +9 -0
  219. pyoq/runtime/kernels.py +25 -0
  220. pyoq/runtime/python.py +43 -0
  221. pyoq/runtime/selection.py +73 -0
  222. pyoq/sanic/__init__.py +32 -0
  223. pyoq/sanic/scope.py +197 -0
  224. pyoq/sanic/workers.py +129 -0
  225. pyoq/schema/__init__.py +108 -0
  226. pyoq/schema/codec.py +711 -0
  227. pyoq/schema/models.py +604 -0
  228. pyoq/schema/mysql/__init__.py +16 -0
  229. pyoq/schema/mysql/connection.py +73 -0
  230. pyoq/schema/mysql/dsn.py +72 -0
  231. pyoq/schema/mysql/records.py +354 -0
  232. pyoq/schema/mysql/reflection.py +309 -0
  233. pyoq/schema/mysql/source.py +30 -0
  234. pyoq/schema/mysql/sql.py +128 -0
  235. pyoq/schema/mysql/types.py +105 -0
  236. pyoq/schema/postgres/__init__.py +13 -0
  237. pyoq/schema/postgres/connection.py +63 -0
  238. pyoq/schema/postgres/records.py +384 -0
  239. pyoq/schema/postgres/reflection.py +466 -0
  240. pyoq/schema/postgres/source.py +30 -0
  241. pyoq/schema/postgres/sql.py +246 -0
  242. pyoq/schema/postgres/types.py +98 -0
  243. pyoq/schema/registry.py +45 -0
  244. pyoq/schema/source.py +15 -0
  245. pyoq/schema/sqlite/__init__.py +6 -0
  246. pyoq/schema/sqlite/connection.py +54 -0
  247. pyoq/schema/sqlite/records.py +167 -0
  248. pyoq/schema/sqlite/reflection.py +393 -0
  249. pyoq/schema/sqlite/source.py +30 -0
  250. pyoq/schema/sqlite/sql.py +254 -0
  251. pyoq/schema/sqlite/types.py +74 -0
  252. pyoq/serving/__init__.py +23 -0
  253. pyoq/serving/databases.py +107 -0
  254. pyoq/serving/opening.py +331 -0
  255. pyoq/snapshots/__init__.py +20 -0
  256. pyoq/snapshots/drift.py +312 -0
  257. pyoq/snapshots/files.py +96 -0
  258. pyoq/snapshots/routing.py +40 -0
  259. pyoq/snapshots/source.py +33 -0
  260. pyoq/tracing/__init__.py +5 -0
  261. pyoq/tracing/spans.py +89 -0
  262. pyoq/unset.py +14 -0
  263. pyoq_sql-1.0.2.dist-info/METADATA +3050 -0
  264. pyoq_sql-1.0.2.dist-info/RECORD +267 -0
  265. pyoq_sql-1.0.2.dist-info/WHEEL +4 -0
  266. pyoq_sql-1.0.2.dist-info/entry_points.txt +3 -0
  267. pyoq_sql-1.0.2.dist-info/licenses/LICENSE +373 -0
@@ -0,0 +1,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
+ )