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,540 @@
|
|
|
1
|
+
"""Derived values in the materialized head (design 5.8, 6.6 step 6, AC-07).
|
|
2
|
+
|
|
3
|
+
`expression` and `aggregate` Derivations compute an Attribute of a Record from its own values
|
|
4
|
+
and from the Records one hop away. Their results live in the `derived` column of the
|
|
5
|
+
materialized row, keyed by Attribute id: never in the tree, so they take no part in hashing or
|
|
6
|
+
merge and are recomputed from merged inputs.
|
|
7
|
+
|
|
8
|
+
`DerivedValues` evaluates the Derivations of a schema in dependency order (a Derivation that
|
|
9
|
+
reads a derived Attribute runs after the one computing it):
|
|
10
|
+
|
|
11
|
+
- `recompute_all` computes every Record of every Kind with a Derivation (rebuilds, schema
|
|
12
|
+
changes, verification);
|
|
13
|
+
- `recompute` computes, after a changeset was applied, the Records it can affect: Records
|
|
14
|
+
written, Records whose Links of a Relation the Derivation reads changed (for a path
|
|
15
|
+
Derivation, a Link of any of its steps), Records linked to written Records or to Records
|
|
16
|
+
whose derived values changed. One Derivation recomputes at most
|
|
17
|
+
`store.materialize.derivation_max_incremental_records` Records this way; beyond that, every
|
|
18
|
+
Record of its Kind.
|
|
19
|
+
|
|
20
|
+
Evaluation is bounded by `schema.expression_limits`; `now()` is the time of the Revision. A
|
|
21
|
+
value that cannot be computed (an evaluation error, a result that does not fit the Attribute)
|
|
22
|
+
is absent and counted in `failures`. With `write=False` nothing is written: results are kept in
|
|
23
|
+
memory (`expected`) for verification. `preview` evaluates one Derivation on a few Records and
|
|
24
|
+
returns the values and the errors without writing, whatever `write` is (candidate Derivations
|
|
25
|
+
that are not stored yet).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
from collections import defaultdict
|
|
32
|
+
from collections.abc import AsyncIterator, Callable, Iterable, Mapping, Sequence
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
from decimal import Decimal
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from pydantic import JsonValue
|
|
39
|
+
|
|
40
|
+
from lorebase_core.expr import (
|
|
41
|
+
CompiledDerivation,
|
|
42
|
+
EvaluationContext,
|
|
43
|
+
ExprError,
|
|
44
|
+
ExprErrorCode,
|
|
45
|
+
ExprLimits,
|
|
46
|
+
LinkedRecord,
|
|
47
|
+
LinksRequest,
|
|
48
|
+
MemoryResolver,
|
|
49
|
+
RecordNotLoaded,
|
|
50
|
+
compile_derivation,
|
|
51
|
+
derivation_inputs,
|
|
52
|
+
evaluate_derivation,
|
|
53
|
+
links_read,
|
|
54
|
+
matches_filter,
|
|
55
|
+
)
|
|
56
|
+
from lorebase_core.expr.values import encode_result, to_runtime
|
|
57
|
+
from lorebase_core.model import (
|
|
58
|
+
AggregateDerivation,
|
|
59
|
+
AggregateFunction,
|
|
60
|
+
Attribute,
|
|
61
|
+
Derivation,
|
|
62
|
+
ExpressionDerivation,
|
|
63
|
+
Kind,
|
|
64
|
+
LinkDirection,
|
|
65
|
+
PathRelation,
|
|
66
|
+
Record,
|
|
67
|
+
Relation,
|
|
68
|
+
RelationBacking,
|
|
69
|
+
Schema,
|
|
70
|
+
)
|
|
71
|
+
from lorebase_core.types import AttributeType
|
|
72
|
+
from lorebase_store.bulk import chunks
|
|
73
|
+
from lorebase_store.materialize.links import (
|
|
74
|
+
links_of,
|
|
75
|
+
path_affected,
|
|
76
|
+
path_links_of,
|
|
77
|
+
reverse,
|
|
78
|
+
reverse_steps,
|
|
79
|
+
)
|
|
80
|
+
from lorebase_store.repositories.materialized import MaterializedRepository, RecordRow
|
|
81
|
+
|
|
82
|
+
logger = logging.getLogger("lorebase.store.derived")
|
|
83
|
+
|
|
84
|
+
RecordKey = tuple[str, str]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, eq=False)
|
|
88
|
+
class Hop:
|
|
89
|
+
"""A Relation traversed from the Derivation's Kind (`relation.endpoint(direction)`);
|
|
90
|
+
hashed by identity (one object per Derivation step). For a path Derivation, `relation`
|
|
91
|
+
is its virtual Relation and `path` the path."""
|
|
92
|
+
|
|
93
|
+
relation: Relation
|
|
94
|
+
direction: LinkDirection
|
|
95
|
+
path: PathRelation | None = None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def near_kind(self) -> str:
|
|
99
|
+
return self.relation.endpoint(self.direction)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def far_kind(self) -> str:
|
|
103
|
+
return self.relation.far_endpoint(self.direction)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class DerivationStep:
|
|
108
|
+
"""One Derivation ready to evaluate: its target, compiled expressions and the Relations
|
|
109
|
+
whose Links and far Records it reads."""
|
|
110
|
+
|
|
111
|
+
derivation: Derivation
|
|
112
|
+
kind: Kind
|
|
113
|
+
attribute: Attribute
|
|
114
|
+
compiled: CompiledDerivation
|
|
115
|
+
hops: tuple[Hop, ...]
|
|
116
|
+
inputs: frozenset[str]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def plan_derivations(schema: Schema, limits: ExprLimits) -> list[DerivationStep]:
|
|
120
|
+
"""The `expression` and `aggregate` Derivations of `schema` in dependency order.
|
|
121
|
+
|
|
122
|
+
A Derivation that does not compile (only possible for schemas committed with other rules)
|
|
123
|
+
is left out and logged.
|
|
124
|
+
"""
|
|
125
|
+
steps: dict[str, DerivationStep] = {}
|
|
126
|
+
for derivation in sorted(schema.derivations.values(), key=lambda d: d.id):
|
|
127
|
+
definition = derivation.definition
|
|
128
|
+
if not isinstance(definition, ExpressionDerivation | AggregateDerivation):
|
|
129
|
+
continue
|
|
130
|
+
kind = schema.kinds.get(definition.kind)
|
|
131
|
+
attribute = None if kind is None else kind.attributes.get(definition.attribute)
|
|
132
|
+
if kind is None or attribute is None:
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
compiled = compile_derivation(schema, derivation, limits)
|
|
136
|
+
except ExprError as error:
|
|
137
|
+
logger.warning(
|
|
138
|
+
"derivation skipped",
|
|
139
|
+
extra={"derivation": derivation.id, "error": error.describe()},
|
|
140
|
+
)
|
|
141
|
+
continue
|
|
142
|
+
hops: list[Hop] = []
|
|
143
|
+
paths = schema.path_relations()
|
|
144
|
+
if isinstance(definition, AggregateDerivation):
|
|
145
|
+
relation = schema.link_relation(definition.relation)
|
|
146
|
+
if relation is None:
|
|
147
|
+
continue
|
|
148
|
+
hops.append(Hop(relation, definition.direction, paths.get(relation.id)))
|
|
149
|
+
elif compiled.expression is not None:
|
|
150
|
+
hops.extend(
|
|
151
|
+
Hop(f.relation, f.direction, paths.get(f.relation.id))
|
|
152
|
+
for f in links_read(compiled.expression)
|
|
153
|
+
)
|
|
154
|
+
steps[attribute.id] = DerivationStep(
|
|
155
|
+
derivation,
|
|
156
|
+
kind,
|
|
157
|
+
attribute,
|
|
158
|
+
compiled,
|
|
159
|
+
tuple(hops),
|
|
160
|
+
derivation_inputs(schema, derivation, limits),
|
|
161
|
+
)
|
|
162
|
+
ordered: list[DerivationStep] = []
|
|
163
|
+
done: set[str] = set()
|
|
164
|
+
visiting: set[str] = set()
|
|
165
|
+
|
|
166
|
+
def visit(attribute_id: str) -> None:
|
|
167
|
+
if attribute_id in done or attribute_id in visiting:
|
|
168
|
+
return # a cycle is reported by schema validation; keep a stable order
|
|
169
|
+
visiting.add(attribute_id)
|
|
170
|
+
step = steps[attribute_id]
|
|
171
|
+
for dependency in sorted(step.inputs):
|
|
172
|
+
if dependency in steps:
|
|
173
|
+
visit(dependency)
|
|
174
|
+
visiting.discard(attribute_id)
|
|
175
|
+
done.add(attribute_id)
|
|
176
|
+
ordered.append(step)
|
|
177
|
+
|
|
178
|
+
for attribute_id in sorted(steps):
|
|
179
|
+
visit(attribute_id)
|
|
180
|
+
return ordered
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class AppliedChanges:
|
|
185
|
+
"""What a changeset did to the materialized head, as derived values need it.
|
|
186
|
+
|
|
187
|
+
`written` and `removed` are Record keys by Kind id; `links` the `(from, to)` keys of
|
|
188
|
+
changed Links by Relation id; `previous` the values of written or removed Records before
|
|
189
|
+
the change (`None` for new Records).
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
written: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
|
|
193
|
+
removed: dict[str, set[str]] = field(default_factory=lambda: defaultdict(set))
|
|
194
|
+
links: dict[str, set[tuple[str, str]]] = field(default_factory=lambda: defaultdict(set))
|
|
195
|
+
previous: dict[RecordKey, Mapping[str, Any] | None] = field(default_factory=dict)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True, slots=True)
|
|
199
|
+
class PreviewedValue:
|
|
200
|
+
"""The value a Derivation computes for one Record: `value` is `None` when there is none,
|
|
201
|
+
`error` says why the evaluation failed."""
|
|
202
|
+
|
|
203
|
+
key: str
|
|
204
|
+
title: str | None
|
|
205
|
+
value: JsonValue
|
|
206
|
+
error: ExprError | None = None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class _Loaded:
|
|
211
|
+
"""Inputs of one batch: Records of the Derivation's Kind and, per hop, the Links and far
|
|
212
|
+
Records."""
|
|
213
|
+
|
|
214
|
+
rows: dict[str, RecordRow]
|
|
215
|
+
links: dict[Hop, dict[str, list[LinkedRecord]]]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class DerivedValues:
|
|
219
|
+
"""Evaluates the Derivations of one schema on one branch (see the module docstring)."""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
repository: MaterializedRepository,
|
|
224
|
+
schema: Schema,
|
|
225
|
+
*,
|
|
226
|
+
limits: ExprLimits,
|
|
227
|
+
now: datetime,
|
|
228
|
+
batch: int,
|
|
229
|
+
max_incremental: int,
|
|
230
|
+
fanout: int,
|
|
231
|
+
write: bool = True,
|
|
232
|
+
) -> None:
|
|
233
|
+
self.repository = repository
|
|
234
|
+
self.schema = schema
|
|
235
|
+
self.limits = limits
|
|
236
|
+
self.now = now
|
|
237
|
+
self.batch = batch
|
|
238
|
+
self.fanout = fanout
|
|
239
|
+
self.max_incremental = max_incremental
|
|
240
|
+
self.write = write
|
|
241
|
+
self.steps = plan_derivations(schema, limits)
|
|
242
|
+
self.expected: dict[RecordKey, dict[str, JsonValue]] = {}
|
|
243
|
+
self.failures = 0
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def kinds(self) -> set[str]:
|
|
247
|
+
"""Kinds with at least one Derivation."""
|
|
248
|
+
return {step.kind.id for step in self.steps}
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def reads_paths(self) -> bool:
|
|
252
|
+
"""Whether a Derivation reads a path Derivation (its values follow the fan-out)."""
|
|
253
|
+
return any(hop.path is not None for step in self.steps for hop in step.hops)
|
|
254
|
+
|
|
255
|
+
def searchable(self, kind_id: str) -> bool:
|
|
256
|
+
"""Whether a derived Attribute of the Kind is in its search vector."""
|
|
257
|
+
return any(s.kind.id == kind_id and s.attribute.searchable for s in self.steps)
|
|
258
|
+
|
|
259
|
+
# Entry points -----------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
async def recompute_all(self) -> dict[str, set[str]]:
|
|
262
|
+
"""Recompute every derived value; returns the keys whose values changed, by Kind."""
|
|
263
|
+
changed: dict[str, set[str]] = defaultdict(set)
|
|
264
|
+
for step in self.steps:
|
|
265
|
+
changed[step.kind.id] |= await self._run(step, None)
|
|
266
|
+
return changed
|
|
267
|
+
|
|
268
|
+
async def prune(self) -> None:
|
|
269
|
+
"""Drop stored derived values of Attributes that are no longer derived."""
|
|
270
|
+
keep: dict[str, list[str]] = defaultdict(list)
|
|
271
|
+
for step in self.steps:
|
|
272
|
+
keep[step.kind.id].append(step.attribute.id)
|
|
273
|
+
for kind_id, attributes in sorted(keep.items()):
|
|
274
|
+
await self.repository.prune_derived(kind_id, attributes)
|
|
275
|
+
await self.repository.clear_derived(sorted(keep))
|
|
276
|
+
|
|
277
|
+
async def recompute_step(self, step: DerivationStep, keys: Iterable[str]) -> set[str]:
|
|
278
|
+
"""Recompute one Derivation for the Records `keys` of its Kind; returns the keys
|
|
279
|
+
whose value changed. Running every step in order over every key of its Kind is
|
|
280
|
+
`recompute_all`, in pieces a background job can commit one by one."""
|
|
281
|
+
return await self._run(step, keys)
|
|
282
|
+
|
|
283
|
+
async def recompute(self, applied: AppliedChanges) -> dict[str, set[str]]:
|
|
284
|
+
"""Recompute the derived values a changeset can affect; returns changed keys by Kind."""
|
|
285
|
+
changed: dict[str, set[str]] = defaultdict(set)
|
|
286
|
+
for step in self.steps:
|
|
287
|
+
kind_id = step.kind.id
|
|
288
|
+
keys = set(applied.written.get(kind_id, ())) | changed[kind_id]
|
|
289
|
+
for hop in step.hops:
|
|
290
|
+
keys |= await self._affected_through(hop, applied, changed)
|
|
291
|
+
keys -= applied.removed.get(kind_id, set())
|
|
292
|
+
if not keys:
|
|
293
|
+
continue
|
|
294
|
+
target = None if len(keys) > self.max_incremental else keys
|
|
295
|
+
changed[kind_id] |= await self._run(step, target)
|
|
296
|
+
return changed
|
|
297
|
+
|
|
298
|
+
async def preview(
|
|
299
|
+
self, attribute_id: str, *, keys: Sequence[str] | None, limit: int
|
|
300
|
+
) -> list[PreviewedValue]:
|
|
301
|
+
"""The values the Derivation of `attribute_id` computes for `keys` (the first `limit`
|
|
302
|
+
Records of its Kind by key when `None`), from the stored and derived values of the
|
|
303
|
+
branch. Nothing is written; Records that do not exist are left out."""
|
|
304
|
+
step = next((s for s in self.steps if s.attribute.id == attribute_id), None)
|
|
305
|
+
if step is None:
|
|
306
|
+
raise ExprError(ExprErrorCode.EVALUATION, "the Derivation cannot be evaluated")
|
|
307
|
+
if keys is None:
|
|
308
|
+
rows = await self.repository.record_page(step.kind.id, after=None, limit=limit)
|
|
309
|
+
batch = [row.key for row in rows]
|
|
310
|
+
else:
|
|
311
|
+
batch = list(dict.fromkeys(keys))[:limit]
|
|
312
|
+
loaded = await self._load(step, batch)
|
|
313
|
+
previewed: list[PreviewedValue] = []
|
|
314
|
+
for key in sorted(loaded.rows):
|
|
315
|
+
row = loaded.rows[key]
|
|
316
|
+
try:
|
|
317
|
+
previewed.append(
|
|
318
|
+
PreviewedValue(key, row.title, await self._compute(step, row, loaded))
|
|
319
|
+
)
|
|
320
|
+
except ExprError as error:
|
|
321
|
+
previewed.append(PreviewedValue(key, row.title, None, error))
|
|
322
|
+
return previewed
|
|
323
|
+
|
|
324
|
+
# Affected Records ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
async def _affected_through(
|
|
327
|
+
self, hop: Hop, applied: AppliedChanges, changed: Mapping[str, set[str]]
|
|
328
|
+
) -> set[str]:
|
|
329
|
+
"""Keys of the hop's near Kind whose Links or far Records changed. For a path, the
|
|
330
|
+
Records whose pairs a change of any step can change (`path_affected`)."""
|
|
331
|
+
relation = hop.relation
|
|
332
|
+
forward = hop.direction is LinkDirection.FORWARD
|
|
333
|
+
if hop.path is not None:
|
|
334
|
+
steps = hop.path.steps if forward else reverse_steps(hop.path.steps)
|
|
335
|
+
keys = await path_affected(
|
|
336
|
+
self.repository, steps, applied, fanout=self.fanout, inverse=not forward
|
|
337
|
+
)
|
|
338
|
+
else:
|
|
339
|
+
keys = {f if forward else t for f, t in applied.links.get(relation.id, ())}
|
|
340
|
+
far = hop.far_kind
|
|
341
|
+
far_keys = (
|
|
342
|
+
set(applied.written.get(far, ()))
|
|
343
|
+
| set(applied.removed.get(far, ()))
|
|
344
|
+
| set(changed.get(far, ()))
|
|
345
|
+
)
|
|
346
|
+
if relation.backing is RelationBacking.FOREIGN_KEY:
|
|
347
|
+
keys |= self._foreign_key_targets(hop, applied)
|
|
348
|
+
if far_keys:
|
|
349
|
+
back = Hop(relation, reverse(hop.direction), hop.path)
|
|
350
|
+
for near_keys in (await self._links(back, sorted(far_keys))).values():
|
|
351
|
+
keys.update(far_key for far_key, _ in near_keys)
|
|
352
|
+
return keys
|
|
353
|
+
|
|
354
|
+
def _foreign_key_targets(self, hop: Hop, applied: AppliedChanges) -> set[str]:
|
|
355
|
+
"""Targets, before and after the change, of written or removed `from` Records of a
|
|
356
|
+
foreign-key Relation read from its `to` end."""
|
|
357
|
+
relation = hop.relation
|
|
358
|
+
attribute = relation.foreign_key_attribute
|
|
359
|
+
if hop.direction is LinkDirection.FORWARD or attribute is None:
|
|
360
|
+
return set() # the near Records hold the foreign key: they were written
|
|
361
|
+
targets: set[str] = set()
|
|
362
|
+
source = relation.from_kind
|
|
363
|
+
for key in set(applied.written.get(source, ())) | set(applied.removed.get(source, ())):
|
|
364
|
+
before = applied.previous.get((source, key))
|
|
365
|
+
if before is not None and isinstance(before.get(attribute), str):
|
|
366
|
+
targets.add(before[attribute])
|
|
367
|
+
return targets
|
|
368
|
+
|
|
369
|
+
# Loading ----------------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
async def _links(self, hop: Hop, keys: Sequence[str]) -> dict[str, list[tuple[str, Any]]]:
|
|
372
|
+
"""`near key -> [(far key, link values)]` of the hop for `keys`."""
|
|
373
|
+
if hop.path is not None:
|
|
374
|
+
return await path_links_of(self.repository, hop.path, hop.direction, keys, self.fanout)
|
|
375
|
+
return await links_of(self.repository, hop.relation, hop.direction, keys)
|
|
376
|
+
|
|
377
|
+
def _inputs(self, kind_id: str, row: RecordRow) -> dict[str, Any]:
|
|
378
|
+
"""Stored and derived values of a Record; derived ones from `expected` when not
|
|
379
|
+
writing (verification compares stored values with recomputed ones)."""
|
|
380
|
+
if self.write:
|
|
381
|
+
return row.inputs
|
|
382
|
+
return {**row.values, **self.expected.get((kind_id, row.key), {})}
|
|
383
|
+
|
|
384
|
+
async def _linked(self, hop: Hop, keys: Sequence[str]) -> dict[str, list[LinkedRecord]]:
|
|
385
|
+
links = await self._links(hop, keys)
|
|
386
|
+
far_keys = sorted({far for items in links.values() for far, _ in items})
|
|
387
|
+
rows = await self.repository.records(hop.far_kind, far_keys) if far_keys else {}
|
|
388
|
+
return {
|
|
389
|
+
near: [
|
|
390
|
+
LinkedRecord(far, self._inputs(hop.far_kind, rows[far]), values)
|
|
391
|
+
for far, values in items
|
|
392
|
+
if far in rows
|
|
393
|
+
]
|
|
394
|
+
for near, items in links.items()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async def _load(self, step: DerivationStep, keys: Sequence[str]) -> _Loaded:
|
|
398
|
+
rows = await self.repository.records(step.kind.id, keys)
|
|
399
|
+
present = sorted(rows)
|
|
400
|
+
links = {hop: await self._linked(hop, present) for hop in step.hops}
|
|
401
|
+
return _Loaded(rows, links)
|
|
402
|
+
|
|
403
|
+
# Evaluation -------------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
async def _run(self, step: DerivationStep, keys: Iterable[str] | None) -> set[str]:
|
|
406
|
+
"""Recompute one Derivation for `keys` (every Record of its Kind when `None`)."""
|
|
407
|
+
changed: set[str] = set()
|
|
408
|
+
async for batch in self._batches(step.kind.id, keys):
|
|
409
|
+
loaded = await self._load(step, batch)
|
|
410
|
+
updates: list[tuple[str, JsonValue]] = []
|
|
411
|
+
for key in batch:
|
|
412
|
+
row = loaded.rows.get(key)
|
|
413
|
+
if row is None:
|
|
414
|
+
continue
|
|
415
|
+
value = await self._value(step, row, loaded)
|
|
416
|
+
current = self._inputs(step.kind.id, row).get(step.attribute.id)
|
|
417
|
+
if not self.write:
|
|
418
|
+
record = self.expected.setdefault((step.kind.id, key), {})
|
|
419
|
+
if value is not None:
|
|
420
|
+
record[step.attribute.id] = value
|
|
421
|
+
if value != current:
|
|
422
|
+
updates.append((key, value))
|
|
423
|
+
changed.add(key)
|
|
424
|
+
if self.write and updates:
|
|
425
|
+
await self.repository.set_derived(step.kind.id, step.attribute.id, updates)
|
|
426
|
+
return changed
|
|
427
|
+
|
|
428
|
+
async def _batches(self, kind_id: str, keys: Iterable[str] | None) -> AsyncIterator[list[str]]:
|
|
429
|
+
if keys is not None:
|
|
430
|
+
for chunk in chunks(sorted(keys), self.batch):
|
|
431
|
+
yield list(chunk)
|
|
432
|
+
return
|
|
433
|
+
async for rows in self.repository.stream_records(kind_id, batch=self.batch):
|
|
434
|
+
yield [row.key for row in rows]
|
|
435
|
+
|
|
436
|
+
async def _value(self, step: DerivationStep, row: RecordRow, loaded: _Loaded) -> JsonValue:
|
|
437
|
+
try:
|
|
438
|
+
return await self._compute(step, row, loaded)
|
|
439
|
+
except ExprError as error:
|
|
440
|
+
self.failures += 1
|
|
441
|
+
logger.debug(
|
|
442
|
+
"derived value not computed",
|
|
443
|
+
extra={"derivation": step.derivation.id, "key": row.key, "error": str(error)},
|
|
444
|
+
)
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
async def _compute(self, step: DerivationStep, row: RecordRow, loaded: _Loaded) -> JsonValue:
|
|
448
|
+
"""The value of one Record; raises `ExprError` when it cannot be computed."""
|
|
449
|
+
resolver = MemoryResolver()
|
|
450
|
+
for hop, linked in loaded.links.items():
|
|
451
|
+
resolver.links[(hop.relation.id, hop.direction, row.key)] = linked.get(row.key, [])
|
|
452
|
+
context = EvaluationContext(now=self.now, resolver=resolver, limits=self.limits)
|
|
453
|
+
record = Record(kind=step.kind.id, key=row.key, values=self._inputs(step.kind.id, row))
|
|
454
|
+
compiled = step.compiled
|
|
455
|
+
definition = step.derivation.definition
|
|
456
|
+
if isinstance(definition, ExpressionDerivation):
|
|
457
|
+
return await self._loading(
|
|
458
|
+
lambda: evaluate_derivation(compiled, record, context), resolver
|
|
459
|
+
)
|
|
460
|
+
assert isinstance(definition, AggregateDerivation)
|
|
461
|
+
hop = step.hops[0]
|
|
462
|
+
far = self.schema.kinds[hop.far_kind]
|
|
463
|
+
members = []
|
|
464
|
+
for item in loaded.links[hop].get(row.key, []):
|
|
465
|
+
candidate = Record(kind=far.id, key=item.key, values=dict(item.values))
|
|
466
|
+
|
|
467
|
+
def passes(record: Record = candidate) -> bool:
|
|
468
|
+
return matches_filter(compiled, record, context)
|
|
469
|
+
|
|
470
|
+
if await self._loading(passes, resolver):
|
|
471
|
+
members.append(item)
|
|
472
|
+
return aggregate(step.attribute, definition, far, members)
|
|
473
|
+
|
|
474
|
+
async def _loading[T](self, compute: Callable[[], T], resolver: MemoryResolver) -> T:
|
|
475
|
+
"""Run an evaluation, loading the Link lists it asks for (filters reading Links of
|
|
476
|
+
aggregated Records), then run it again."""
|
|
477
|
+
for _ in range(self.limits.max_record_lookups + 1):
|
|
478
|
+
try:
|
|
479
|
+
return compute()
|
|
480
|
+
except RecordNotLoaded as missing:
|
|
481
|
+
request = missing.request
|
|
482
|
+
if not isinstance(request, LinksRequest):
|
|
483
|
+
raise ExprError(
|
|
484
|
+
ExprErrorCode.EVALUATION, "Records cannot be read by a Derivation"
|
|
485
|
+
) from None
|
|
486
|
+
relation = self.schema.link_relation(request.relation)
|
|
487
|
+
if relation is None:
|
|
488
|
+
raise ExprError(
|
|
489
|
+
ExprErrorCode.EVALUATION, f"unknown Relation {request.relation}"
|
|
490
|
+
) from None
|
|
491
|
+
path = self.schema.path_relations().get(relation.id)
|
|
492
|
+
hop = Hop(relation, request.direction, path)
|
|
493
|
+
linked = await self._linked(hop, [request.key])
|
|
494
|
+
resolver.links[(request.relation, request.direction, request.key)] = linked.get(
|
|
495
|
+
request.key, []
|
|
496
|
+
)
|
|
497
|
+
return compute()
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def aggregate(
|
|
501
|
+
target: Attribute,
|
|
502
|
+
definition: AggregateDerivation,
|
|
503
|
+
far: Kind,
|
|
504
|
+
members: Sequence[LinkedRecord],
|
|
505
|
+
) -> JsonValue:
|
|
506
|
+
"""The canonical value of an aggregate over the far Records that passed the filter.
|
|
507
|
+
|
|
508
|
+
`count` counts them; `sum` adds the source values (0 when none); `min`, `max` and `avg`
|
|
509
|
+
have no value without members. Decimal results are rounded to the target's scale.
|
|
510
|
+
"""
|
|
511
|
+
if definition.function is AggregateFunction.COUNT:
|
|
512
|
+
return encode_result(target.type, len(members))
|
|
513
|
+
assert definition.source_attribute is not None
|
|
514
|
+
source = far.attributes[definition.source_attribute]
|
|
515
|
+
values: list[Any] = [
|
|
516
|
+
value
|
|
517
|
+
for member in members
|
|
518
|
+
if (value := to_runtime(source.type, member.values.get(source.id))) is not None
|
|
519
|
+
]
|
|
520
|
+
result: object
|
|
521
|
+
if definition.function is AggregateFunction.SUM:
|
|
522
|
+
start: Any = Decimal(0) if source.type.name is AttributeType.DECIMAL else 0
|
|
523
|
+
result = sum(values, start)
|
|
524
|
+
elif not values:
|
|
525
|
+
return None
|
|
526
|
+
elif definition.function is AggregateFunction.MIN:
|
|
527
|
+
result = min(values)
|
|
528
|
+
elif definition.function is AggregateFunction.MAX:
|
|
529
|
+
result = max(values)
|
|
530
|
+
else:
|
|
531
|
+
total = sum(Decimal(str(v)) for v in values)
|
|
532
|
+
result = total / len(values)
|
|
533
|
+
if target.type.name is AttributeType.DECIMAL and isinstance(result, int | float | Decimal):
|
|
534
|
+
assert target.type.scale is not None
|
|
535
|
+
result = Decimal(str(result)).quantize(Decimal(1).scaleb(-target.type.scale))
|
|
536
|
+
elif target.type.name is AttributeType.INTEGER and isinstance(result, Decimal):
|
|
537
|
+
if result != result.to_integral_value():
|
|
538
|
+
raise ExprError(ExprErrorCode.VALUE_INVALID, f"{result} is not an integer")
|
|
539
|
+
result = int(result)
|
|
540
|
+
return encode_result(target.type, result)
|