lorebase-core 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_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- lorebase_core-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Bounds of compiling and evaluating one expression (the sandbox budget).
|
|
2
|
+
|
|
3
|
+
Every bound is a field with a documented default; services expose the object in their
|
|
4
|
+
settings (``keeper.operations.expression_limits``) so deployments can tune it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ExprLimits(BaseModel):
|
|
13
|
+
"""Budget of one expression. Exceeding a bound is an ``expression.limit_exceeded`` error."""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
16
|
+
|
|
17
|
+
max_source_length: int = Field(
|
|
18
|
+
default=4096, ge=1, description="Characters of expression source accepted by compile."
|
|
19
|
+
)
|
|
20
|
+
max_depth: int = Field(
|
|
21
|
+
default=48,
|
|
22
|
+
ge=1,
|
|
23
|
+
description="Nesting depth of the expression tree (operators, calls, literals).",
|
|
24
|
+
)
|
|
25
|
+
max_steps: int = Field(
|
|
26
|
+
default=10_000,
|
|
27
|
+
ge=1,
|
|
28
|
+
description="Evaluation steps (one per node visited, comprehension iterations "
|
|
29
|
+
"included) of one evaluation.",
|
|
30
|
+
)
|
|
31
|
+
max_duration_ms: int = Field(
|
|
32
|
+
default=250,
|
|
33
|
+
ge=1,
|
|
34
|
+
description="Wall-clock budget of one evaluation; a safety net behind max_steps.",
|
|
35
|
+
)
|
|
36
|
+
max_list_size: int = Field(
|
|
37
|
+
default=10_000, ge=1, description="Elements of a list or map built during evaluation."
|
|
38
|
+
)
|
|
39
|
+
max_string_length: int = Field(
|
|
40
|
+
default=65_536, ge=1, description="Characters of a string built during evaluation."
|
|
41
|
+
)
|
|
42
|
+
max_record_lookups: int = Field(
|
|
43
|
+
default=100,
|
|
44
|
+
ge=0,
|
|
45
|
+
description="Records and link lists read through record(), links() and links.<name> "
|
|
46
|
+
"in one evaluation.",
|
|
47
|
+
)
|
|
48
|
+
decimal_precision: int = Field(
|
|
49
|
+
default=34,
|
|
50
|
+
ge=1,
|
|
51
|
+
description="Significant digits of decimal arithmetic (34 is IEEE 754 decimal128).",
|
|
52
|
+
)
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Operations (design 5.7): compile their expressions, check preconditions, plan their steps.
|
|
2
|
+
|
|
3
|
+
Everything is pure: Records are read through the context's ``RecordResolver`` and the
|
|
4
|
+
result is a list of ``lorebase_core.validate`` data changes for the commit pipeline.
|
|
5
|
+
|
|
6
|
+
Steps run in order. A step sees the Records written by earlier steps (``record($step[0])``
|
|
7
|
+
reads the Record step 0 created), and ``$step[n]`` is the reference of the Record step
|
|
8
|
+
``n`` created, updated or deleted.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from pydantic import JsonValue
|
|
17
|
+
|
|
18
|
+
from lorebase_core.expr.ast import STEP_VARIABLE
|
|
19
|
+
from lorebase_core.expr.compiler import CompiledExpression, compile, run
|
|
20
|
+
from lorebase_core.expr.context import EvaluationContext, activation
|
|
21
|
+
from lorebase_core.expr.environment import PARAMS, PRINCIPAL, Environment, operation_environment
|
|
22
|
+
from lorebase_core.expr.errors import ExprError, ExprErrorCode, PathPart, evaluation_error
|
|
23
|
+
from lorebase_core.expr.limits import ExprLimits
|
|
24
|
+
from lorebase_core.expr.ops import ref_key, require_bool
|
|
25
|
+
from lorebase_core.expr.types import BOOL, ExprType, from_type_spec, ref_of
|
|
26
|
+
from lorebase_core.expr.values import Ref, encode_result
|
|
27
|
+
from lorebase_core.model.attribute import Attribute
|
|
28
|
+
from lorebase_core.model.operation import (
|
|
29
|
+
CreateLinkStep,
|
|
30
|
+
CreateRecordStep,
|
|
31
|
+
DeleteLinkStep,
|
|
32
|
+
DeleteRecordStep,
|
|
33
|
+
Operation,
|
|
34
|
+
OperationStep,
|
|
35
|
+
ReviewMode,
|
|
36
|
+
UpdateRecordStep,
|
|
37
|
+
)
|
|
38
|
+
from lorebase_core.model.record import Link, Record, format_ref, record_key
|
|
39
|
+
from lorebase_core.model.relation import RelationBacking
|
|
40
|
+
from lorebase_core.model.schema import Schema
|
|
41
|
+
from lorebase_core.types import normalize_key
|
|
42
|
+
from lorebase_core.validate.changes import (
|
|
43
|
+
DataChange,
|
|
44
|
+
LinkDelete,
|
|
45
|
+
LinkUpsert,
|
|
46
|
+
RecordDelete,
|
|
47
|
+
RecordUpsert,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
#: Names Operation expressions define themselves; parameters cannot take them.
|
|
51
|
+
RESERVED_PARAMETER_NAMES = frozenset({PARAMS, PRINCIPAL, STEP_VARIABLE})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, eq=False)
|
|
55
|
+
class CompiledStep:
|
|
56
|
+
"""One step with its compiled expressions (``values`` keyed by Attribute id)."""
|
|
57
|
+
|
|
58
|
+
index: int
|
|
59
|
+
step: OperationStep
|
|
60
|
+
environment: Environment
|
|
61
|
+
values: Mapping[str, CompiledExpression] = field(default_factory=dict)
|
|
62
|
+
target: CompiledExpression | None = None
|
|
63
|
+
from_: CompiledExpression | None = None
|
|
64
|
+
to: CompiledExpression | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, eq=False)
|
|
68
|
+
class CompiledOperation:
|
|
69
|
+
schema: Schema
|
|
70
|
+
operation: Operation
|
|
71
|
+
preconditions: tuple[CompiledExpression, ...]
|
|
72
|
+
review_policy: CompiledExpression | None
|
|
73
|
+
steps: tuple[CompiledStep, ...]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class PreconditionFailure:
|
|
78
|
+
"""A precondition that is false (``error`` is ``None``) or could not be evaluated."""
|
|
79
|
+
|
|
80
|
+
index: int
|
|
81
|
+
message: str
|
|
82
|
+
error: ExprError | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class StepPlan:
|
|
87
|
+
"""The data changes of an Operation's steps and the Record each step touched."""
|
|
88
|
+
|
|
89
|
+
changes: list[DataChange] = field(default_factory=list)
|
|
90
|
+
records: list[Ref | None] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _Builder:
|
|
94
|
+
"""Compiles every expression of an Operation, raising or collecting errors."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, schema: Schema, limits: ExprLimits, *, collect: bool) -> None:
|
|
97
|
+
self.schema = schema
|
|
98
|
+
self.limits = limits
|
|
99
|
+
self.errors: list[ExprError] | None = [] if collect else None
|
|
100
|
+
|
|
101
|
+
def fail(self, error: ExprError, path: tuple[PathPart, ...]) -> None:
|
|
102
|
+
error.located(path=path)
|
|
103
|
+
if self.errors is None:
|
|
104
|
+
raise error
|
|
105
|
+
self.errors.append(error)
|
|
106
|
+
|
|
107
|
+
def unknown(self, message: str, path: tuple[PathPart, ...]) -> None:
|
|
108
|
+
"""A dangling reference: schema validation reports it, compiling for use refuses it."""
|
|
109
|
+
if self.errors is None:
|
|
110
|
+
raise ExprError(ExprErrorCode.TYPE, message, path=path)
|
|
111
|
+
|
|
112
|
+
def expression(
|
|
113
|
+
self,
|
|
114
|
+
path: tuple[PathPart, ...],
|
|
115
|
+
source: str,
|
|
116
|
+
environment: Environment,
|
|
117
|
+
expected: ExprType,
|
|
118
|
+
*,
|
|
119
|
+
nullable: bool,
|
|
120
|
+
) -> CompiledExpression | None:
|
|
121
|
+
try:
|
|
122
|
+
return compile(
|
|
123
|
+
source, environment, expected=expected, nullable=nullable, limits=self.limits
|
|
124
|
+
)
|
|
125
|
+
except ExprError as error:
|
|
126
|
+
self.fail(error, path)
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def values(
|
|
130
|
+
self,
|
|
131
|
+
path: tuple[PathPart, ...],
|
|
132
|
+
values: Mapping[str, str],
|
|
133
|
+
attributes: Mapping[str, Attribute],
|
|
134
|
+
environment: Environment,
|
|
135
|
+
) -> dict[str, CompiledExpression]:
|
|
136
|
+
compiled: dict[str, CompiledExpression] = {}
|
|
137
|
+
for attribute_id, source in values.items():
|
|
138
|
+
attribute = attributes.get(attribute_id)
|
|
139
|
+
if attribute is None:
|
|
140
|
+
continue # reported by schema validation
|
|
141
|
+
program = self.expression(
|
|
142
|
+
(*path, "values", attribute_id),
|
|
143
|
+
source,
|
|
144
|
+
environment,
|
|
145
|
+
from_type_spec(attribute.type),
|
|
146
|
+
nullable=True,
|
|
147
|
+
)
|
|
148
|
+
if program is not None:
|
|
149
|
+
compiled[attribute_id] = program
|
|
150
|
+
return compiled
|
|
151
|
+
|
|
152
|
+
def operation(self, operation: Operation) -> CompiledOperation:
|
|
153
|
+
schema = self.schema
|
|
154
|
+
for index, parameter in enumerate(operation.parameters):
|
|
155
|
+
if parameter.name in RESERVED_PARAMETER_NAMES:
|
|
156
|
+
self.fail(
|
|
157
|
+
ExprError(
|
|
158
|
+
ExprErrorCode.TYPE,
|
|
159
|
+
f"parameter name {parameter.name!r} is reserved in expressions",
|
|
160
|
+
),
|
|
161
|
+
("parameters", index, "name"),
|
|
162
|
+
)
|
|
163
|
+
environment = operation_environment(schema, operation)
|
|
164
|
+
preconditions = []
|
|
165
|
+
for index, precondition in enumerate(operation.preconditions):
|
|
166
|
+
program = self.expression(
|
|
167
|
+
("preconditions", index, "expression"),
|
|
168
|
+
precondition.expression,
|
|
169
|
+
environment,
|
|
170
|
+
BOOL,
|
|
171
|
+
nullable=False,
|
|
172
|
+
)
|
|
173
|
+
if program is not None:
|
|
174
|
+
preconditions.append(program)
|
|
175
|
+
review = None
|
|
176
|
+
if operation.review_policy.mode is ReviewMode.EXPRESSION:
|
|
177
|
+
assert operation.review_policy.expression is not None
|
|
178
|
+
review = self.expression(
|
|
179
|
+
("review_policy", "expression"),
|
|
180
|
+
operation.review_policy.expression,
|
|
181
|
+
environment,
|
|
182
|
+
BOOL,
|
|
183
|
+
nullable=False,
|
|
184
|
+
)
|
|
185
|
+
steps = tuple(self.step(operation, index) for index in range(len(operation.steps)))
|
|
186
|
+
return CompiledOperation(schema, operation, tuple(preconditions), review, steps)
|
|
187
|
+
|
|
188
|
+
def step(self, operation: Operation, index: int) -> CompiledStep:
|
|
189
|
+
step = operation.steps[index]
|
|
190
|
+
path: tuple[PathPart, ...] = ("steps", index)
|
|
191
|
+
environment = operation_environment(self.schema, operation, step_index=index)
|
|
192
|
+
if isinstance(step, CreateLinkStep | DeleteLinkStep):
|
|
193
|
+
relation = self.schema.relations.get(step.relation)
|
|
194
|
+
if relation is None or relation.backing is not RelationBacking.EXPLICIT:
|
|
195
|
+
self.unknown(f"Relation {step.relation} cannot be written", (*path, "relation"))
|
|
196
|
+
return CompiledStep(index, step, environment)
|
|
197
|
+
from_ = self.expression(
|
|
198
|
+
(*path, "from"), step.from_, environment, ref_of(relation.from_kind), nullable=False
|
|
199
|
+
)
|
|
200
|
+
to = self.expression(
|
|
201
|
+
(*path, "to"), step.to, environment, ref_of(relation.to_kind), nullable=False
|
|
202
|
+
)
|
|
203
|
+
values = {}
|
|
204
|
+
if isinstance(step, CreateLinkStep):
|
|
205
|
+
values = self.values(path, step.values, relation.attributes, environment)
|
|
206
|
+
return CompiledStep(index, step, environment, values, from_=from_, to=to)
|
|
207
|
+
kind = self.schema.kinds.get(step.kind)
|
|
208
|
+
if kind is None:
|
|
209
|
+
self.unknown(f"unknown Kind {step.kind}", (*path, "kind"))
|
|
210
|
+
return CompiledStep(index, step, environment)
|
|
211
|
+
target = None
|
|
212
|
+
if isinstance(step, UpdateRecordStep | DeleteRecordStep):
|
|
213
|
+
target = self.expression(
|
|
214
|
+
(*path, "target"), step.target, environment, ref_of(kind.id), nullable=False
|
|
215
|
+
)
|
|
216
|
+
values = {}
|
|
217
|
+
if isinstance(step, CreateRecordStep | UpdateRecordStep):
|
|
218
|
+
values = self.values(path, step.values, kind.attributes, environment)
|
|
219
|
+
return CompiledStep(index, step, environment, values, target=target)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def compile_operation(
|
|
223
|
+
schema: Schema, operation: Operation, limits: ExprLimits | None = None
|
|
224
|
+
) -> CompiledOperation:
|
|
225
|
+
"""Compile every expression of ``operation``; raises the first ``ExprError`` (with path)."""
|
|
226
|
+
return _Builder(schema, limits or ExprLimits(), collect=False).operation(operation)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def operation_expression_errors(
|
|
230
|
+
schema: Schema, operation: Operation, limits: ExprLimits | None = None
|
|
231
|
+
) -> list[ExprError]:
|
|
232
|
+
"""Every expression problem of ``operation``; each error's ``path`` is inside the element."""
|
|
233
|
+
builder = _Builder(schema, limits or ExprLimits(), collect=True)
|
|
234
|
+
builder.operation(operation)
|
|
235
|
+
assert builder.errors is not None
|
|
236
|
+
return builder.errors
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Evaluation --------------------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _evaluate(
|
|
243
|
+
compiled: CompiledExpression,
|
|
244
|
+
context: EvaluationContext,
|
|
245
|
+
variables: Mapping[str, object],
|
|
246
|
+
*,
|
|
247
|
+
overlay: Mapping[tuple[str, str], Mapping[str, JsonValue] | None] | None = None,
|
|
248
|
+
) -> object:
|
|
249
|
+
return run(
|
|
250
|
+
compiled,
|
|
251
|
+
variables,
|
|
252
|
+
now=context.now,
|
|
253
|
+
resolver=context.resolver,
|
|
254
|
+
limits=context.limits,
|
|
255
|
+
overlay=overlay,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def evaluate_precondition(
|
|
260
|
+
compiled: CompiledExpression,
|
|
261
|
+
record: Record | None,
|
|
262
|
+
parameters: Mapping[str, JsonValue],
|
|
263
|
+
context: EvaluationContext,
|
|
264
|
+
) -> bool:
|
|
265
|
+
"""Value of a precondition (or any bool expression of an Operation environment).
|
|
266
|
+
|
|
267
|
+
``record`` is the subject Record when the environment has one (``None`` for Operation
|
|
268
|
+
preconditions); ``parameters`` are canonical values by name.
|
|
269
|
+
"""
|
|
270
|
+
variables = activation(
|
|
271
|
+
compiled.environment, record=record, parameters=parameters, principal=context.principal
|
|
272
|
+
)
|
|
273
|
+
return require_bool(_evaluate(compiled, context, variables), "precondition")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def check_preconditions(
|
|
277
|
+
compiled: CompiledOperation, parameters: Mapping[str, JsonValue], context: EvaluationContext
|
|
278
|
+
) -> list[PreconditionFailure]:
|
|
279
|
+
"""Preconditions that are false or fail to evaluate, in declaration order.
|
|
280
|
+
|
|
281
|
+
``RecordNotLoaded`` from the resolver propagates so that the caller can load and retry.
|
|
282
|
+
"""
|
|
283
|
+
failures: list[PreconditionFailure] = []
|
|
284
|
+
for index, (program, precondition) in enumerate(
|
|
285
|
+
zip(compiled.preconditions, compiled.operation.preconditions, strict=True)
|
|
286
|
+
):
|
|
287
|
+
try:
|
|
288
|
+
if not evaluate_precondition(program, None, parameters, context):
|
|
289
|
+
failures.append(PreconditionFailure(index, precondition.message))
|
|
290
|
+
except ExprError as error:
|
|
291
|
+
error.located(path=("preconditions", index, "expression"))
|
|
292
|
+
failures.append(PreconditionFailure(index, precondition.message, error))
|
|
293
|
+
return failures
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def evaluate_review_policy(
|
|
297
|
+
compiled: CompiledOperation, parameters: Mapping[str, JsonValue], context: EvaluationContext
|
|
298
|
+
) -> bool:
|
|
299
|
+
"""Whether the edits go to a Proposal: ``always``, ``never`` or the policy expression."""
|
|
300
|
+
mode = compiled.operation.review_policy.mode
|
|
301
|
+
if mode is ReviewMode.ALWAYS:
|
|
302
|
+
return True
|
|
303
|
+
if compiled.review_policy is None:
|
|
304
|
+
return False
|
|
305
|
+
try:
|
|
306
|
+
return evaluate_precondition(compiled.review_policy, None, parameters, context)
|
|
307
|
+
except ExprError as error:
|
|
308
|
+
raise error.located(path=("review_policy", "expression")) from None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def plan_steps(
|
|
312
|
+
compiled: CompiledOperation, parameters: Mapping[str, JsonValue], context: EvaluationContext
|
|
313
|
+
) -> StepPlan:
|
|
314
|
+
"""Data changes of the Operation's steps.
|
|
315
|
+
|
|
316
|
+
Raises ``ExprError`` (with ``path`` to the failing step expression) when a value cannot
|
|
317
|
+
be computed or does not fit its Attribute, when an updated Record does not exist
|
|
318
|
+
(``record.not_found``) or a created one does (``record.exists``); lets
|
|
319
|
+
``RecordNotLoaded`` through.
|
|
320
|
+
"""
|
|
321
|
+
plan = StepPlan()
|
|
322
|
+
overlay: dict[tuple[str, str], Mapping[str, JsonValue] | None] = {}
|
|
323
|
+
for compiled_step in compiled.steps:
|
|
324
|
+
variables = activation(
|
|
325
|
+
compiled_step.environment,
|
|
326
|
+
parameters=parameters,
|
|
327
|
+
principal=context.principal,
|
|
328
|
+
steps=plan.records,
|
|
329
|
+
)
|
|
330
|
+
planner = _StepPlanner(compiled, compiled_step, context, variables, overlay)
|
|
331
|
+
plan.records.append(planner.run(plan.changes))
|
|
332
|
+
return plan
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class _StepPlanner:
|
|
336
|
+
def __init__(
|
|
337
|
+
self,
|
|
338
|
+
compiled: CompiledOperation,
|
|
339
|
+
step: CompiledStep,
|
|
340
|
+
context: EvaluationContext,
|
|
341
|
+
variables: Mapping[str, object],
|
|
342
|
+
overlay: dict[tuple[str, str], Mapping[str, JsonValue] | None],
|
|
343
|
+
) -> None:
|
|
344
|
+
self.schema = compiled.schema
|
|
345
|
+
self.step = step
|
|
346
|
+
self.context = context
|
|
347
|
+
self.variables = variables
|
|
348
|
+
self.overlay = overlay
|
|
349
|
+
self.path: tuple[PathPart, ...] = ("steps", step.index)
|
|
350
|
+
|
|
351
|
+
def value(self, program: CompiledExpression | None, *where: PathPart) -> object:
|
|
352
|
+
if program is None:
|
|
353
|
+
raise evaluation_error("the step expression did not compile").located(
|
|
354
|
+
path=(*self.path, *where)
|
|
355
|
+
)
|
|
356
|
+
try:
|
|
357
|
+
return _evaluate(program, self.context, self.variables, overlay=self.overlay)
|
|
358
|
+
except ExprError as error:
|
|
359
|
+
raise error.located(path=(*self.path, *where)) from None
|
|
360
|
+
|
|
361
|
+
def ref(self, program: CompiledExpression | None, kind_id: str, where: str) -> Ref:
|
|
362
|
+
value = self.value(program, where)
|
|
363
|
+
assert program is not None # `value` raises without it
|
|
364
|
+
try:
|
|
365
|
+
if isinstance(value, str):
|
|
366
|
+
return Ref(kind_id, normalize_key(value))
|
|
367
|
+
ref = ref_key(value, where)
|
|
368
|
+
except ExprError as error:
|
|
369
|
+
raise error.located(source=program.source, path=(*self.path, where)) from None
|
|
370
|
+
if ref.kind != kind_id:
|
|
371
|
+
raise ExprError(
|
|
372
|
+
ExprErrorCode.VALUE_INVALID,
|
|
373
|
+
f"{where} refers to a {self.kind_name(ref.kind)} Record, expected "
|
|
374
|
+
f"{self.kind_name(kind_id)}",
|
|
375
|
+
path=(*self.path, where),
|
|
376
|
+
)
|
|
377
|
+
return ref
|
|
378
|
+
|
|
379
|
+
def kind_name(self, kind_id: str) -> str:
|
|
380
|
+
kind = self.schema.kinds.get(kind_id)
|
|
381
|
+
return kind.api_name if kind is not None else kind_id
|
|
382
|
+
|
|
383
|
+
def encoded(self, attributes: Mapping[str, Attribute]) -> dict[str, JsonValue]:
|
|
384
|
+
values: dict[str, JsonValue] = {}
|
|
385
|
+
for attribute_id, program in self.step.values.items():
|
|
386
|
+
value = self.value(program, "values", attribute_id)
|
|
387
|
+
try:
|
|
388
|
+
values[attribute_id] = encode_result(attributes[attribute_id].type, value)
|
|
389
|
+
except ExprError as error:
|
|
390
|
+
error.message = f"{attributes[attribute_id].api_name}: {error.message}"
|
|
391
|
+
raise error.located(
|
|
392
|
+
source=program.source, path=(*self.path, "values", attribute_id)
|
|
393
|
+
) from None
|
|
394
|
+
return values
|
|
395
|
+
|
|
396
|
+
def current(self, kind_id: str, key: str) -> Mapping[str, JsonValue] | None:
|
|
397
|
+
if (kind_id, key) in self.overlay:
|
|
398
|
+
return self.overlay[(kind_id, key)]
|
|
399
|
+
if self.context.resolver is None:
|
|
400
|
+
return None
|
|
401
|
+
return self.context.resolver.record(kind_id, key)
|
|
402
|
+
|
|
403
|
+
def run(self, changes: list[DataChange]) -> Ref | None:
|
|
404
|
+
step = self.step.step
|
|
405
|
+
if isinstance(step, CreateLinkStep | DeleteLinkStep):
|
|
406
|
+
relation = self.schema.relations[step.relation]
|
|
407
|
+
from_ref = self.ref(self.step.from_, relation.from_kind, "from")
|
|
408
|
+
to_ref = self.ref(self.step.to, relation.to_kind, "to")
|
|
409
|
+
if isinstance(step, DeleteLinkStep):
|
|
410
|
+
changes.append(
|
|
411
|
+
LinkDelete(relation=relation.id, from_key=from_ref.key, to_key=to_ref.key)
|
|
412
|
+
)
|
|
413
|
+
return None
|
|
414
|
+
link = Link(
|
|
415
|
+
relation=relation.id,
|
|
416
|
+
from_key=from_ref.key,
|
|
417
|
+
to_key=to_ref.key,
|
|
418
|
+
values=self.encoded(relation.attributes),
|
|
419
|
+
)
|
|
420
|
+
changes.append(LinkUpsert(link=link))
|
|
421
|
+
return None
|
|
422
|
+
kind = self.schema.kinds[step.kind]
|
|
423
|
+
if isinstance(step, CreateRecordStep):
|
|
424
|
+
values = self.encoded(kind.attributes)
|
|
425
|
+
key = record_key(kind, values) if kind.has_natural_key else None
|
|
426
|
+
if key is None:
|
|
427
|
+
key = self.mint(kind.has_natural_key)
|
|
428
|
+
elif self.current(kind.id, key) is not None:
|
|
429
|
+
raise ExprError(
|
|
430
|
+
ExprErrorCode.RECORD_EXISTS,
|
|
431
|
+
f"Record {format_ref(kind.api_name, key)} exists already",
|
|
432
|
+
path=self.path,
|
|
433
|
+
)
|
|
434
|
+
changes.append(RecordUpsert(record=Record(kind=kind.id, key=key, values=values)))
|
|
435
|
+
self.overlay[(kind.id, key)] = values
|
|
436
|
+
return Ref(kind.id, key)
|
|
437
|
+
target = self.ref(self.step.target, kind.id, "target")
|
|
438
|
+
if isinstance(step, DeleteRecordStep):
|
|
439
|
+
changes.append(RecordDelete(kind=kind.id, key=target.key))
|
|
440
|
+
self.overlay[(kind.id, target.key)] = None
|
|
441
|
+
return target
|
|
442
|
+
current = self.current(kind.id, target.key)
|
|
443
|
+
if current is None:
|
|
444
|
+
raise ExprError(
|
|
445
|
+
ExprErrorCode.RECORD_NOT_FOUND,
|
|
446
|
+
f"Record {format_ref(kind.api_name, target.key)} does not exist",
|
|
447
|
+
source=self.step.target.source if self.step.target is not None else None,
|
|
448
|
+
path=(*self.path, "target"),
|
|
449
|
+
)
|
|
450
|
+
# derived values the resolver may hold are not Record content (AC-07)
|
|
451
|
+
merged: dict[str, JsonValue] = {
|
|
452
|
+
attribute_id: value
|
|
453
|
+
for attribute_id, value in current.items()
|
|
454
|
+
if attribute_id not in kind.attributes or not kind.attributes[attribute_id].is_derived
|
|
455
|
+
}
|
|
456
|
+
for attribute_id, value in self.encoded(kind.attributes).items():
|
|
457
|
+
if value is None:
|
|
458
|
+
merged.pop(attribute_id, None)
|
|
459
|
+
else:
|
|
460
|
+
merged[attribute_id] = value
|
|
461
|
+
changes.append(RecordUpsert(record=Record(kind=kind.id, key=target.key, values=merged)))
|
|
462
|
+
self.overlay[(kind.id, target.key)] = merged
|
|
463
|
+
return target
|
|
464
|
+
|
|
465
|
+
def mint(self, natural: bool) -> str:
|
|
466
|
+
if natural:
|
|
467
|
+
raise ExprError(
|
|
468
|
+
ExprErrorCode.VALUE_INVALID,
|
|
469
|
+
"a primary key value of the created Record is missing",
|
|
470
|
+
path=self.path,
|
|
471
|
+
)
|
|
472
|
+
mint: Callable[[], str] | None = self.context.mint_key
|
|
473
|
+
if mint is None:
|
|
474
|
+
raise ExprError(
|
|
475
|
+
ExprErrorCode.EVALUATION,
|
|
476
|
+
"the Kind has no natural key and the caller supplied no key minting",
|
|
477
|
+
path=self.path,
|
|
478
|
+
)
|
|
479
|
+
return mint()
|