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,512 @@
|
|
|
1
|
+
"""Queries and checks behind the health reports, over the materialized head of one branch.
|
|
2
|
+
|
|
3
|
+
Each function answers one question for one Kind or Relation and reads the database through the
|
|
4
|
+
caller's session. Nothing here writes. Statements are scoped to the Domain and the branch and
|
|
5
|
+
use only primary-key friendly predicates, so the cost is a scan per Kind (validation, duplicates,
|
|
6
|
+
derived values) or per Relation (orphans and gaps) at worst. An overlay branch is read through
|
|
7
|
+
its resolved rows (`lorebase_store.layout`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from functools import cached_property
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import (
|
|
20
|
+
ColumnElement,
|
|
21
|
+
FromClause,
|
|
22
|
+
and_,
|
|
23
|
+
exists,
|
|
24
|
+
func,
|
|
25
|
+
literal_column,
|
|
26
|
+
or_,
|
|
27
|
+
select,
|
|
28
|
+
text,
|
|
29
|
+
)
|
|
30
|
+
from sqlalchemy.dialects.postgresql import distinct_on
|
|
31
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
32
|
+
|
|
33
|
+
from lorebase_core.model import Attribute, Kind, Record, Relation, Schema
|
|
34
|
+
from lorebase_core.model.relation import RelationBacking
|
|
35
|
+
from lorebase_core.tree import kind_partition
|
|
36
|
+
from lorebase_core.validate import Issue, validate_record
|
|
37
|
+
from lorebase_keeper.kindhealth.config import HealthSettings
|
|
38
|
+
from lorebase_keeper.kindhealth.models import (
|
|
39
|
+
DerivedReport,
|
|
40
|
+
DuplicateGroup,
|
|
41
|
+
DuplicateReport,
|
|
42
|
+
HiddenDuplicates,
|
|
43
|
+
IssueGroup,
|
|
44
|
+
LastChange,
|
|
45
|
+
LinkRef,
|
|
46
|
+
OrphanLinks,
|
|
47
|
+
RelationHealth,
|
|
48
|
+
RequiredGaps,
|
|
49
|
+
ViolationReport,
|
|
50
|
+
)
|
|
51
|
+
from lorebase_keeper.reading.views import kind_name
|
|
52
|
+
from lorebase_store.layout import BranchLayout
|
|
53
|
+
|
|
54
|
+
BATCH = 500
|
|
55
|
+
"""Records fetched per round trip while re-validating."""
|
|
56
|
+
|
|
57
|
+
_DUPLICATES = r"""
|
|
58
|
+
WITH normalized AS (
|
|
59
|
+
SELECT key, lower(regexp_replace(btrim("values" ->> :attribute), '\s+', ' ', 'g')) AS norm
|
|
60
|
+
FROM {records}
|
|
61
|
+
WHERE domain_id = :domain AND branch = :branch AND kind_id = :kind
|
|
62
|
+
AND "values" ->> :attribute IS NOT NULL
|
|
63
|
+
)
|
|
64
|
+
SELECT norm, count(*) AS members,
|
|
65
|
+
(array_agg(key ORDER BY key))[1:CAST(:keys AS int)] AS member_keys,
|
|
66
|
+
count(*) OVER () AS total_groups,
|
|
67
|
+
sum(count(*)) OVER () AS total_members
|
|
68
|
+
FROM normalized
|
|
69
|
+
WHERE norm <> ''
|
|
70
|
+
GROUP BY norm
|
|
71
|
+
HAVING count(*) > 1
|
|
72
|
+
ORDER BY count(*) DESC, norm
|
|
73
|
+
LIMIT :groups
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Branch:
|
|
79
|
+
"""The materialized head being examined; `layout` locates its rows (default: the
|
|
80
|
+
branch's own rows)."""
|
|
81
|
+
|
|
82
|
+
domain_id: str
|
|
83
|
+
branch: str
|
|
84
|
+
layout: BranchLayout | None = None
|
|
85
|
+
|
|
86
|
+
@cached_property
|
|
87
|
+
def rows(self) -> BranchLayout:
|
|
88
|
+
return self.layout or BranchLayout(self.domain_id, self.branch)
|
|
89
|
+
|
|
90
|
+
@cached_property
|
|
91
|
+
def records(self) -> FromClause:
|
|
92
|
+
return self.rows.records()
|
|
93
|
+
|
|
94
|
+
@cached_property
|
|
95
|
+
def links(self) -> FromClause:
|
|
96
|
+
return self.rows.links()
|
|
97
|
+
|
|
98
|
+
@cached_property
|
|
99
|
+
def history(self) -> FromClause:
|
|
100
|
+
return self.rows.history()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _records(where: Branch) -> ColumnElement[bool]:
|
|
104
|
+
r = where.records
|
|
105
|
+
return and_(r.c.domain_id == where.domain_id, r.c.branch == where.branch)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _links(where: Branch) -> ColumnElement[bool]:
|
|
109
|
+
link = where.links
|
|
110
|
+
return and_(link.c.domain_id == where.domain_id, link.c.branch == where.branch)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Counts and history -------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def record_counts(session: AsyncSession, where: Branch) -> dict[str, int]:
|
|
117
|
+
"""Records per Kind id."""
|
|
118
|
+
r = where.records
|
|
119
|
+
result = await session.execute(
|
|
120
|
+
select(r.c.kind_id, func.count()).where(_records(where)).group_by(r.c.kind_id)
|
|
121
|
+
)
|
|
122
|
+
return {kind_id: int(count) for kind_id, count in result}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def link_counts(session: AsyncSession, where: Branch) -> dict[str, int]:
|
|
126
|
+
"""Stored Links per Relation id."""
|
|
127
|
+
link = where.links
|
|
128
|
+
result = await session.execute(
|
|
129
|
+
select(link.c.relation_id, func.count()).where(_links(where)).group_by(link.c.relation_id)
|
|
130
|
+
)
|
|
131
|
+
return {relation_id: int(count) for relation_id, count in result}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def last_changes(
|
|
135
|
+
session: AsyncSession, where: Branch, kinds: Iterable[str]
|
|
136
|
+
) -> dict[str, LastChange]:
|
|
137
|
+
"""The latest Revision of the branch that touched each Kind (Record writes and deletions)."""
|
|
138
|
+
partitions = {kind_partition(kind_id): kind_id for kind_id in kinds}
|
|
139
|
+
if not partitions:
|
|
140
|
+
return {}
|
|
141
|
+
h = where.history
|
|
142
|
+
result = await session.execute(
|
|
143
|
+
select(h.c.partition, h.c.revision_id, h.c.at)
|
|
144
|
+
.ext(distinct_on(h.c.partition))
|
|
145
|
+
.where(
|
|
146
|
+
h.c.domain_id == where.domain_id,
|
|
147
|
+
h.c.branch == where.branch,
|
|
148
|
+
h.c.partition.in_(list(partitions)),
|
|
149
|
+
)
|
|
150
|
+
.order_by(h.c.partition, h.c.at.desc(), h.c.revision_id)
|
|
151
|
+
)
|
|
152
|
+
return {
|
|
153
|
+
partitions[partition]: LastChange(revision=bytes(revision).hex(), at=at)
|
|
154
|
+
for partition, revision, at in result
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Constraint violations ----------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _sample_filter(step: int) -> ColumnElement[bool]:
|
|
162
|
+
"""Keeps one Record in `step`, chosen by a hash of its key: spread evenly and stable."""
|
|
163
|
+
bits = "('x' || substr(md5(mat_records.key), 1, 7))::bit(28)::int"
|
|
164
|
+
return literal_column(f"mod({bits}, {int(step)})") == 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def validate_records(
|
|
168
|
+
session: AsyncSession,
|
|
169
|
+
where: Branch,
|
|
170
|
+
schema: Schema,
|
|
171
|
+
kind: Kind,
|
|
172
|
+
total: int,
|
|
173
|
+
settings: HealthSettings,
|
|
174
|
+
generalize: Callable[[list[Issue]], list[Issue]] | None = None,
|
|
175
|
+
hidden: Callable[[Attribute], bool] = lambda a: False,
|
|
176
|
+
*,
|
|
177
|
+
projected: bool = False,
|
|
178
|
+
) -> ViolationReport:
|
|
179
|
+
"""Re-validate the Records of a Kind with `validate_record` against the branch's schema.
|
|
180
|
+
`generalize` rewrites the issues of each Record before they are grouped (issues about
|
|
181
|
+
values the caller may not read lose their specific code); issues about a `hidden`
|
|
182
|
+
Attribute list no Record keys, which would tell which values fail the rule. With
|
|
183
|
+
`projected` (a schema projected to the Kinds the caller may read, design 13.3), values of
|
|
184
|
+
Attributes the projection left out are not validated."""
|
|
185
|
+
r = where.records
|
|
186
|
+
step = max(1, math.ceil(total / settings.max_records_validated))
|
|
187
|
+
checked = invalid = 0
|
|
188
|
+
groups: dict[tuple[str, str | None], list[Any]] = defaultdict(lambda: [0, []])
|
|
189
|
+
last: str | None = None
|
|
190
|
+
while checked < settings.max_records_validated:
|
|
191
|
+
query = (
|
|
192
|
+
select(r.c.key, r.c["values"])
|
|
193
|
+
.where(_records(where), r.c.kind_id == kind.id)
|
|
194
|
+
.order_by(r.c.key)
|
|
195
|
+
.limit(min(BATCH, settings.max_records_validated - checked))
|
|
196
|
+
)
|
|
197
|
+
if step > 1:
|
|
198
|
+
query = query.where(_sample_filter(step))
|
|
199
|
+
if last is not None:
|
|
200
|
+
query = query.where(r.c.key > last)
|
|
201
|
+
rows = (await session.execute(query)).all()
|
|
202
|
+
if not rows:
|
|
203
|
+
break
|
|
204
|
+
for key, values in rows:
|
|
205
|
+
checked += 1
|
|
206
|
+
if projected:
|
|
207
|
+
values = {k: v for k, v in (values or {}).items() if k in kind.attributes}
|
|
208
|
+
issues = validate_record(
|
|
209
|
+
schema, Record.model_construct(kind=kind.id, key=key, values=values)
|
|
210
|
+
)
|
|
211
|
+
if generalize is not None:
|
|
212
|
+
issues = generalize(issues)
|
|
213
|
+
if issues:
|
|
214
|
+
invalid += 1
|
|
215
|
+
seen: set[tuple[str, str | None]] = set()
|
|
216
|
+
for issue in issues:
|
|
217
|
+
attribute = kind.attributes.get(issue.attribute) if issue.attribute else None
|
|
218
|
+
name = attribute.api_name if attribute is not None else issue.attribute
|
|
219
|
+
label = (issue.code.value, name)
|
|
220
|
+
entry = groups[label]
|
|
221
|
+
entry[0] += 1
|
|
222
|
+
if (
|
|
223
|
+
label not in seen
|
|
224
|
+
and len(entry[1]) < settings.examples_per_issue
|
|
225
|
+
and not (attribute is not None and hidden(attribute))
|
|
226
|
+
):
|
|
227
|
+
entry[1].append(key)
|
|
228
|
+
seen.add(label)
|
|
229
|
+
last = rows[-1][0]
|
|
230
|
+
ordered = sorted(groups.items(), key=lambda item: (-item[1][0], item[0][0], item[0][1] or ""))
|
|
231
|
+
return ViolationReport(
|
|
232
|
+
checked=checked,
|
|
233
|
+
sampled=step > 1,
|
|
234
|
+
invalid_records=invalid,
|
|
235
|
+
issues=[
|
|
236
|
+
IssueGroup(code=code, attribute=name, count=count, examples=examples)
|
|
237
|
+
for (code, name), (count, examples) in ordered[: settings.max_issue_groups]
|
|
238
|
+
],
|
|
239
|
+
issues_truncated=len(ordered) > settings.max_issue_groups,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Duplicates ---------------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def duplicate_attributes(kind: Kind) -> list[str]:
|
|
247
|
+
"""Attribute ids checked for duplicates: unique ones, then the title Attribute."""
|
|
248
|
+
ids = [a.id for a in kind.attributes.values() if a.unique and not a.is_derived]
|
|
249
|
+
title = kind.attributes.get(kind.title_attribute) if kind.title_attribute else None
|
|
250
|
+
if title is not None and not title.is_derived and title.id not in ids:
|
|
251
|
+
ids.append(title.id)
|
|
252
|
+
return ids
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def find_duplicates(
|
|
256
|
+
session: AsyncSession,
|
|
257
|
+
where: Branch,
|
|
258
|
+
kind: Kind,
|
|
259
|
+
total: int,
|
|
260
|
+
settings: HealthSettings,
|
|
261
|
+
sensitive: Callable[[Attribute], bool] = lambda a: a.is_sensitive,
|
|
262
|
+
hidden: Callable[[Attribute], bool] = lambda a: False,
|
|
263
|
+
) -> DuplicateReport:
|
|
264
|
+
"""Records of a Kind sharing a unique or title value, ignoring case and spacing.
|
|
265
|
+
|
|
266
|
+
Groups of a `hidden` Attribute (one whose values the caller may not read) are not listed:
|
|
267
|
+
which Records share a value would tell that their values are equal. Only how many groups
|
|
268
|
+
and Records there are is reported for it."""
|
|
269
|
+
ids = duplicate_attributes(kind)
|
|
270
|
+
names = [kind.attributes[i].api_name for i in ids]
|
|
271
|
+
if settings.duplicates_max_records == 0:
|
|
272
|
+
return DuplicateReport(attributes=names, groups=[], total_groups=0, skipped="disabled")
|
|
273
|
+
if total > settings.duplicates_max_records:
|
|
274
|
+
return DuplicateReport(
|
|
275
|
+
attributes=names, groups=[], total_groups=0, skipped="too_many_records"
|
|
276
|
+
)
|
|
277
|
+
found: list[DuplicateGroup] = []
|
|
278
|
+
withheld: list[HiddenDuplicates] = []
|
|
279
|
+
groups = 0
|
|
280
|
+
for attribute_id in ids:
|
|
281
|
+
attribute = kind.attributes[attribute_id]
|
|
282
|
+
rows = (
|
|
283
|
+
await session.execute(
|
|
284
|
+
text(_DUPLICATES.format(records=where.rows.records_from())),
|
|
285
|
+
{
|
|
286
|
+
"domain": where.domain_id,
|
|
287
|
+
"branch": where.branch,
|
|
288
|
+
"kind": kind.id,
|
|
289
|
+
"attribute": attribute_id,
|
|
290
|
+
"keys": settings.duplicate_keys_per_group,
|
|
291
|
+
"groups": settings.max_duplicate_groups,
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
).all()
|
|
295
|
+
if rows:
|
|
296
|
+
groups += int(rows[0].total_groups)
|
|
297
|
+
if hidden(attribute):
|
|
298
|
+
if rows:
|
|
299
|
+
withheld.append(
|
|
300
|
+
HiddenDuplicates(
|
|
301
|
+
attribute=attribute.api_name,
|
|
302
|
+
unique=attribute.unique,
|
|
303
|
+
groups=int(rows[0].total_groups),
|
|
304
|
+
records=int(rows[0].total_members),
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
continue
|
|
308
|
+
found.extend(
|
|
309
|
+
DuplicateGroup(
|
|
310
|
+
attribute=attribute.api_name,
|
|
311
|
+
unique=attribute.unique,
|
|
312
|
+
value=None if sensitive(attribute) else row.norm,
|
|
313
|
+
count=int(row.members),
|
|
314
|
+
keys=list(row.member_keys),
|
|
315
|
+
)
|
|
316
|
+
for row in rows
|
|
317
|
+
)
|
|
318
|
+
return DuplicateReport(attributes=names, groups=found, total_groups=groups, hidden=withheld)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# Derived values -----------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def derived_status(
|
|
325
|
+
session: AsyncSession, where: Branch, kind: Kind, total: int
|
|
326
|
+
) -> DerivedReport | None:
|
|
327
|
+
"""Records lacking the materialized value of a derived Attribute (None: no such Attribute)."""
|
|
328
|
+
derived = [a for a in kind.attributes.values() if a.is_derived]
|
|
329
|
+
if not derived:
|
|
330
|
+
return None
|
|
331
|
+
r = where.records
|
|
332
|
+
missing = 0
|
|
333
|
+
if total:
|
|
334
|
+
missing = int(
|
|
335
|
+
(
|
|
336
|
+
await session.execute(
|
|
337
|
+
select(func.count()).where(
|
|
338
|
+
_records(where),
|
|
339
|
+
r.c.kind_id == kind.id,
|
|
340
|
+
or_(*[~r.c.derived.has_key(a.id) for a in derived]),
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
).scalar_one()
|
|
344
|
+
)
|
|
345
|
+
return DerivedReport(
|
|
346
|
+
attributes=[a.api_name for a in derived], records_missing_value=missing, stale=missing > 0
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# Relations ----------------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _gaps(missing: int, keys: Iterable[str]) -> RequiredGaps:
|
|
354
|
+
return RequiredGaps(missing=missing, examples=list(keys))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
async def relation_health(
|
|
358
|
+
session: AsyncSession,
|
|
359
|
+
where: Branch,
|
|
360
|
+
schema: Schema,
|
|
361
|
+
relation: Relation,
|
|
362
|
+
stored_links: Mapping[str, int],
|
|
363
|
+
settings: HealthSettings,
|
|
364
|
+
) -> RelationHealth:
|
|
365
|
+
"""Links, orphan Links and required-relation gaps of a Relation."""
|
|
366
|
+
if relation.backing is RelationBacking.FOREIGN_KEY and relation.foreign_key_attribute:
|
|
367
|
+
links, orphans, gaps = await _foreign_key_health(session, where, relation, settings)
|
|
368
|
+
else:
|
|
369
|
+
links = stored_links.get(relation.id, 0)
|
|
370
|
+
orphans = await _orphan_links(session, where, relation, settings)
|
|
371
|
+
gaps = await _missing_links(session, where, relation, settings)
|
|
372
|
+
return RelationHealth(
|
|
373
|
+
relation=relation.api_name,
|
|
374
|
+
from_kind=kind_name(schema, relation.from_kind),
|
|
375
|
+
to_kind=kind_name(schema, relation.to_kind),
|
|
376
|
+
backing=relation.backing,
|
|
377
|
+
links=links,
|
|
378
|
+
orphan_links=orphans,
|
|
379
|
+
required=relation.required_from,
|
|
380
|
+
required_gaps=gaps if relation.required_from else None,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
async def _orphan_links(
|
|
385
|
+
session: AsyncSession, where: Branch, relation: Relation, settings: HealthSettings
|
|
386
|
+
) -> OrphanLinks:
|
|
387
|
+
link = where.links
|
|
388
|
+
f, t = where.rows.records("f"), where.rows.records("t")
|
|
389
|
+
from_exists = exists().where(
|
|
390
|
+
f.c.domain_id == link.c.domain_id,
|
|
391
|
+
f.c.branch == link.c.branch,
|
|
392
|
+
f.c.kind_id == relation.from_kind,
|
|
393
|
+
f.c.key == link.c.from_key,
|
|
394
|
+
)
|
|
395
|
+
to_exists = exists().where(
|
|
396
|
+
t.c.domain_id == link.c.domain_id,
|
|
397
|
+
t.c.branch == link.c.branch,
|
|
398
|
+
t.c.kind_id == relation.to_kind,
|
|
399
|
+
t.c.key == link.c.to_key,
|
|
400
|
+
)
|
|
401
|
+
scope = (_links(where), link.c.relation_id == relation.id)
|
|
402
|
+
counts = (
|
|
403
|
+
await session.execute(
|
|
404
|
+
select(
|
|
405
|
+
func.count().filter(~from_exists),
|
|
406
|
+
func.count().filter(~to_exists),
|
|
407
|
+
func.count().filter(or_(~from_exists, ~to_exists)),
|
|
408
|
+
).where(*scope)
|
|
409
|
+
)
|
|
410
|
+
).one()
|
|
411
|
+
examples: list[LinkRef] = []
|
|
412
|
+
if counts[2] and settings.orphan_examples:
|
|
413
|
+
rows = await session.execute(
|
|
414
|
+
select(link.c.from_key, link.c.to_key)
|
|
415
|
+
.where(*scope, or_(~from_exists, ~to_exists))
|
|
416
|
+
.order_by(link.c.from_key, link.c.to_key)
|
|
417
|
+
.limit(settings.orphan_examples)
|
|
418
|
+
)
|
|
419
|
+
examples = [LinkRef(from_key=a, to_key=b) for a, b in rows]
|
|
420
|
+
return OrphanLinks(
|
|
421
|
+
count=int(counts[2]),
|
|
422
|
+
missing_from=int(counts[0]),
|
|
423
|
+
missing_to=int(counts[1]),
|
|
424
|
+
examples=examples,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
async def _missing_links(
|
|
429
|
+
session: AsyncSession, where: Branch, relation: Relation, settings: HealthSettings
|
|
430
|
+
) -> RequiredGaps:
|
|
431
|
+
r, link = where.records, where.links
|
|
432
|
+
linked = exists().where(
|
|
433
|
+
link.c.domain_id == r.c.domain_id,
|
|
434
|
+
link.c.branch == r.c.branch,
|
|
435
|
+
link.c.relation_id == relation.id,
|
|
436
|
+
link.c.from_key == r.c.key,
|
|
437
|
+
)
|
|
438
|
+
return await _gap_query(session, where, relation.from_kind, ~linked, settings)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
async def _gap_query(
|
|
442
|
+
session: AsyncSession,
|
|
443
|
+
where: Branch,
|
|
444
|
+
kind_id: str,
|
|
445
|
+
condition: ColumnElement[bool],
|
|
446
|
+
settings: HealthSettings,
|
|
447
|
+
) -> RequiredGaps:
|
|
448
|
+
r = where.records
|
|
449
|
+
scope = (_records(where), r.c.kind_id == kind_id, condition)
|
|
450
|
+
missing = int((await session.execute(select(func.count()).where(*scope))).scalar_one())
|
|
451
|
+
keys: Sequence[str] = []
|
|
452
|
+
if missing and settings.orphan_examples:
|
|
453
|
+
rows = await session.execute(
|
|
454
|
+
select(r.c.key).where(*scope).order_by(r.c.key).limit(settings.orphan_examples)
|
|
455
|
+
)
|
|
456
|
+
keys = [key for (key,) in rows]
|
|
457
|
+
return _gaps(missing, keys)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
async def _foreign_key_health(
|
|
461
|
+
session: AsyncSession, where: Branch, relation: Relation, settings: HealthSettings
|
|
462
|
+
) -> tuple[int, OrphanLinks, RequiredGaps]:
|
|
463
|
+
"""A foreign-key backed Relation has no stored Links: they are the `ref` values of the
|
|
464
|
+
`from` Kind. Orphans are references to missing Records; gaps are Records without one."""
|
|
465
|
+
r, t = where.records, where.rows.records("t")
|
|
466
|
+
attribute = str(relation.foreign_key_attribute)
|
|
467
|
+
value = r.c["values"][attribute].astext
|
|
468
|
+
target_exists = exists().where(
|
|
469
|
+
t.c.domain_id == r.c.domain_id,
|
|
470
|
+
t.c.branch == r.c.branch,
|
|
471
|
+
t.c.kind_id == relation.to_kind,
|
|
472
|
+
t.c.key == value,
|
|
473
|
+
)
|
|
474
|
+
scope = (_records(where), r.c.kind_id == relation.from_kind)
|
|
475
|
+
links = int(
|
|
476
|
+
(await session.execute(select(func.count()).where(*scope, value.is_not(None)))).scalar_one()
|
|
477
|
+
)
|
|
478
|
+
dangling = (*scope, value.is_not(None), ~target_exists)
|
|
479
|
+
count = int((await session.execute(select(func.count()).where(*dangling))).scalar_one())
|
|
480
|
+
examples: list[LinkRef] = []
|
|
481
|
+
if count and settings.orphan_examples:
|
|
482
|
+
rows = await session.execute(
|
|
483
|
+
select(r.c.key, value)
|
|
484
|
+
.where(*dangling)
|
|
485
|
+
.order_by(r.c.key)
|
|
486
|
+
.limit(settings.orphan_examples)
|
|
487
|
+
)
|
|
488
|
+
examples = [LinkRef(from_key=a, to_key=b) for a, b in rows]
|
|
489
|
+
orphans = OrphanLinks(count=count, missing_from=0, missing_to=count, examples=examples)
|
|
490
|
+
gaps = await _gap_query(session, where, relation.from_kind, value.is_(None), settings)
|
|
491
|
+
return links, orphans, gaps
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def is_timeout(error: BaseException) -> bool:
|
|
495
|
+
"""Whether a database error is a statement timeout (SQLSTATE 57014)."""
|
|
496
|
+
original = getattr(error, "orig", error)
|
|
497
|
+
code = getattr(original, "sqlstate", None) or getattr(original, "pgcode", None)
|
|
498
|
+
return code == "57014" or "statement timeout" in str(original)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
__all__ = [
|
|
502
|
+
"Branch",
|
|
503
|
+
"derived_status",
|
|
504
|
+
"duplicate_attributes",
|
|
505
|
+
"find_duplicates",
|
|
506
|
+
"is_timeout",
|
|
507
|
+
"last_changes",
|
|
508
|
+
"link_counts",
|
|
509
|
+
"record_counts",
|
|
510
|
+
"relation_health",
|
|
511
|
+
"validate_records",
|
|
512
|
+
]
|