lorebase-store 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_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- lorebase_store-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Outbox events written in commit transactions (design 6.6 step 8, 10.5) and read by the
|
|
2
|
+
change feed and the consumers of the outbox.
|
|
3
|
+
|
|
4
|
+
The audit entry of the same step goes through `lorebase_store.auth.AuditRepository`, which owns
|
|
5
|
+
the per-Domain hash chain. Outbox ids increase with every event, so they serve as the sequence
|
|
6
|
+
numbers of the change feed (`Last-Event-ID`).
|
|
7
|
+
|
|
8
|
+
Each consumer (`outbox_consumers`) owns its delivery state: the events it has not handed off
|
|
9
|
+
yet are its `outbox_pending` rows, added by a trigger in the transaction writing the event, so
|
|
10
|
+
an event committed late (its transaction began before others that committed first) is never
|
|
11
|
+
skipped the way a position in the id sequence could skip it. A consumer claims its pending
|
|
12
|
+
rows (`claim`, skipping rows another worker of the same consumer holds) and deletes them with
|
|
13
|
+
`acknowledge` in the transaction that hands the events off; a failure before that leaves them
|
|
14
|
+
pending (at-least-once). Several consumers never take events from one another.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from sqlalchemy import Table, and_, delete, exists, func, insert, or_, select
|
|
25
|
+
from sqlalchemy.dialects.postgresql import insert as upsert
|
|
26
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
27
|
+
|
|
28
|
+
from lorebase_store.models import metadata
|
|
29
|
+
|
|
30
|
+
OUTBOX: Table = metadata.tables["outbox"]
|
|
31
|
+
OUTBOX_CONSUMERS: Table = metadata.tables["outbox_consumers"]
|
|
32
|
+
OUTBOX_PENDING: Table = metadata.tables["outbox_pending"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class OutboxRow:
|
|
37
|
+
id: int
|
|
38
|
+
domain_id: str
|
|
39
|
+
event_type: str
|
|
40
|
+
payload: dict[str, Any]
|
|
41
|
+
created_at: datetime
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _row(row: Any) -> OutboxRow:
|
|
45
|
+
return OutboxRow(row.id, row.domain_id, row.event_type, row.payload, row.created_at)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_COLUMNS = (
|
|
49
|
+
OUTBOX.c.id,
|
|
50
|
+
OUTBOX.c.domain_id,
|
|
51
|
+
OUTBOX.c.event_type,
|
|
52
|
+
OUTBOX.c.payload,
|
|
53
|
+
OUTBOX.c.created_at,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class OutboxRepository:
|
|
58
|
+
"""Events delivered after commit by a background worker."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
61
|
+
self._session = session
|
|
62
|
+
|
|
63
|
+
async def enqueue(self, domain_id: str, event_type: str, payload: dict[str, Any]) -> int:
|
|
64
|
+
result = await self._session.execute(
|
|
65
|
+
insert(OUTBOX)
|
|
66
|
+
.values(domain_id=domain_id, event_type=event_type, payload=payload)
|
|
67
|
+
.returning(OUTBOX.c.id)
|
|
68
|
+
)
|
|
69
|
+
return int(result.scalar_one())
|
|
70
|
+
|
|
71
|
+
async def latest(self, domain_id: str) -> int:
|
|
72
|
+
"""Id of the newest event of the Domain (0 when there is none)."""
|
|
73
|
+
found = await self._session.scalar(
|
|
74
|
+
select(func.max(OUTBOX.c.id)).where(OUTBOX.c.domain_id == domain_id)
|
|
75
|
+
)
|
|
76
|
+
return int(found or 0)
|
|
77
|
+
|
|
78
|
+
async def after(
|
|
79
|
+
self,
|
|
80
|
+
domain_id: str,
|
|
81
|
+
after: int,
|
|
82
|
+
*,
|
|
83
|
+
event_types: Sequence[str],
|
|
84
|
+
branch: str | None,
|
|
85
|
+
limit: int,
|
|
86
|
+
) -> list[OutboxRow]:
|
|
87
|
+
"""Events of the Domain newer than `after`, oldest first; `branch` filters on the
|
|
88
|
+
`branch` member of the payload."""
|
|
89
|
+
query = (
|
|
90
|
+
select(*_COLUMNS)
|
|
91
|
+
.where(
|
|
92
|
+
OUTBOX.c.domain_id == domain_id,
|
|
93
|
+
OUTBOX.c.id > after,
|
|
94
|
+
OUTBOX.c.event_type.in_(list(event_types)),
|
|
95
|
+
)
|
|
96
|
+
.order_by(OUTBOX.c.id)
|
|
97
|
+
.limit(limit)
|
|
98
|
+
)
|
|
99
|
+
if branch is not None:
|
|
100
|
+
query = query.where(OUTBOX.c.payload["branch"].astext == branch)
|
|
101
|
+
return [_row(row) for row in await self._session.execute(query)]
|
|
102
|
+
|
|
103
|
+
async def claim(self, consumer: str, limit: int) -> list[OutboxRow]:
|
|
104
|
+
"""The oldest events pending for `consumer`, in every Domain visible to the
|
|
105
|
+
transaction; their pending rows stay locked until it ends (other workers of the
|
|
106
|
+
consumer skip them)."""
|
|
107
|
+
p = OUTBOX_PENDING
|
|
108
|
+
claimed = (
|
|
109
|
+
select(p.c.event_id)
|
|
110
|
+
.where(p.c.consumer == consumer)
|
|
111
|
+
.order_by(p.c.event_id)
|
|
112
|
+
.limit(limit)
|
|
113
|
+
.with_for_update(skip_locked=True)
|
|
114
|
+
.cte("claimed")
|
|
115
|
+
)
|
|
116
|
+
query = (
|
|
117
|
+
select(*_COLUMNS).join(claimed, claimed.c.event_id == OUTBOX.c.id).order_by(OUTBOX.c.id)
|
|
118
|
+
)
|
|
119
|
+
return [_row(row) for row in await self._session.execute(query)]
|
|
120
|
+
|
|
121
|
+
async def acknowledge(self, consumer: str, ids: Sequence[int]) -> None:
|
|
122
|
+
"""`consumer` handed these events off: they are no longer pending for it."""
|
|
123
|
+
if ids:
|
|
124
|
+
await self._session.execute(
|
|
125
|
+
delete(OUTBOX_PENDING).where(
|
|
126
|
+
OUTBOX_PENDING.c.consumer == consumer,
|
|
127
|
+
OUTBOX_PENDING.c.event_id.in_(list(ids)),
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def pending(self) -> dict[str, int]:
|
|
132
|
+
"""Events each registered consumer has not handed off yet (its backlog)."""
|
|
133
|
+
p, c = OUTBOX_PENDING, OUTBOX_CONSUMERS
|
|
134
|
+
result = await self._session.execute(
|
|
135
|
+
select(c.c.name, func.count(p.c.event_id))
|
|
136
|
+
.select_from(c.outerjoin(p, p.c.consumer == c.c.name))
|
|
137
|
+
.group_by(c.c.name)
|
|
138
|
+
)
|
|
139
|
+
return {name: int(count) for name, count in result}
|
|
140
|
+
|
|
141
|
+
async def prune(
|
|
142
|
+
self, *, before: datetime, expire_before: datetime, limit: int
|
|
143
|
+
) -> tuple[int, int]:
|
|
144
|
+
"""Delete up to `limit` events created before `expire_before` whatever is pending, then
|
|
145
|
+
up to `limit` created before `before` that no consumer has pending, oldest first; the
|
|
146
|
+
numbers deleted (handed off, expired). Bounded, so a large backlog is deleted over
|
|
147
|
+
several calls instead of in one long transaction."""
|
|
148
|
+
expired_ids = (
|
|
149
|
+
select(OUTBOX.c.id)
|
|
150
|
+
.where(OUTBOX.c.created_at < expire_before)
|
|
151
|
+
.order_by(OUTBOX.c.id)
|
|
152
|
+
.limit(limit)
|
|
153
|
+
)
|
|
154
|
+
expired = await self._session.execute(
|
|
155
|
+
delete(OUTBOX).where(OUTBOX.c.id.in_(expired_ids)).returning(OUTBOX.c.id)
|
|
156
|
+
)
|
|
157
|
+
handed_ids = (
|
|
158
|
+
select(OUTBOX.c.id)
|
|
159
|
+
.where(
|
|
160
|
+
OUTBOX.c.created_at < before,
|
|
161
|
+
~exists().where(OUTBOX_PENDING.c.event_id == OUTBOX.c.id),
|
|
162
|
+
)
|
|
163
|
+
.order_by(OUTBOX.c.id)
|
|
164
|
+
.limit(limit)
|
|
165
|
+
)
|
|
166
|
+
handed = await self._session.execute(
|
|
167
|
+
delete(OUTBOX).where(OUTBOX.c.id.in_(handed_ids)).returning(OUTBOX.c.id)
|
|
168
|
+
)
|
|
169
|
+
return len(handed.all()), len(expired.all())
|
|
170
|
+
|
|
171
|
+
async def prunable(self, *, before: datetime, expire_before: datetime, cap: int) -> int:
|
|
172
|
+
"""Events `prune` would delete now, counted up to `cap`. Pending events between the
|
|
173
|
+
two cutoffs are scanned to find them, so callers count only after a pruning pass that
|
|
174
|
+
ran out of time with work left."""
|
|
175
|
+
eligible = (
|
|
176
|
+
select(OUTBOX.c.id)
|
|
177
|
+
.where(
|
|
178
|
+
or_(
|
|
179
|
+
OUTBOX.c.created_at < expire_before,
|
|
180
|
+
and_(
|
|
181
|
+
OUTBOX.c.created_at < before,
|
|
182
|
+
~exists().where(OUTBOX_PENDING.c.event_id == OUTBOX.c.id),
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
.limit(cap)
|
|
187
|
+
.subquery()
|
|
188
|
+
)
|
|
189
|
+
result = await self._session.execute(select(func.count()).select_from(eligible))
|
|
190
|
+
return int(result.scalar_one())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def register_consumer(session: AsyncSession, name: str) -> None:
|
|
194
|
+
"""Make `name` a consumer of the outbox: events written from now on are pending for it."""
|
|
195
|
+
await session.execute(upsert(OUTBOX_CONSUMERS).values(name=name).on_conflict_do_nothing())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def drop_consumer(session: AsyncSession, name: str) -> bool:
|
|
199
|
+
"""Stop keeping events for `name` (its pending rows go too); false when unknown."""
|
|
200
|
+
result = await session.execute(
|
|
201
|
+
delete(OUTBOX_CONSUMERS)
|
|
202
|
+
.where(OUTBOX_CONSUMERS.c.name == name)
|
|
203
|
+
.returning(OUTBOX_CONSUMERS.c.name)
|
|
204
|
+
)
|
|
205
|
+
return result.first() is not None
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""Core `IndexView` backed by the materialized head of a branch.
|
|
2
|
+
|
|
3
|
+
Validation (core `evaluate_changeset`) asks one question at a time. To keep a batch of
|
|
4
|
+
thousands of changes at a handful of queries, `prefetch_for` loads in bulk what the batch will
|
|
5
|
+
ask (existence and state of touched Records and Links, unique values, Link counts and Links of
|
|
6
|
+
deleted Records); the view then answers from memory and falls back to one query per miss.
|
|
7
|
+
|
|
8
|
+
Like `PgNodeStore`, the view runs over a synchronous `Session` inside `AsyncSession.run_sync`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
18
|
+
|
|
19
|
+
from pydantic import JsonValue
|
|
20
|
+
from sqlalchemy import ColumnElement, FromClause, TextClause, and_, func, select, text, tuple_
|
|
21
|
+
from sqlalchemy.orm import Session
|
|
22
|
+
|
|
23
|
+
from lorebase_core.model import Link, Record, RecordIdentity, RelationBacking, Schema
|
|
24
|
+
from lorebase_core.model.record import LinkIdentity
|
|
25
|
+
from lorebase_core.types import AttributeType, CanonicalValue
|
|
26
|
+
from lorebase_core.validate import DataChange, LinkUpsert, RecordDelete, RecordUpsert
|
|
27
|
+
from lorebase_store.bulk import chunks, pairs_in
|
|
28
|
+
from lorebase_store.layout import BranchLayout, lineage_column, load_layout_sync
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from lorebase_store.indexes import IndexSpec
|
|
32
|
+
|
|
33
|
+
Side = Literal["from", "to"]
|
|
34
|
+
|
|
35
|
+
_UNIQUE_VALUES = """
|
|
36
|
+
SELECT key, "values" -> :attribute AS value
|
|
37
|
+
FROM {records}
|
|
38
|
+
WHERE domain_id = :domain AND branch = :branch AND kind_id = :kind
|
|
39
|
+
AND "values" -> :attribute IN (SELECT jsonb_array_elements(CAST(:candidates AS jsonb)))
|
|
40
|
+
"""
|
|
41
|
+
#: `_UNIQUE_VALUES` through the Attribute's unique index: its predicate as literals and its
|
|
42
|
+
#: expression compared with the candidates' canonical text (`IndexSpec.value_sql`).
|
|
43
|
+
_UNIQUE_INDEXED = """
|
|
44
|
+
SELECT key, "values" -> :attribute AS value
|
|
45
|
+
FROM {records}
|
|
46
|
+
WHERE {predicate}
|
|
47
|
+
AND {expression} IN (
|
|
48
|
+
SELECT {value} FROM jsonb_array_elements_text(CAST(:candidates AS jsonb)) AS c(v))
|
|
49
|
+
"""
|
|
50
|
+
_REFERENCING = """
|
|
51
|
+
SELECT key, "values" -> :attribute AS value
|
|
52
|
+
FROM {records}
|
|
53
|
+
WHERE domain_id = :domain AND branch = :branch AND kind_id = :kind
|
|
54
|
+
AND jsonb_exists_any("values" -> :attribute, CAST(:keys AS text[]))
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class RecordState:
|
|
60
|
+
"""A Record as materialized before the batch."""
|
|
61
|
+
|
|
62
|
+
values: dict[str, Any]
|
|
63
|
+
lineage: dict[str, str]
|
|
64
|
+
object_hash: bytes
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class LinkState:
|
|
69
|
+
"""A Link as materialized before the batch."""
|
|
70
|
+
|
|
71
|
+
values: dict[str, Any]
|
|
72
|
+
lineage: dict[str, str]
|
|
73
|
+
object_hash: bytes
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class IndexStats:
|
|
78
|
+
queries: int = 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def value_key(value: JsonValue) -> str:
|
|
82
|
+
"""Memo key of a canonical value."""
|
|
83
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _keys_in(value: JsonValue) -> list[str]:
|
|
87
|
+
if isinstance(value, str):
|
|
88
|
+
return [value]
|
|
89
|
+
if isinstance(value, list):
|
|
90
|
+
return [item for item in value if isinstance(item, str)]
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MaterializedIndexView:
|
|
95
|
+
"""`IndexView` over `mat_records` and `mat_links` of one branch, with bulk prefetch.
|
|
96
|
+
|
|
97
|
+
An overlay branch is read through its resolved rows (`lorebase_store.layout`).
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
session: Session,
|
|
103
|
+
domain_id: str,
|
|
104
|
+
branch: str,
|
|
105
|
+
*,
|
|
106
|
+
batch: int,
|
|
107
|
+
layout: BranchLayout | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._session = session
|
|
110
|
+
self._domain = domain_id
|
|
111
|
+
self._branch = branch
|
|
112
|
+
self._batch = batch
|
|
113
|
+
self._layout = layout
|
|
114
|
+
self._records: dict[tuple[str, str], RecordState | None] = {}
|
|
115
|
+
self._links: dict[tuple[str, str, str], LinkState | None] = {}
|
|
116
|
+
self._unique: dict[tuple[str, str, str], str | None] = {}
|
|
117
|
+
self._counts: dict[tuple[str, Side, str], int] = {}
|
|
118
|
+
self._pairs: dict[tuple[str, Side, str], list[tuple[str, str]]] = {}
|
|
119
|
+
self._referencing: dict[tuple[str, str, str], set[str]] = {}
|
|
120
|
+
self._unique_indexes: dict[tuple[str, str], IndexSpec] = {}
|
|
121
|
+
self.stats = IndexStats()
|
|
122
|
+
|
|
123
|
+
# IndexView protocol ---------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def record_exists(self, kind_id: str, key: str) -> bool:
|
|
126
|
+
return self.record_state(kind_id, key) is not None
|
|
127
|
+
|
|
128
|
+
def find_unique(self, kind_id: str, attribute_id: str, value: CanonicalValue) -> str | None:
|
|
129
|
+
memo = (kind_id, attribute_id, value_key(value))
|
|
130
|
+
if memo not in self._unique:
|
|
131
|
+
self.prefetch_unique(kind_id, attribute_id, [value])
|
|
132
|
+
return self._unique.get(memo)
|
|
133
|
+
|
|
134
|
+
def keys_referencing(self, kind_id: str, attribute_id: str, key: str) -> Iterable[str]:
|
|
135
|
+
memo = (kind_id, attribute_id, key)
|
|
136
|
+
if memo not in self._referencing:
|
|
137
|
+
self.prefetch_referencing(kind_id, attribute_id, [key])
|
|
138
|
+
return sorted(self._referencing[memo])
|
|
139
|
+
|
|
140
|
+
def link_exists(self, relation_id: str, from_key: str, to_key: str) -> bool:
|
|
141
|
+
return self.link_state(relation_id, from_key, to_key) is not None
|
|
142
|
+
|
|
143
|
+
def count_links(
|
|
144
|
+
self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
|
|
145
|
+
) -> int:
|
|
146
|
+
side, key = _side(from_key, to_key)
|
|
147
|
+
if side is None:
|
|
148
|
+
return self._count_all(relation_id, from_key, to_key)
|
|
149
|
+
memo = (relation_id, side, key)
|
|
150
|
+
if memo not in self._counts:
|
|
151
|
+
self.prefetch_link_counts(relation_id, side, [key])
|
|
152
|
+
return self._counts[memo]
|
|
153
|
+
|
|
154
|
+
def links_of(
|
|
155
|
+
self, relation_id: str, *, from_key: str | None = None, to_key: str | None = None
|
|
156
|
+
) -> Iterable[tuple[str, str]]:
|
|
157
|
+
side, key = _side(from_key, to_key)
|
|
158
|
+
if side is None:
|
|
159
|
+
return [
|
|
160
|
+
pair
|
|
161
|
+
for pair in self._pairs_query(relation_id, None, [])
|
|
162
|
+
if (from_key is None or pair[0] == from_key)
|
|
163
|
+
and (to_key is None or pair[1] == to_key)
|
|
164
|
+
]
|
|
165
|
+
memo = (relation_id, side, key)
|
|
166
|
+
if memo not in self._pairs:
|
|
167
|
+
self.prefetch_links_of(relation_id, side, [key])
|
|
168
|
+
return list(self._pairs[memo])
|
|
169
|
+
|
|
170
|
+
# State before the batch -------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def record_state(self, kind_id: str, key: str) -> RecordState | None:
|
|
173
|
+
memo = (kind_id, key)
|
|
174
|
+
if memo not in self._records:
|
|
175
|
+
self.prefetch_records([RecordIdentity(kind_id, key)])
|
|
176
|
+
return self._records[memo]
|
|
177
|
+
|
|
178
|
+
def link_state(self, relation_id: str, from_key: str, to_key: str) -> LinkState | None:
|
|
179
|
+
memo = (relation_id, from_key, to_key)
|
|
180
|
+
if memo not in self._links:
|
|
181
|
+
self.prefetch_links([LinkIdentity(relation_id, from_key, to_key)])
|
|
182
|
+
return self._links[memo]
|
|
183
|
+
|
|
184
|
+
def records_of_kind(self, kind_id: str) -> Iterator[Record]:
|
|
185
|
+
"""Every materialized Record of a Kind, in key order."""
|
|
186
|
+
rows_from = self._record_rows()
|
|
187
|
+
last: str | None = None
|
|
188
|
+
while True:
|
|
189
|
+
query = (
|
|
190
|
+
select(rows_from.c.key, rows_from.c["values"])
|
|
191
|
+
.where(self._scope(rows_from), rows_from.c.kind_id == kind_id)
|
|
192
|
+
.order_by(rows_from.c.key)
|
|
193
|
+
.limit(self._batch)
|
|
194
|
+
)
|
|
195
|
+
if last is not None:
|
|
196
|
+
query = query.where(rows_from.c.key > last)
|
|
197
|
+
rows = list(self._execute(query))
|
|
198
|
+
if not rows:
|
|
199
|
+
return
|
|
200
|
+
for key, values in rows:
|
|
201
|
+
yield Record(kind=kind_id, key=key, values=values)
|
|
202
|
+
last = rows[-1][0]
|
|
203
|
+
|
|
204
|
+
def links_of_relation(self, relation_id: str) -> Iterator[Link]:
|
|
205
|
+
"""Every materialized Link of a Relation, in `(from, to)` order."""
|
|
206
|
+
links = self._link_rows()
|
|
207
|
+
last: tuple[str, str] | None = None
|
|
208
|
+
while True:
|
|
209
|
+
query = (
|
|
210
|
+
select(links.c.from_key, links.c.to_key, links.c["values"])
|
|
211
|
+
.where(self._scope(links), links.c.relation_id == relation_id)
|
|
212
|
+
.order_by(links.c.from_key, links.c.to_key)
|
|
213
|
+
.limit(self._batch)
|
|
214
|
+
)
|
|
215
|
+
if last is not None:
|
|
216
|
+
query = query.where(tuple_(links.c.from_key, links.c.to_key) > last)
|
|
217
|
+
rows = list(self._execute(query))
|
|
218
|
+
if not rows:
|
|
219
|
+
return
|
|
220
|
+
for from_key, to_key, values in rows:
|
|
221
|
+
yield Link(relation=relation_id, from_key=from_key, to_key=to_key, values=values)
|
|
222
|
+
last = (rows[-1][0], rows[-1][1])
|
|
223
|
+
|
|
224
|
+
# Bulk prefetch ----------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def prefetch_records(self, identities: Iterable[RecordIdentity]) -> None:
|
|
227
|
+
by_kind: dict[str, list[str]] = defaultdict(list)
|
|
228
|
+
for kind_id, key in dict.fromkeys(identities):
|
|
229
|
+
if (kind_id, key) not in self._records:
|
|
230
|
+
by_kind[kind_id].append(key)
|
|
231
|
+
rows = self._record_rows()
|
|
232
|
+
for kind_id, keys in by_kind.items():
|
|
233
|
+
for chunk in chunks(keys, self._batch):
|
|
234
|
+
for key in chunk:
|
|
235
|
+
self._records[(kind_id, key)] = None
|
|
236
|
+
query = select(
|
|
237
|
+
rows.c.key,
|
|
238
|
+
rows.c["values"],
|
|
239
|
+
lineage_column(rows),
|
|
240
|
+
rows.c.object_hash,
|
|
241
|
+
).where(
|
|
242
|
+
self._scope(rows),
|
|
243
|
+
rows.c.kind_id == kind_id,
|
|
244
|
+
rows.c.key.in_(chunk),
|
|
245
|
+
)
|
|
246
|
+
for key, values, lineage, digest in self._execute(query):
|
|
247
|
+
self._records[(kind_id, key)] = RecordState(values, lineage, digest)
|
|
248
|
+
|
|
249
|
+
def prefetch_links(self, identities: Iterable[LinkIdentity]) -> None:
|
|
250
|
+
by_relation: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
|
251
|
+
for relation_id, from_key, to_key in dict.fromkeys(identities):
|
|
252
|
+
if (relation_id, from_key, to_key) not in self._links:
|
|
253
|
+
by_relation[relation_id].append((from_key, to_key))
|
|
254
|
+
links = self._link_rows()
|
|
255
|
+
for relation_id, pairs in by_relation.items():
|
|
256
|
+
for chunk in chunks(pairs, self._batch):
|
|
257
|
+
for from_key, to_key in chunk:
|
|
258
|
+
self._links[(relation_id, from_key, to_key)] = None
|
|
259
|
+
query = select(
|
|
260
|
+
links.c.from_key,
|
|
261
|
+
links.c.to_key,
|
|
262
|
+
links.c["values"],
|
|
263
|
+
lineage_column(links),
|
|
264
|
+
links.c.object_hash,
|
|
265
|
+
).where(
|
|
266
|
+
self._scope(links),
|
|
267
|
+
links.c.relation_id == relation_id,
|
|
268
|
+
pairs_in(links.c.from_key, links.c.to_key, chunk),
|
|
269
|
+
)
|
|
270
|
+
for from_key, to_key, values, lineage, digest in self._execute(query):
|
|
271
|
+
self._links[(relation_id, from_key, to_key)] = LinkState(
|
|
272
|
+
values, lineage, digest
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def prefetch_unique(
|
|
276
|
+
self, kind_id: str, attribute_id: str, values: Iterable[CanonicalValue]
|
|
277
|
+
) -> None:
|
|
278
|
+
pending = {
|
|
279
|
+
value_key(v): v
|
|
280
|
+
for v in values
|
|
281
|
+
if (kind_id, attribute_id, value_key(v)) not in self._unique
|
|
282
|
+
}
|
|
283
|
+
spec = self._unique_indexes.get((kind_id, attribute_id))
|
|
284
|
+
if spec is not None:
|
|
285
|
+
# Without the index's predicate and expression, the lookup reads every Record
|
|
286
|
+
# of the Kind.
|
|
287
|
+
statement = text(
|
|
288
|
+
_UNIQUE_INDEXED.format(
|
|
289
|
+
records=self.layout().records_from(),
|
|
290
|
+
predicate=spec.predicate_sql(self._domain, self._branch),
|
|
291
|
+
expression=spec.expression,
|
|
292
|
+
value=spec.value_sql("c.v"),
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
statement = self._text(_UNIQUE_VALUES)
|
|
297
|
+
for chunk in chunks(list(pending), self._batch):
|
|
298
|
+
for memo in chunk:
|
|
299
|
+
self._unique[(kind_id, attribute_id, memo)] = None
|
|
300
|
+
rows = self._execute(
|
|
301
|
+
statement,
|
|
302
|
+
{
|
|
303
|
+
"domain": self._domain,
|
|
304
|
+
"branch": self._branch,
|
|
305
|
+
"kind": kind_id,
|
|
306
|
+
"attribute": attribute_id,
|
|
307
|
+
"candidates": json.dumps([pending[memo] for memo in chunk]),
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
for key, value in rows:
|
|
311
|
+
self._unique[(kind_id, attribute_id, value_key(value))] = key
|
|
312
|
+
|
|
313
|
+
def prefetch_referencing(self, kind_id: str, attribute_id: str, keys: Iterable[str]) -> None:
|
|
314
|
+
pending = [
|
|
315
|
+
k for k in dict.fromkeys(keys) if (kind_id, attribute_id, k) not in self._referencing
|
|
316
|
+
]
|
|
317
|
+
for chunk in chunks(pending, self._batch):
|
|
318
|
+
for key in chunk:
|
|
319
|
+
self._referencing[(kind_id, attribute_id, key)] = set()
|
|
320
|
+
rows = self._execute(
|
|
321
|
+
self._text(_REFERENCING),
|
|
322
|
+
{
|
|
323
|
+
"domain": self._domain,
|
|
324
|
+
"branch": self._branch,
|
|
325
|
+
"kind": kind_id,
|
|
326
|
+
"attribute": attribute_id,
|
|
327
|
+
"keys": list(chunk),
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
wanted = set(chunk)
|
|
331
|
+
for record_key, value in rows:
|
|
332
|
+
for target in _keys_in(value):
|
|
333
|
+
if target in wanted:
|
|
334
|
+
self._referencing[(kind_id, attribute_id, target)].add(record_key)
|
|
335
|
+
|
|
336
|
+
def prefetch_link_counts(self, relation_id: str, side: Side, keys: Iterable[str]) -> None:
|
|
337
|
+
links = self._link_rows()
|
|
338
|
+
column = links.c.from_key if side == "from" else links.c.to_key
|
|
339
|
+
pending = [k for k in dict.fromkeys(keys) if (relation_id, side, k) not in self._counts]
|
|
340
|
+
for chunk in chunks(pending, self._batch):
|
|
341
|
+
for key in chunk:
|
|
342
|
+
self._counts[(relation_id, side, key)] = 0
|
|
343
|
+
query = (
|
|
344
|
+
select(column, func.count())
|
|
345
|
+
.where(
|
|
346
|
+
self._scope(links),
|
|
347
|
+
links.c.relation_id == relation_id,
|
|
348
|
+
column.in_(chunk),
|
|
349
|
+
)
|
|
350
|
+
.group_by(column)
|
|
351
|
+
)
|
|
352
|
+
for key, count in self._execute(query):
|
|
353
|
+
self._counts[(relation_id, side, key)] = int(count)
|
|
354
|
+
|
|
355
|
+
def prefetch_links_of(self, relation_id: str, side: Side, keys: Iterable[str]) -> None:
|
|
356
|
+
pending = [k for k in dict.fromkeys(keys) if (relation_id, side, k) not in self._pairs]
|
|
357
|
+
for chunk in chunks(pending, self._batch):
|
|
358
|
+
for key in chunk:
|
|
359
|
+
self._pairs[(relation_id, side, key)] = []
|
|
360
|
+
for pair in self._pairs_query(relation_id, side, chunk):
|
|
361
|
+
key = pair[0] if side == "from" else pair[1]
|
|
362
|
+
self._pairs[(relation_id, side, key)].append(pair)
|
|
363
|
+
for key in chunk:
|
|
364
|
+
self._counts[(relation_id, side, key)] = len(self._pairs[(relation_id, side, key)])
|
|
365
|
+
|
|
366
|
+
def prefetch_for(self, schema: Schema, changes: Sequence[DataChange]) -> None:
|
|
367
|
+
"""Load in bulk what validating `changes` against `schema` will read."""
|
|
368
|
+
# Imported here: `lorebase_store.indexes` imports the repositories package.
|
|
369
|
+
from lorebase_store.indexes import plan_indexes
|
|
370
|
+
|
|
371
|
+
self._unique_indexes = {
|
|
372
|
+
key: spec for key, spec in plan_indexes(schema).by_attribute().items() if spec.unique
|
|
373
|
+
}
|
|
374
|
+
records: list[RecordIdentity] = []
|
|
375
|
+
links: list[LinkIdentity] = []
|
|
376
|
+
unique: dict[tuple[str, str], list[CanonicalValue]] = defaultdict(list)
|
|
377
|
+
counts: dict[tuple[str, Side], list[str]] = defaultdict(list)
|
|
378
|
+
pairs: dict[tuple[str, Side], list[str]] = defaultdict(list)
|
|
379
|
+
referencing: dict[tuple[str, str], list[str]] = defaultdict(list)
|
|
380
|
+
new_records: dict[str, list[str]] = defaultdict(list)
|
|
381
|
+
for change in changes:
|
|
382
|
+
if isinstance(change, RecordUpsert):
|
|
383
|
+
record = change.record
|
|
384
|
+
records.append(record.identity)
|
|
385
|
+
new_records[record.kind].append(record.key)
|
|
386
|
+
kind = schema.kinds.get(record.kind)
|
|
387
|
+
if kind is None:
|
|
388
|
+
continue
|
|
389
|
+
for attribute in kind.attributes.values():
|
|
390
|
+
value = record.values.get(attribute.id)
|
|
391
|
+
if value is None:
|
|
392
|
+
continue
|
|
393
|
+
if attribute.unique:
|
|
394
|
+
unique[(kind.id, attribute.id)].append(value)
|
|
395
|
+
target = attribute.type.scalar.kind_ref
|
|
396
|
+
if attribute.type.scalar.name is AttributeType.REF and target is not None:
|
|
397
|
+
records.extend(RecordIdentity(target, k) for k in _keys_in(value))
|
|
398
|
+
elif isinstance(change, RecordDelete):
|
|
399
|
+
records.append(change.identity)
|
|
400
|
+
for relation in schema.relations_of(change.kind):
|
|
401
|
+
if relation.backing is not RelationBacking.EXPLICIT:
|
|
402
|
+
continue
|
|
403
|
+
if relation.from_kind == change.kind:
|
|
404
|
+
pairs[(relation.id, "from")].append(change.key)
|
|
405
|
+
if relation.to_kind == change.kind:
|
|
406
|
+
pairs[(relation.id, "to")].append(change.key)
|
|
407
|
+
for kind in schema.kinds.values():
|
|
408
|
+
for attribute in kind.attributes.values():
|
|
409
|
+
scalar = attribute.type.scalar
|
|
410
|
+
if scalar.name is AttributeType.REF and scalar.kind_ref == change.kind:
|
|
411
|
+
referencing[(kind.id, attribute.id)].append(change.key)
|
|
412
|
+
else:
|
|
413
|
+
identity = change.identity
|
|
414
|
+
links.append(identity)
|
|
415
|
+
linked = schema.relations.get(identity.relation)
|
|
416
|
+
if linked is None:
|
|
417
|
+
continue
|
|
418
|
+
records.append(RecordIdentity(linked.from_kind, identity.from_key))
|
|
419
|
+
records.append(RecordIdentity(linked.to_kind, identity.to_key))
|
|
420
|
+
if linked.single_per_from or linked.required_from:
|
|
421
|
+
counts[(linked.id, "from")].append(identity.from_key)
|
|
422
|
+
if linked.single_per_to and isinstance(change, LinkUpsert):
|
|
423
|
+
counts[(linked.id, "to")].append(identity.to_key)
|
|
424
|
+
for required in schema.relations.values():
|
|
425
|
+
if required.required_from:
|
|
426
|
+
counts[(required.id, "from")].extend(new_records.get(required.from_kind, ()))
|
|
427
|
+
self.prefetch_records(records)
|
|
428
|
+
self.prefetch_links(links)
|
|
429
|
+
for (kind_id, attribute_id), values in unique.items():
|
|
430
|
+
self.prefetch_unique(kind_id, attribute_id, values)
|
|
431
|
+
for (relation_id, side), keys in pairs.items():
|
|
432
|
+
self.prefetch_links_of(relation_id, side, keys)
|
|
433
|
+
for (relation_id, side), keys in counts.items():
|
|
434
|
+
self.prefetch_link_counts(relation_id, side, keys)
|
|
435
|
+
for (kind_id, attribute_id), keys in referencing.items():
|
|
436
|
+
self.prefetch_referencing(kind_id, attribute_id, keys)
|
|
437
|
+
|
|
438
|
+
# Queries ----------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
def layout(self) -> BranchLayout:
|
|
441
|
+
if self._layout is None:
|
|
442
|
+
self._layout = load_layout_sync(self._session, self._domain, self._branch)
|
|
443
|
+
return self._layout
|
|
444
|
+
|
|
445
|
+
def _record_rows(self) -> FromClause:
|
|
446
|
+
return self.layout().records()
|
|
447
|
+
|
|
448
|
+
def _link_rows(self) -> FromClause:
|
|
449
|
+
return self.layout().links()
|
|
450
|
+
|
|
451
|
+
def _text(self, template: str) -> TextClause:
|
|
452
|
+
return text(template.format(records=self.layout().records_from()))
|
|
453
|
+
|
|
454
|
+
def _scope(self, table: Any) -> ColumnElement[bool]:
|
|
455
|
+
return and_(table.c.domain_id == self._domain, table.c.branch == self._branch)
|
|
456
|
+
|
|
457
|
+
def _execute(self, statement: Any, parameters: dict[str, Any] | None = None) -> Any:
|
|
458
|
+
self.stats.queries += 1
|
|
459
|
+
return self._session.execute(statement, parameters or {})
|
|
460
|
+
|
|
461
|
+
def _pairs_query(
|
|
462
|
+
self, relation_id: str, side: Side | None, keys: Sequence[str]
|
|
463
|
+
) -> list[tuple[str, str]]:
|
|
464
|
+
links = self._link_rows()
|
|
465
|
+
query = select(links.c.from_key, links.c.to_key).where(
|
|
466
|
+
self._scope(links), links.c.relation_id == relation_id
|
|
467
|
+
)
|
|
468
|
+
if side == "from":
|
|
469
|
+
query = query.where(links.c.from_key.in_(keys))
|
|
470
|
+
elif side == "to":
|
|
471
|
+
query = query.where(links.c.to_key.in_(keys))
|
|
472
|
+
return [(f, t) for f, t in self._execute(query)]
|
|
473
|
+
|
|
474
|
+
def _count_all(self, relation_id: str, from_key: str | None, to_key: str | None) -> int:
|
|
475
|
+
links = self._link_rows()
|
|
476
|
+
query = select(func.count()).where(self._scope(links), links.c.relation_id == relation_id)
|
|
477
|
+
if from_key is not None:
|
|
478
|
+
query = query.where(links.c.from_key == from_key)
|
|
479
|
+
if to_key is not None:
|
|
480
|
+
query = query.where(links.c.to_key == to_key)
|
|
481
|
+
return int(self._execute(query).scalar_one())
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _side(from_key: str | None, to_key: str | None) -> tuple[Side | None, str]:
|
|
485
|
+
if from_key is not None and to_key is None:
|
|
486
|
+
return "from", from_key
|
|
487
|
+
if to_key is not None and from_key is None:
|
|
488
|
+
return "to", to_key
|
|
489
|
+
return None, ""
|