lorebase-keeper 0.2.3__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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
"""Record and Link reads: get, list and query, Links of a Record or a Relation.
|
|
2
|
+
|
|
3
|
+
Reads at the branch head use the materialized rows and the query compiler; reads at another
|
|
4
|
+
Revision go through `TreeReader` for key lookups, listings of a Kind or a Trait ordered by key,
|
|
5
|
+
and Links (path Derivations composed from the Links of their steps there), and through a
|
|
6
|
+
snapshot of the Revision (`reading.snapshots`) for everything else.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import Select, and_, func, literal, literal_column, select
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
18
|
+
|
|
19
|
+
from lorebase_core.model import Kind, LinkDirection, PathRelation, Relation
|
|
20
|
+
from lorebase_core.model.paths import walk
|
|
21
|
+
from lorebase_core.model.relation import RelationBacking
|
|
22
|
+
from lorebase_core.query import (
|
|
23
|
+
KEY_FIELD,
|
|
24
|
+
Hop,
|
|
25
|
+
IncludePlan,
|
|
26
|
+
Query,
|
|
27
|
+
QueryError,
|
|
28
|
+
QueryPlan,
|
|
29
|
+
plan_kinds,
|
|
30
|
+
resolve_hop,
|
|
31
|
+
resolve_query,
|
|
32
|
+
target_of_kind,
|
|
33
|
+
)
|
|
34
|
+
from lorebase_core.tree import kind_partition, relation_partition
|
|
35
|
+
from lorebase_keeper.reading.compiler import (
|
|
36
|
+
Compiler,
|
|
37
|
+
SortKey,
|
|
38
|
+
after,
|
|
39
|
+
cursor_value,
|
|
40
|
+
from_cursor_value,
|
|
41
|
+
json_text,
|
|
42
|
+
order_clauses,
|
|
43
|
+
record_columns,
|
|
44
|
+
)
|
|
45
|
+
from lorebase_keeper.reading.cursors import invalid_cursor, issue, position_of
|
|
46
|
+
from lorebase_keeper.reading.points import ReadPoint, not_found
|
|
47
|
+
from lorebase_keeper.reading.problems import query_problem
|
|
48
|
+
from lorebase_keeper.reading.runtime import ReadRuntime
|
|
49
|
+
from lorebase_keeper.reading.sensitivity import require_comparable_plan
|
|
50
|
+
from lorebase_keeper.reading.snapshots import with_rows
|
|
51
|
+
from lorebase_keeper.reading.tree import TreeReader
|
|
52
|
+
from lorebase_keeper.reading.views import (
|
|
53
|
+
AggregateCount,
|
|
54
|
+
AggregateOut,
|
|
55
|
+
LineageOut,
|
|
56
|
+
LinkCountOut,
|
|
57
|
+
LinkOut,
|
|
58
|
+
RecordOut,
|
|
59
|
+
kind_name,
|
|
60
|
+
link_values,
|
|
61
|
+
record_out,
|
|
62
|
+
record_ref,
|
|
63
|
+
relation_name,
|
|
64
|
+
)
|
|
65
|
+
from lorebase_keeper.tracing.explain import explain
|
|
66
|
+
from lorebase_store.indexes import plan_indexes
|
|
67
|
+
from lorebase_store.materialize.rows import record_title, split_link_key
|
|
68
|
+
from lorebase_store.reads import hydrate_records, links_to_record
|
|
69
|
+
from lorebase_store.repositories import ProvenanceRepository
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class FetchedRecord:
|
|
74
|
+
"""A Record row (materialized columns, or tree content with the same keys)."""
|
|
75
|
+
|
|
76
|
+
row: Mapping[Any, Any]
|
|
77
|
+
object_hash: bytes
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class QueryPage:
|
|
82
|
+
items: list[RecordOut]
|
|
83
|
+
next_cursor: str | None
|
|
84
|
+
aggregate: AggregateOut | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class LinkPageResult:
|
|
89
|
+
items: list[LinkOut]
|
|
90
|
+
next_cursor: str | None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def compiler_for(point: ReadPoint, runtime: ReadRuntime) -> Compiler:
|
|
94
|
+
return Compiler(
|
|
95
|
+
point.domain.id,
|
|
96
|
+
point.layout.branch,
|
|
97
|
+
text_search_config=runtime.store_settings.materialize.text_search_config,
|
|
98
|
+
indexes=plan_indexes(point.schema).by_attribute(),
|
|
99
|
+
layout=point.layout,
|
|
100
|
+
path_fanout=runtime.store_settings.traverse.path_fanout,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def require_kind(point: ReadPoint, api_name: str) -> Kind:
|
|
105
|
+
"""The Kind named `api_name`: 404 when unknown, or invisible to the caller (design 13.3);
|
|
106
|
+
403 when the caller may not read it and invisible Kinds are off."""
|
|
107
|
+
point.scope.require([api_name])
|
|
108
|
+
kind = point.schema.kind_by_api_name(api_name)
|
|
109
|
+
if kind is None:
|
|
110
|
+
raise not_found(f"Kind {api_name}")
|
|
111
|
+
return kind
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Single Record --------------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def fetch_record(
|
|
118
|
+
session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, kind: Kind, key: str
|
|
119
|
+
) -> FetchedRecord:
|
|
120
|
+
"""The Record `(kind, key)` at the read point; 404 when absent."""
|
|
121
|
+
if point.materialized:
|
|
122
|
+
r = point.layout.records()
|
|
123
|
+
found = (
|
|
124
|
+
(
|
|
125
|
+
await session.execute(
|
|
126
|
+
select(*record_columns(r)).where(
|
|
127
|
+
r.c.domain_id == point.domain.id,
|
|
128
|
+
r.c.branch == point.layout.branch,
|
|
129
|
+
r.c.kind_id == kind.id,
|
|
130
|
+
r.c.key == key,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
.mappings()
|
|
135
|
+
.one_or_none()
|
|
136
|
+
)
|
|
137
|
+
if found is None:
|
|
138
|
+
raise not_found(f"Record {kind.api_name}/{key}")
|
|
139
|
+
return FetchedRecord(found, bytes(found["object_hash"]))
|
|
140
|
+
reader = TreeReader(session, runtime, point.domain.id, point.revision.root)
|
|
141
|
+
digest = await reader.get(kind_partition(kind.id), key)
|
|
142
|
+
if digest is None:
|
|
143
|
+
raise not_found(f"Record {kind.api_name}/{key}")
|
|
144
|
+
body = (await reader.bodies([digest]))[digest]
|
|
145
|
+
return FetchedRecord(tree_record_row(kind, key, body), digest)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def tree_record_row(kind: Kind, key: str, body: Mapping[str, Any]) -> dict[str, Any]:
|
|
149
|
+
values = dict(body.get("values", {}))
|
|
150
|
+
return {
|
|
151
|
+
"kind_id": kind.id,
|
|
152
|
+
"key": key,
|
|
153
|
+
"values": values,
|
|
154
|
+
"title": record_title(kind, values),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def get_record(
|
|
159
|
+
session: AsyncSession,
|
|
160
|
+
runtime: ReadRuntime,
|
|
161
|
+
point: ReadPoint,
|
|
162
|
+
kind: Kind,
|
|
163
|
+
key: str,
|
|
164
|
+
*,
|
|
165
|
+
links: bool,
|
|
166
|
+
lineage: bool,
|
|
167
|
+
) -> tuple[RecordOut, FetchedRecord]:
|
|
168
|
+
"""A Record with, on request, its Link counts and the lineage of its values."""
|
|
169
|
+
if links or lineage:
|
|
170
|
+
point = await with_rows(session, runtime, point, "The links and lineage summaries")
|
|
171
|
+
fetched = await fetch_record(session, runtime, point, kind, key)
|
|
172
|
+
record = record_out(point.schema, fetched.row, masking=point.masking)
|
|
173
|
+
if links:
|
|
174
|
+
record.links = await link_counts(session, runtime, point, kind, fetched.row)
|
|
175
|
+
if lineage:
|
|
176
|
+
record.lineage = await lineage_summary(session, runtime, point, kind, fetched.row)
|
|
177
|
+
return record, fetched
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def link_counts(
|
|
181
|
+
session: AsyncSession,
|
|
182
|
+
runtime: ReadRuntime,
|
|
183
|
+
point: ReadPoint,
|
|
184
|
+
kind: Kind,
|
|
185
|
+
row: Mapping[Any, Any],
|
|
186
|
+
) -> list[LinkCountOut]:
|
|
187
|
+
"""Number of Links per Relation and direction touching the Record, path Derivations
|
|
188
|
+
included (a path only when the caller may read every Kind it crosses)."""
|
|
189
|
+
key = row["key"]
|
|
190
|
+
counts: list[LinkCountOut] = []
|
|
191
|
+
explicit: dict[LinkDirection, list[Relation]] = defaultdict(list)
|
|
192
|
+
for relation in sorted(point.schema.relations_of(kind.id), key=lambda r: r.api_name):
|
|
193
|
+
for direction in LinkDirection:
|
|
194
|
+
if relation.endpoint(direction) != kind.id:
|
|
195
|
+
continue
|
|
196
|
+
far = point.schema.kinds.get(relation.far_endpoint(direction))
|
|
197
|
+
if far is None or not point.scope.allows(far.api_name):
|
|
198
|
+
continue # Links to Kinds the caller may not read are not counted
|
|
199
|
+
if relation.backing is RelationBacking.FOREIGN_KEY:
|
|
200
|
+
count = await _foreign_key_count(session, point, relation, direction, row)
|
|
201
|
+
counts.append(
|
|
202
|
+
LinkCountOut(
|
|
203
|
+
relation=relation_name(relation, direction),
|
|
204
|
+
direction=direction,
|
|
205
|
+
count=count,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
else:
|
|
209
|
+
explicit[direction].append(relation)
|
|
210
|
+
links = point.layout.links()
|
|
211
|
+
for direction, relations in explicit.items():
|
|
212
|
+
near = links.c.from_key if direction is LinkDirection.FORWARD else links.c.to_key
|
|
213
|
+
result = await session.execute(
|
|
214
|
+
select(links.c.relation_id, func.count())
|
|
215
|
+
.where(
|
|
216
|
+
links.c.domain_id == point.domain.id,
|
|
217
|
+
links.c.branch == point.layout.branch,
|
|
218
|
+
links.c.relation_id.in_([r.id for r in relations]),
|
|
219
|
+
near == key,
|
|
220
|
+
)
|
|
221
|
+
.group_by(links.c.relation_id)
|
|
222
|
+
)
|
|
223
|
+
found = {relation_id: int(count) for relation_id, count in result}
|
|
224
|
+
counts.extend(
|
|
225
|
+
LinkCountOut(
|
|
226
|
+
relation=relation_name(r, direction), direction=direction, count=found.get(r.id, 0)
|
|
227
|
+
)
|
|
228
|
+
for r in relations
|
|
229
|
+
)
|
|
230
|
+
counts.extend(await _path_counts(session, runtime, point, kind, key))
|
|
231
|
+
return sorted(counts, key=lambda c: (c.relation, c.direction))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def _path_counts(
|
|
235
|
+
session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, kind: Kind, key: str
|
|
236
|
+
) -> list[LinkCountOut]:
|
|
237
|
+
"""Link counts of the path Derivations starting or ending at the Record's Kind, stored
|
|
238
|
+
or composed like their Links (`Compiler.link_source`)."""
|
|
239
|
+
target = target_of_kind(point.schema, kind.api_name)
|
|
240
|
+
assert target is not None
|
|
241
|
+
counts: list[LinkCountOut] = []
|
|
242
|
+
compiler: Compiler | None = None
|
|
243
|
+
for path in sorted(point.schema.path_relations().values(), key=lambda p: p.id):
|
|
244
|
+
for direction in LinkDirection:
|
|
245
|
+
name = relation_name(path.relation, direction)
|
|
246
|
+
if not name or path.relation.endpoint(direction) != kind.id:
|
|
247
|
+
continue
|
|
248
|
+
hop = resolve_hop(point.schema, target, name)
|
|
249
|
+
if isinstance(hop, str) or not all(point.scope.allows(k) for k in hop.kinds_read):
|
|
250
|
+
continue # paths crossing Kinds the caller may not read are not counted
|
|
251
|
+
compiler = compiler or compiler_for(point, runtime)
|
|
252
|
+
source = compiler.link_source(hop)
|
|
253
|
+
count = await session.scalar(
|
|
254
|
+
select(func.count())
|
|
255
|
+
.select_from(source.rows)
|
|
256
|
+
.where(source.scope, source.near == literal(key))
|
|
257
|
+
)
|
|
258
|
+
counts.append(LinkCountOut(relation=name, direction=direction, count=int(count or 0)))
|
|
259
|
+
return counts
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def _foreign_key_count(
|
|
263
|
+
session: AsyncSession,
|
|
264
|
+
point: ReadPoint,
|
|
265
|
+
relation: Relation,
|
|
266
|
+
direction: LinkDirection,
|
|
267
|
+
row: Mapping[Any, Any],
|
|
268
|
+
) -> int:
|
|
269
|
+
attribute = relation.foreign_key_attribute
|
|
270
|
+
assert attribute is not None
|
|
271
|
+
if direction is LinkDirection.FORWARD:
|
|
272
|
+
return 1 if attribute in (row.get("values") or {}) else 0
|
|
273
|
+
r = point.layout.records()
|
|
274
|
+
count = await session.scalar(
|
|
275
|
+
select(func.count()).where(
|
|
276
|
+
r.c.domain_id == point.domain.id,
|
|
277
|
+
r.c.branch == point.layout.branch,
|
|
278
|
+
r.c.kind_id == relation.from_kind,
|
|
279
|
+
json_text(r.c["values"], attribute) == row["key"],
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
return int(count or 0)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def lineage_summary(
|
|
286
|
+
session: AsyncSession,
|
|
287
|
+
runtime: ReadRuntime,
|
|
288
|
+
point: ReadPoint,
|
|
289
|
+
kind: Kind,
|
|
290
|
+
row: Mapping[Any, Any],
|
|
291
|
+
) -> dict[str, LineageOut]:
|
|
292
|
+
"""Per Attribute api name, the provenance entry of its last change."""
|
|
293
|
+
lineage: Mapping[str, str] = row.get("lineage") or {}
|
|
294
|
+
entries = await ProvenanceRepository(
|
|
295
|
+
session, point.domain.id, writes=runtime.store_settings.writes
|
|
296
|
+
).get_many(lineage.values())
|
|
297
|
+
summary: dict[str, LineageOut] = {}
|
|
298
|
+
for attribute_id, provenance_id in lineage.items():
|
|
299
|
+
attribute = kind.attributes.get(attribute_id)
|
|
300
|
+
if attribute is None:
|
|
301
|
+
continue
|
|
302
|
+
entry = entries.get(provenance_id)
|
|
303
|
+
summary[attribute.api_name] = LineageOut(
|
|
304
|
+
provenance_id=provenance_id,
|
|
305
|
+
origin_type=None if entry is None else entry.origin_type,
|
|
306
|
+
actor=None if entry is None else entry.actor,
|
|
307
|
+
at=None if entry is None else entry.at,
|
|
308
|
+
)
|
|
309
|
+
return summary
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Lists and queries ------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def plan_query(query: Query, point: ReadPoint, runtime: ReadRuntime, prefix: str) -> QueryPlan:
|
|
316
|
+
"""The resolved query, once every Kind it reads is one the caller may read."""
|
|
317
|
+
if query.kind is not None:
|
|
318
|
+
point.scope.precheck([query.kind])
|
|
319
|
+
try:
|
|
320
|
+
plan = resolve_query(query, point.schema, runtime.read.query_limits())
|
|
321
|
+
except QueryError as error:
|
|
322
|
+
raise query_problem(error, prefix) from None
|
|
323
|
+
point.scope.require(plan_kinds(plan))
|
|
324
|
+
require_comparable_plan(point.schema, point.masking, plan, prefix)
|
|
325
|
+
return plan
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _key_ordered(plan: QueryPlan) -> bool:
|
|
329
|
+
order = plan.order
|
|
330
|
+
return not order or (
|
|
331
|
+
len(order) == 1 and order[0].field.name == KEY_FIELD and not order[0].descending
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def run_query(
|
|
336
|
+
session: AsyncSession,
|
|
337
|
+
runtime: ReadRuntime,
|
|
338
|
+
point: ReadPoint,
|
|
339
|
+
plan: QueryPlan,
|
|
340
|
+
*,
|
|
341
|
+
shape: str,
|
|
342
|
+
cursor_pointer: str,
|
|
343
|
+
) -> QueryPage:
|
|
344
|
+
"""A page of the Records matching a resolved query (plus includes and aggregate)."""
|
|
345
|
+
if not point.materialized:
|
|
346
|
+
if plan.where is None and not plan.include and not plan.aggregate and _key_ordered(plan):
|
|
347
|
+
return await _tree_list(session, runtime, point, plan, shape, cursor_pointer)
|
|
348
|
+
what = "Filtering, ordering, includes and aggregates"
|
|
349
|
+
point = await with_rows(session, runtime, point, what)
|
|
350
|
+
compiler = compiler_for(point, runtime)
|
|
351
|
+
r = compiler.records("r")
|
|
352
|
+
target = plan.target
|
|
353
|
+
keys = compiler.sort_keys(r, plan.order, target)
|
|
354
|
+
items: list[RecordOut] = []
|
|
355
|
+
next_cursor = None
|
|
356
|
+
if plan.limit > 0:
|
|
357
|
+
query: Select[Any] = select(
|
|
358
|
+
*record_columns(r), *(k.expression.label(f"s{i}") for i, k in enumerate(keys))
|
|
359
|
+
).where(compiler.record_scope(r, target))
|
|
360
|
+
if plan.where is not None:
|
|
361
|
+
query = query.where(compiler.condition(r, plan.where, target))
|
|
362
|
+
position = position_of(plan.cursor, shape, cursor_pointer)
|
|
363
|
+
if position is not None:
|
|
364
|
+
query = query.where(after(keys, _decode_position(keys, position, cursor_pointer)))
|
|
365
|
+
query = query.order_by(*order_clauses(keys)).limit(plan.limit + 1)
|
|
366
|
+
await explain(session, query, point.domain.id, "query")
|
|
367
|
+
rows = list((await session.execute(query)).mappings())
|
|
368
|
+
if len(rows) > plan.limit:
|
|
369
|
+
rows = rows[: plan.limit]
|
|
370
|
+
last = rows[-1]
|
|
371
|
+
next_cursor = issue(shape, [cursor_value(last[f"s{i}"]) for i in range(len(keys))])
|
|
372
|
+
items = [
|
|
373
|
+
record_out(point.schema, row, masking=point.masking, select=plan.select) for row in rows
|
|
374
|
+
]
|
|
375
|
+
for include in plan.include:
|
|
376
|
+
await _include(session, compiler, point, include, rows, items)
|
|
377
|
+
aggregate = None
|
|
378
|
+
if plan.aggregate is not None:
|
|
379
|
+
aggregate = await _aggregate(session, runtime, compiler, point, plan)
|
|
380
|
+
return QueryPage(items, next_cursor, aggregate)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _decode_position(keys: Sequence[SortKey], position: list[Any], pointer: str) -> list[Any]:
|
|
384
|
+
if len(position) != len(keys):
|
|
385
|
+
raise invalid_cursor(pointer)
|
|
386
|
+
try:
|
|
387
|
+
return [from_cursor_value(v, k.type) for k, v in zip(keys, position, strict=True)]
|
|
388
|
+
except ValueError:
|
|
389
|
+
raise invalid_cursor(pointer) from None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def _include(
|
|
393
|
+
session: AsyncSession,
|
|
394
|
+
compiler: Compiler,
|
|
395
|
+
point: ReadPoint,
|
|
396
|
+
include: IncludePlan,
|
|
397
|
+
rows: Sequence[Mapping[Any, Any]],
|
|
398
|
+
items: list[RecordOut],
|
|
399
|
+
) -> None:
|
|
400
|
+
hop = include.hop
|
|
401
|
+
near = [row["key"] for row in rows if row["kind_id"] == hop.near_kind]
|
|
402
|
+
found: dict[str, list[RecordOut]] = defaultdict(list)
|
|
403
|
+
if near:
|
|
404
|
+
query = compiler.neighbors(hop, near, where=include.where, per_near=include.limit)
|
|
405
|
+
for row in (await session.execute(query)).mappings():
|
|
406
|
+
found[row["near_key"]].append(
|
|
407
|
+
record_out(point.schema, row, masking=point.masking, select=include.select)
|
|
408
|
+
)
|
|
409
|
+
for row, item in zip(rows, items, strict=True):
|
|
410
|
+
if row["kind_id"] != hop.near_kind:
|
|
411
|
+
continue
|
|
412
|
+
item.included = item.included or {}
|
|
413
|
+
item.included[hop.name] = found.get(row["key"], [])
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
async def _aggregate(
|
|
417
|
+
session: AsyncSession,
|
|
418
|
+
runtime: ReadRuntime,
|
|
419
|
+
compiler: Compiler,
|
|
420
|
+
point: ReadPoint,
|
|
421
|
+
plan: QueryPlan,
|
|
422
|
+
) -> AggregateOut:
|
|
423
|
+
r = compiler.records("r")
|
|
424
|
+
target = plan.target
|
|
425
|
+
assert plan.aggregate is not None
|
|
426
|
+
group = plan.aggregate.group_by
|
|
427
|
+
columns: list[Any] = [r.c.kind_id]
|
|
428
|
+
if group is not None:
|
|
429
|
+
columns.append(compiler.field_json(r, group).label("value"))
|
|
430
|
+
query = select(*columns, func.count().label("n")).where(compiler.record_scope(r, target))
|
|
431
|
+
if plan.where is not None:
|
|
432
|
+
query = query.where(compiler.condition(r, plan.where, target))
|
|
433
|
+
cap = runtime.read.query.max_aggregate_groups
|
|
434
|
+
# Grouped by output position: the group expression binds its Attribute id as a parameter,
|
|
435
|
+
# so repeating it in GROUP BY would not match the selected column.
|
|
436
|
+
groups: list[Any] = [literal_column(str(i + 1)) for i in range(len(columns))]
|
|
437
|
+
query = query.group_by(*groups).order_by(*groups).limit(cap + 1)
|
|
438
|
+
rows = list((await session.execute(query)).mappings())
|
|
439
|
+
return AggregateOut(
|
|
440
|
+
counts=[
|
|
441
|
+
AggregateCount(
|
|
442
|
+
kind=kind_name(point.schema, row["kind_id"]),
|
|
443
|
+
value=row["value"] if group is not None else None,
|
|
444
|
+
count=int(row["n"]),
|
|
445
|
+
)
|
|
446
|
+
for row in rows[:cap]
|
|
447
|
+
],
|
|
448
|
+
truncated=len(rows) > cap,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
async def _tree_list(
|
|
453
|
+
session: AsyncSession,
|
|
454
|
+
runtime: ReadRuntime,
|
|
455
|
+
point: ReadPoint,
|
|
456
|
+
plan: QueryPlan,
|
|
457
|
+
shape: str,
|
|
458
|
+
cursor_pointer: str,
|
|
459
|
+
) -> QueryPage:
|
|
460
|
+
"""A page of the target's Records at a Revision, in the order of the head's keyset
|
|
461
|
+
(`Compiler.sort_keys`): by key for one Kind; for a Trait by Kind id and key, or by key
|
|
462
|
+
and Kind id when ordered by key. Cursors carry the same positions as at the head."""
|
|
463
|
+
kinds = plan.target.kinds
|
|
464
|
+
by_key = len(kinds) == 1 or bool(plan.order)
|
|
465
|
+
reader = TreeReader(session, runtime, point.domain.id, point.revision.root)
|
|
466
|
+
|
|
467
|
+
def position(kind: Kind, key: str) -> list[str]:
|
|
468
|
+
if len(kinds) == 1:
|
|
469
|
+
return [key]
|
|
470
|
+
return [key, kind.id] if by_key else [kind.id, key]
|
|
471
|
+
|
|
472
|
+
entries = [
|
|
473
|
+
(position(kind, entry.key), kind, entry)
|
|
474
|
+
for kind in kinds
|
|
475
|
+
for entry in await reader.entries(kind_partition(kind.id))
|
|
476
|
+
]
|
|
477
|
+
entries.sort(key=lambda item: item[0])
|
|
478
|
+
after_position = position_of(plan.cursor, shape, cursor_pointer)
|
|
479
|
+
if after_position is not None:
|
|
480
|
+
width = 1 if len(kinds) == 1 else 2
|
|
481
|
+
if len(after_position) != width or not all(isinstance(p, str) for p in after_position):
|
|
482
|
+
raise invalid_cursor(cursor_pointer)
|
|
483
|
+
entries = [item for item in entries if item[0] > after_position]
|
|
484
|
+
page = entries[: plan.limit]
|
|
485
|
+
bodies = await reader.bodies(entry.object_hash for _, _, entry in page)
|
|
486
|
+
items = [
|
|
487
|
+
record_out(
|
|
488
|
+
point.schema,
|
|
489
|
+
tree_record_row(kind, entry.key, bodies[entry.object_hash]),
|
|
490
|
+
masking=point.masking,
|
|
491
|
+
select=plan.select,
|
|
492
|
+
)
|
|
493
|
+
for _, kind, entry in page
|
|
494
|
+
]
|
|
495
|
+
more = len(entries) > plan.limit and plan.limit > 0
|
|
496
|
+
return QueryPage(items, issue(shape, page[-1][0]) if more else None)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# Links ----------------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def require_hop(point: ReadPoint, kind: Kind, name: str) -> Hop:
|
|
503
|
+
target = target_of_kind(point.schema, kind.api_name)
|
|
504
|
+
assert target is not None
|
|
505
|
+
hop = resolve_hop(point.schema, target, name)
|
|
506
|
+
if isinstance(hop, str):
|
|
507
|
+
raise not_found(f"Relation {name} of Kind {kind.api_name}")
|
|
508
|
+
point.scope.require([kind.api_name, *sorted(hop.kinds_read)])
|
|
509
|
+
return hop
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _link_out(
|
|
513
|
+
point: ReadPoint, hop: Hop, near: str, far: str, values: Any, title: str | None = None
|
|
514
|
+
) -> LinkOut:
|
|
515
|
+
forward = hop.direction is LinkDirection.FORWARD
|
|
516
|
+
return LinkOut(
|
|
517
|
+
relation=relation_name(hop.relation, hop.direction),
|
|
518
|
+
direction=hop.direction,
|
|
519
|
+
from_key=near if forward else far,
|
|
520
|
+
to_key=far if forward else near,
|
|
521
|
+
values=link_values(point.schema, hop.relation, values, point.masking),
|
|
522
|
+
target=record_ref(point.schema, hop.far.kind_ids[0], far, title, point.masking),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
async def record_links(
|
|
527
|
+
session: AsyncSession,
|
|
528
|
+
runtime: ReadRuntime,
|
|
529
|
+
point: ReadPoint,
|
|
530
|
+
kind: Kind,
|
|
531
|
+
key: str,
|
|
532
|
+
relation: str,
|
|
533
|
+
*,
|
|
534
|
+
limit: int,
|
|
535
|
+
cursor: str | None,
|
|
536
|
+
shape: str,
|
|
537
|
+
) -> LinkPageResult:
|
|
538
|
+
"""Links of one Record along a Relation (forward or inverse name), by far key."""
|
|
539
|
+
hop = require_hop(point, kind, relation)
|
|
540
|
+
await fetch_record(session, runtime, point, kind, key)
|
|
541
|
+
position = position_of(cursor, shape, "/query/cursor")
|
|
542
|
+
if position is not None and (len(position) != 1 or not isinstance(position[0], str)):
|
|
543
|
+
raise invalid_cursor("/query/cursor")
|
|
544
|
+
after_key = None if position is None else position[0]
|
|
545
|
+
if not point.materialized:
|
|
546
|
+
pairs = await _tree_links(session, runtime, point, hop, key)
|
|
547
|
+
pairs = [p for p in pairs if after_key is None or p[0] > after_key]
|
|
548
|
+
page = pairs[:limit]
|
|
549
|
+
items = [_link_out(point, hop, key, far, values) for far, values in page]
|
|
550
|
+
more = len(pairs) > limit
|
|
551
|
+
return LinkPageResult(items, issue(shape, [page[-1][0]]) if more and page else None)
|
|
552
|
+
composed = hop.path is not None and not hop.path.materialized
|
|
553
|
+
if hop.direction is LinkDirection.INVERSE and not composed:
|
|
554
|
+
return await _inbound_links(session, runtime, point, hop, key, limit, after_key, shape)
|
|
555
|
+
compiler = compiler_for(point, runtime)
|
|
556
|
+
source = compiler.link_source(hop)
|
|
557
|
+
far = point.layout.records("far")
|
|
558
|
+
query = (
|
|
559
|
+
select(source.far.label("far_key"), source.values.label("link_values"), far.c.title)
|
|
560
|
+
.select_from(source.rows)
|
|
561
|
+
.outerjoin(
|
|
562
|
+
far,
|
|
563
|
+
and_(
|
|
564
|
+
far.c.domain_id == literal(point.domain.id),
|
|
565
|
+
far.c.branch == literal(point.layout.branch),
|
|
566
|
+
far.c.kind_id == literal(hop.far.kind_ids[0]),
|
|
567
|
+
far.c.key == source.far,
|
|
568
|
+
),
|
|
569
|
+
)
|
|
570
|
+
.where(source.scope, source.near == literal(key))
|
|
571
|
+
.order_by(source.far)
|
|
572
|
+
.limit(limit + 1)
|
|
573
|
+
)
|
|
574
|
+
if after_key is not None:
|
|
575
|
+
query = query.where(source.far > literal(after_key))
|
|
576
|
+
rows = list((await session.execute(query)).mappings())
|
|
577
|
+
more = len(rows) > limit
|
|
578
|
+
rows = rows[:limit]
|
|
579
|
+
items = [
|
|
580
|
+
_link_out(point, hop, key, row["far_key"], row["link_values"], row["title"]) for row in rows
|
|
581
|
+
]
|
|
582
|
+
return LinkPageResult(items, issue(shape, [rows[-1]["far_key"]]) if more else None)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
async def _inbound_links(
|
|
586
|
+
session: AsyncSession,
|
|
587
|
+
runtime: ReadRuntime,
|
|
588
|
+
point: ReadPoint,
|
|
589
|
+
hop: Hop,
|
|
590
|
+
key: str,
|
|
591
|
+
limit: int,
|
|
592
|
+
after_key: str | None,
|
|
593
|
+
shape: str,
|
|
594
|
+
) -> LinkPageResult:
|
|
595
|
+
"""Links pointing at the Record along an inverse name, through the store primitive
|
|
596
|
+
(`links_to_record`: the reverse Link index, or the from Kind's per-Attribute index for
|
|
597
|
+
foreign-key Relations, with index plans under row-level security)."""
|
|
598
|
+
domain_id, branch, store = point.domain.id, point.layout, runtime.store
|
|
599
|
+
links = await links_to_record(
|
|
600
|
+
session, domain_id, branch, hop.relation, key, limit=limit, after=after_key, runtime=store
|
|
601
|
+
)
|
|
602
|
+
more = len(links) == limit and bool(
|
|
603
|
+
await links_to_record(
|
|
604
|
+
session,
|
|
605
|
+
domain_id,
|
|
606
|
+
branch,
|
|
607
|
+
hop.relation,
|
|
608
|
+
key,
|
|
609
|
+
limit=1,
|
|
610
|
+
after=links[-1].from_key,
|
|
611
|
+
runtime=store,
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
far_kind = hop.far.kind_ids[0]
|
|
615
|
+
found = await hydrate_records(
|
|
616
|
+
session, domain_id, branch, [(far_kind, link.from_key) for link in links], runtime=store
|
|
617
|
+
)
|
|
618
|
+
items = []
|
|
619
|
+
for link in links:
|
|
620
|
+
record = found.get((far_kind, link.from_key))
|
|
621
|
+
title = None if record is None else record.title
|
|
622
|
+
items.append(_link_out(point, hop, key, link.from_key, link.values, title))
|
|
623
|
+
return LinkPageResult(items, issue(shape, [links[-1].from_key]) if more else None)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
async def _tree_links(
|
|
627
|
+
session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, hop: Hop, key: str
|
|
628
|
+
) -> list[tuple[str, Mapping[str, Any]]]:
|
|
629
|
+
"""`(far_key, link values)` of a Record's Links at a Revision, sorted by far key."""
|
|
630
|
+
reader = TreeReader(session, runtime, point.domain.id, point.revision.root)
|
|
631
|
+
if hop.path is not None:
|
|
632
|
+
pairs = await _tree_path_pairs(reader, runtime, hop.path)
|
|
633
|
+
forward = hop.direction is LinkDirection.FORWARD
|
|
634
|
+
return sorted((t if forward else f, {}) for f, t in pairs if (f if forward else t) == key)
|
|
635
|
+
relation = hop.relation
|
|
636
|
+
forward = hop.direction is LinkDirection.FORWARD
|
|
637
|
+
if hop.foreign_key:
|
|
638
|
+
attribute = relation.foreign_key_attribute
|
|
639
|
+
assert attribute is not None
|
|
640
|
+
if forward:
|
|
641
|
+
digest = await reader.get(kind_partition(relation.from_kind), key)
|
|
642
|
+
body = {} if digest is None else (await reader.bodies([digest]))[digest]
|
|
643
|
+
target = body.get("values", {}).get(attribute)
|
|
644
|
+
return [] if target is None else [(str(target), {})]
|
|
645
|
+
entries = await reader.entries(kind_partition(relation.from_kind))
|
|
646
|
+
bodies = await reader.bodies(e.object_hash for e in entries)
|
|
647
|
+
return [
|
|
648
|
+
(e.key, {})
|
|
649
|
+
for e in entries
|
|
650
|
+
if bodies[e.object_hash].get("values", {}).get(attribute) == key
|
|
651
|
+
]
|
|
652
|
+
entries = await reader.entries(relation_partition(relation.id))
|
|
653
|
+
matched: list[tuple[str, bytes]] = []
|
|
654
|
+
for entry in entries:
|
|
655
|
+
from_key, to_key = split_link_key(entry.key)
|
|
656
|
+
near, far = (from_key, to_key) if forward else (to_key, from_key)
|
|
657
|
+
if near == key:
|
|
658
|
+
matched.append((far, entry.object_hash))
|
|
659
|
+
bodies = await reader.bodies(digest for _, digest in matched)
|
|
660
|
+
return sorted((far, bodies[d].get("values", {})) for far, d in matched)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
async def relation_links(
|
|
664
|
+
session: AsyncSession,
|
|
665
|
+
runtime: ReadRuntime,
|
|
666
|
+
point: ReadPoint,
|
|
667
|
+
relation_name_: str,
|
|
668
|
+
*,
|
|
669
|
+
from_key: str | None,
|
|
670
|
+
to_key: str | None,
|
|
671
|
+
limit: int,
|
|
672
|
+
cursor: str | None,
|
|
673
|
+
shape: str,
|
|
674
|
+
) -> LinkPageResult:
|
|
675
|
+
"""Links of a Relation ordered by `(from_key, to_key)`, optionally from or to one key."""
|
|
676
|
+
found = point.schema.relation_by_api_name(relation_name_)
|
|
677
|
+
along = point.schema.path_by_api_name(relation_name_)
|
|
678
|
+
if found is None and along is not None:
|
|
679
|
+
found = (along[0].relation, along[1])
|
|
680
|
+
if found is None:
|
|
681
|
+
raise not_found(f"Relation {relation_name_}")
|
|
682
|
+
relation, _ = found
|
|
683
|
+
from_kind = point.schema.kinds.get(relation.from_kind)
|
|
684
|
+
if from_kind is None:
|
|
685
|
+
raise not_found(f"Relation {relation_name_}")
|
|
686
|
+
hop = require_hop(point, from_kind, relation.api_name)
|
|
687
|
+
position = position_of(cursor, shape, "/query/cursor")
|
|
688
|
+
if position is not None and (
|
|
689
|
+
len(position) != 2 or not all(isinstance(p, str) for p in position)
|
|
690
|
+
):
|
|
691
|
+
raise invalid_cursor("/query/cursor")
|
|
692
|
+
if not point.materialized:
|
|
693
|
+
pairs = await _tree_relation_links(session, runtime, point, hop)
|
|
694
|
+
pairs = [
|
|
695
|
+
p
|
|
696
|
+
for p in pairs
|
|
697
|
+
if (from_key is None or p[0] == from_key)
|
|
698
|
+
and (to_key is None or p[1] == to_key)
|
|
699
|
+
and (position is None or (p[0], p[1]) > (position[0], position[1]))
|
|
700
|
+
]
|
|
701
|
+
page = pairs[:limit]
|
|
702
|
+
items = [_link_out(point, hop, f, t, v) for f, t, v in page]
|
|
703
|
+
more = len(pairs) > limit
|
|
704
|
+
return LinkPageResult(items, issue(shape, list(page[-1][:2])) if more and page else None)
|
|
705
|
+
compiler = compiler_for(point, runtime)
|
|
706
|
+
source = compiler.link_source(hop)
|
|
707
|
+
query = (
|
|
708
|
+
select(
|
|
709
|
+
source.near.label("from_key"),
|
|
710
|
+
source.far.label("to_key"),
|
|
711
|
+
source.values.label("link_values"),
|
|
712
|
+
)
|
|
713
|
+
.select_from(source.rows)
|
|
714
|
+
.where(source.scope)
|
|
715
|
+
.order_by(source.near, source.far)
|
|
716
|
+
.limit(limit + 1)
|
|
717
|
+
)
|
|
718
|
+
if from_key is not None:
|
|
719
|
+
query = query.where(source.near == literal(from_key))
|
|
720
|
+
if to_key is not None:
|
|
721
|
+
query = query.where(source.far == literal(to_key))
|
|
722
|
+
if position is not None:
|
|
723
|
+
p_from, p_to = literal(position[0]), literal(position[1])
|
|
724
|
+
query = query.where((source.near > p_from) | and_(source.near == p_from, source.far > p_to))
|
|
725
|
+
rows = list((await session.execute(query)).mappings())
|
|
726
|
+
more = len(rows) > limit
|
|
727
|
+
rows = rows[:limit]
|
|
728
|
+
items = [_link_out(point, hop, r["from_key"], r["to_key"], r["link_values"]) for r in rows]
|
|
729
|
+
next_cursor = issue(shape, [rows[-1]["from_key"], rows[-1]["to_key"]]) if more else None
|
|
730
|
+
return LinkPageResult(items, next_cursor)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
async def _tree_relation_links(
|
|
734
|
+
session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, hop: Hop
|
|
735
|
+
) -> list[tuple[str, str, Mapping[str, Any]]]:
|
|
736
|
+
reader = TreeReader(session, runtime, point.domain.id, point.revision.root)
|
|
737
|
+
if hop.path is not None:
|
|
738
|
+
return [(f, t, {}) for f, t in sorted(await _tree_path_pairs(reader, runtime, hop.path))]
|
|
739
|
+
return await _tree_pairs(reader, hop.relation)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
async def _tree_pairs(
|
|
743
|
+
reader: TreeReader, relation: Relation
|
|
744
|
+
) -> list[tuple[str, str, Mapping[str, Any]]]:
|
|
745
|
+
"""Every Link of a Relation at a Revision as `(from_key, to_key, values)`, sorted."""
|
|
746
|
+
if relation.backing is RelationBacking.FOREIGN_KEY:
|
|
747
|
+
attribute = relation.foreign_key_attribute
|
|
748
|
+
assert attribute is not None
|
|
749
|
+
entries = await reader.entries(kind_partition(relation.from_kind))
|
|
750
|
+
bodies = await reader.bodies(e.object_hash for e in entries)
|
|
751
|
+
pairs: list[tuple[str, str, Mapping[str, Any]]] = []
|
|
752
|
+
for e in entries:
|
|
753
|
+
target = bodies[e.object_hash].get("values", {}).get(attribute)
|
|
754
|
+
if target is not None:
|
|
755
|
+
pairs.append((e.key, str(target), {}))
|
|
756
|
+
return sorted(pairs, key=lambda p: (p[0], p[1]))
|
|
757
|
+
entries = await reader.entries(relation_partition(relation.id))
|
|
758
|
+
bodies = await reader.bodies(e.object_hash for e in entries)
|
|
759
|
+
out = [(*split_link_key(e.key), bodies[e.object_hash].get("values", {})) for e in entries]
|
|
760
|
+
return sorted(out, key=lambda p: (p[0], p[1]))
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
async def _tree_path_pairs(
|
|
764
|
+
reader: TreeReader, runtime: ReadRuntime, path: PathRelation
|
|
765
|
+
) -> set[tuple[str, str]]:
|
|
766
|
+
"""Every Link of a path Derivation at a Revision, composed from the Links of its steps
|
|
767
|
+
there (`lorebase_core.model.paths`; materialized or not, the tree holds no path Links)."""
|
|
768
|
+
maps: list[dict[str, list[str]]] = []
|
|
769
|
+
for step in path.steps:
|
|
770
|
+
forward = step.direction is LinkDirection.FORWARD
|
|
771
|
+
by_near: dict[str, list[str]] = defaultdict(list)
|
|
772
|
+
for from_key, to_key, _ in await _tree_pairs(reader, step.relation):
|
|
773
|
+
near, far = (from_key, to_key) if forward else (to_key, from_key)
|
|
774
|
+
by_near[near].append(far)
|
|
775
|
+
maps.append(by_near)
|
|
776
|
+
steps = [
|
|
777
|
+
(lambda keys, found=found: {k: found[k] for k in keys if k in found}) for found in maps
|
|
778
|
+
]
|
|
779
|
+
reached = walk(sorted(maps[0]), steps, runtime.store_settings.traverse.path_fanout)
|
|
780
|
+
return {(start, far) for start, fars in reached.items() for far in fars}
|