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,337 @@
|
|
|
1
|
+
"""Which sensitivity tags a value carries, through what computes it (design 10.2, 13.5).
|
|
2
|
+
|
|
3
|
+
A value computed from sensitive values tells about them. Derivations and the step values of
|
|
4
|
+
Operations read Attributes; what they compute must carry every tag of what they may read, and
|
|
5
|
+
never read a vault Attribute (whose stored values are references). The reads of an
|
|
6
|
+
Operation expression are resolved against the Kind the type checker infers for each Record
|
|
7
|
+
read; fields of untyped (`dyn`) values are matched by name, conservatively (a superset,
|
|
8
|
+
never fewer).
|
|
9
|
+
|
|
10
|
+
`effective_tags` gives, per Attribute id, the tags of a schema united with tags from elsewhere
|
|
11
|
+
(the default branch's schema) and carried through Derivations: what a reader must be cleared
|
|
12
|
+
for, whatever the schema read says. `tag_origins` tells which Kinds each tag comes from
|
|
13
|
+
(`effective_origins`, for every Attribute at once). `rebased_tags` stores the tags: it gives a
|
|
14
|
+
branch forked before the default branch tagged an Attribute the tags the default branch has.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterable, Mapping
|
|
20
|
+
|
|
21
|
+
from lorebase_core.expr.ast import (
|
|
22
|
+
STEP_SYNTAX,
|
|
23
|
+
STEP_VARIABLE,
|
|
24
|
+
Call,
|
|
25
|
+
Ident,
|
|
26
|
+
Index,
|
|
27
|
+
Literal,
|
|
28
|
+
Node,
|
|
29
|
+
Select,
|
|
30
|
+
parse,
|
|
31
|
+
)
|
|
32
|
+
from lorebase_core.expr.checker import Checker
|
|
33
|
+
from lorebase_core.expr.derivations import _nodes, derivation_reads
|
|
34
|
+
from lorebase_core.expr.environment import PARAMS, PRINCIPAL, operation_environment
|
|
35
|
+
from lorebase_core.expr.errors import ExprError
|
|
36
|
+
from lorebase_core.expr.limits import ExprLimits
|
|
37
|
+
from lorebase_core.expr.types import ExprType, TypeKind
|
|
38
|
+
from lorebase_core.model.attribute import Attribute, Sensitivity
|
|
39
|
+
from lorebase_core.model.kind import Kind
|
|
40
|
+
from lorebase_core.model.operation import Operation
|
|
41
|
+
from lorebase_core.model.schema import Schema
|
|
42
|
+
|
|
43
|
+
Tags = frozenset[Sensitivity]
|
|
44
|
+
NO_TAGS: Tags = frozenset()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def schema_tags(schema: Schema | None) -> dict[str, Tags]:
|
|
48
|
+
"""Attribute id -> sensitivity tags, for the Attributes of `schema` that have any."""
|
|
49
|
+
if schema is None:
|
|
50
|
+
return {}
|
|
51
|
+
return {a.id: frozenset(a.sensitivity) for a in all_attributes(schema) if a.sensitivity}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def all_attributes(schema: Schema) -> list[Attribute]:
|
|
55
|
+
"""Attributes of every Kind and Relation."""
|
|
56
|
+
found = [a for kind in schema.kinds.values() for a in kind.attributes.values()]
|
|
57
|
+
found += [a for rel in schema.relations.values() for a in rel.attributes.values()]
|
|
58
|
+
return found
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def lowered_tags(old: Schema, new: Schema) -> Tags:
|
|
62
|
+
"""Tags `new` drops from Attributes of `old`: removed from an Attribute, or with the
|
|
63
|
+
Attribute, its Kind or its Relation."""
|
|
64
|
+
after = schema_tags(new)
|
|
65
|
+
lowered: set[Sensitivity] = set()
|
|
66
|
+
for attribute_id, tags in schema_tags(old).items():
|
|
67
|
+
lowered |= tags - after.get(attribute_id, NO_TAGS)
|
|
68
|
+
return frozenset(lowered)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def raised_tags(old: Schema, new: Schema) -> Tags:
|
|
72
|
+
"""Tags `new` gives an Attribute that `old` does not give it, Attributes new to `new`
|
|
73
|
+
included: another schema (a branch) may hold the same Attribute untagged."""
|
|
74
|
+
before = schema_tags(old)
|
|
75
|
+
raised: set[Sensitivity] = set()
|
|
76
|
+
for attribute_id, tags in schema_tags(new).items():
|
|
77
|
+
raised |= tags - before.get(attribute_id, NO_TAGS)
|
|
78
|
+
return frozenset(raised)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def rebased_tags(
|
|
82
|
+
schema: Schema, extra: Mapping[str, Tags], limits: ExprLimits | None = None
|
|
83
|
+
) -> Schema:
|
|
84
|
+
"""`schema` with the tags of `extra` (the default branch's, by Attribute id) on the
|
|
85
|
+
Attributes it has, and on its derived Attributes the tags of what their Derivations may
|
|
86
|
+
read (`effective_tags`). Tags are only ever added: a schema to store on a branch forked
|
|
87
|
+
before the default branch tagged an Attribute."""
|
|
88
|
+
return with_tags(schema, effective_tags(schema, extra, limits))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def with_tags(schema: Schema, extra: Mapping[str, Tags]) -> Schema:
|
|
92
|
+
"""`schema` with each Attribute's tags united with `extra[attribute id]`: for the checks
|
|
93
|
+
that depend on tags (validation), never stored."""
|
|
94
|
+
if not extra:
|
|
95
|
+
return schema
|
|
96
|
+
|
|
97
|
+
def raised(attributes: Mapping[str, Attribute]) -> dict[str, Attribute]:
|
|
98
|
+
return {
|
|
99
|
+
k: a
|
|
100
|
+
if not (extra.get(k, NO_TAGS) - set(a.sensitivity))
|
|
101
|
+
else a.model_copy(update={"sensitivity": tuple(sorted({*a.sensitivity, *extra[k]}))})
|
|
102
|
+
for k, a in attributes.items()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
kinds = {
|
|
106
|
+
k: kind.model_copy(update={"attributes": raised(kind.attributes)})
|
|
107
|
+
for k, kind in schema.kinds.items()
|
|
108
|
+
}
|
|
109
|
+
relations = {
|
|
110
|
+
k: rel.model_copy(update={"attributes": raised(rel.attributes)})
|
|
111
|
+
for k, rel in schema.relations.items()
|
|
112
|
+
}
|
|
113
|
+
return schema.model_copy(update={"kinds": kinds, "relations": relations})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def effective_tags(
|
|
117
|
+
schema: Schema, extra: Mapping[str, Tags] | None = None, limits: ExprLimits | None = None
|
|
118
|
+
) -> dict[str, Tags]:
|
|
119
|
+
"""Attribute id -> the tags a reader must be cleared for: the schema's and `extra`'s, and
|
|
120
|
+
for a derived Attribute those of everything its Derivation may read, transitively."""
|
|
121
|
+
tags: dict[str, set[Sensitivity]] = {
|
|
122
|
+
a.id: {*a.sensitivity, *(extra or {}).get(a.id, NO_TAGS)} for a in all_attributes(schema)
|
|
123
|
+
}
|
|
124
|
+
reads = {
|
|
125
|
+
derivation.target[1]: [a for _, a in derivation_reads(schema, derivation, limits)]
|
|
126
|
+
for derivation in schema.derivations.values()
|
|
127
|
+
if derivation.target is not None
|
|
128
|
+
}
|
|
129
|
+
changed = True
|
|
130
|
+
while changed:
|
|
131
|
+
changed = False
|
|
132
|
+
for target, inputs in reads.items():
|
|
133
|
+
if target not in tags:
|
|
134
|
+
continue
|
|
135
|
+
gained = set().union(*(tags.get(i, set()) for i in inputs)) - tags[target]
|
|
136
|
+
if gained:
|
|
137
|
+
tags[target] |= gained
|
|
138
|
+
changed = True
|
|
139
|
+
return {k: frozenset(v) for k, v in tags.items() if v}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
Origins = dict[Sensitivity, frozenset[str]]
|
|
143
|
+
"""Tag -> ids of the Kinds holding the Attributes the tag comes from."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def effective_origins(
|
|
147
|
+
schema: Schema, extra: Mapping[str, Tags] | None = None, limits: ExprLimits | None = None
|
|
148
|
+
) -> dict[str, Origins]:
|
|
149
|
+
"""Attribute id -> `tag_origins`, for every Attribute of a Kind that has a tag (by the
|
|
150
|
+
schema, `extra` or what its Derivation may read), in one pass over the Derivations."""
|
|
151
|
+
origins: dict[str, dict[Sensitivity, set[str]]] = {
|
|
152
|
+
a.id: {tag: {kind.id} for tag in {*a.sensitivity, *(extra or {}).get(a.id, NO_TAGS)}}
|
|
153
|
+
for kind in schema.kinds.values()
|
|
154
|
+
for a in kind.attributes.values()
|
|
155
|
+
}
|
|
156
|
+
reads = {
|
|
157
|
+
derivation.target[1]: [a for _, a in derivation_reads(schema, derivation, limits)]
|
|
158
|
+
for derivation in schema.derivations.values()
|
|
159
|
+
if derivation.target is not None
|
|
160
|
+
}
|
|
161
|
+
changed = True
|
|
162
|
+
while changed:
|
|
163
|
+
changed = False
|
|
164
|
+
for target, inputs in reads.items():
|
|
165
|
+
held = origins.setdefault(target, {})
|
|
166
|
+
for source in inputs:
|
|
167
|
+
for tag, kinds in origins.get(source, {}).items():
|
|
168
|
+
current = held.setdefault(tag, set())
|
|
169
|
+
if not kinds <= current:
|
|
170
|
+
current |= kinds
|
|
171
|
+
changed = True
|
|
172
|
+
return {
|
|
173
|
+
attribute_id: {tag: frozenset(kinds) for tag, kinds in found.items()}
|
|
174
|
+
for attribute_id, found in origins.items()
|
|
175
|
+
if found
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def tag_origins(
|
|
180
|
+
schema: Schema,
|
|
181
|
+
attribute_id: str,
|
|
182
|
+
extra: Mapping[str, Tags] | None = None,
|
|
183
|
+
limits: ExprLimits | None = None,
|
|
184
|
+
) -> Origins:
|
|
185
|
+
"""Tag -> ids of the Kinds holding the Attributes the tag comes from: the Attribute's own
|
|
186
|
+
tags (the schema's and `extra`'s) and, transitively, those of everything its Derivation may
|
|
187
|
+
read. A value computed from a sensitive Attribute of another Kind tells about Records of
|
|
188
|
+
that Kind, so a reader must be cleared on it too."""
|
|
189
|
+
return effective_origins(schema, extra, limits).get(attribute_id, {})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def expression_fields(source: str, limits: ExprLimits | None = None) -> frozenset[str]:
|
|
193
|
+
"""Names an Operation expression selects from Records (`record(r).name`,
|
|
194
|
+
`$step[0].name`), leaving out parameters and the principal."""
|
|
195
|
+
try:
|
|
196
|
+
root = parse(source, limits or ExprLimits())
|
|
197
|
+
except ExprError:
|
|
198
|
+
return frozenset() # reported by expression validation
|
|
199
|
+
names: set[str] = set()
|
|
200
|
+
for node in _nodes(root):
|
|
201
|
+
if isinstance(node, Select):
|
|
202
|
+
operand = node.operand
|
|
203
|
+
if isinstance(operand, Ident) and operand.name in (PARAMS, PRINCIPAL):
|
|
204
|
+
continue
|
|
205
|
+
names.add(node.field)
|
|
206
|
+
return frozenset(names)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def operation_reads(
|
|
210
|
+
schema: Schema,
|
|
211
|
+
operation: Operation,
|
|
212
|
+
source: str,
|
|
213
|
+
limits: ExprLimits | None = None,
|
|
214
|
+
*,
|
|
215
|
+
step: int | None = None,
|
|
216
|
+
) -> list[Attribute]:
|
|
217
|
+
"""Attributes an Operation expression may read from Records. `step` is the index of the
|
|
218
|
+
step the expression belongs to (`$step[n]` is defined there), `None` for preconditions
|
|
219
|
+
and the review policy.
|
|
220
|
+
|
|
221
|
+
The expression is type checked and each field is resolved against the Kind of the Record
|
|
222
|
+
it is selected from, wherever the checker infers one: `record(p)`, `links(r, "rel")`,
|
|
223
|
+
comprehension variables over them, conditional results and nested selections. Fields of
|
|
224
|
+
untyped (`dyn`) values are matched by name against every Kind and Relation (a superset).
|
|
225
|
+
An expression that does not type check is resolved from its syntax (`_guessed_reads`)."""
|
|
226
|
+
try:
|
|
227
|
+
root = parse(source, limits or ExprLimits())
|
|
228
|
+
except ExprError:
|
|
229
|
+
return [] # reported by expression validation
|
|
230
|
+
environment = operation_environment(
|
|
231
|
+
schema,
|
|
232
|
+
operation,
|
|
233
|
+
step_index=None if step is None else max(0, min(step, len(operation.steps))),
|
|
234
|
+
)
|
|
235
|
+
checker = Checker(environment)
|
|
236
|
+
try:
|
|
237
|
+
checker.check(root)
|
|
238
|
+
except (ExprError, RecursionError):
|
|
239
|
+
return _guessed_reads(schema, operation, root)
|
|
240
|
+
found: dict[str, Attribute] = {}
|
|
241
|
+
unresolved: set[str] = set()
|
|
242
|
+
for node in _nodes(root):
|
|
243
|
+
if not isinstance(node, Select):
|
|
244
|
+
continue
|
|
245
|
+
operand = checker.types.get(node.operand)
|
|
246
|
+
if operand is None or operand.kind is TypeKind.DYN:
|
|
247
|
+
unresolved.add(node.field)
|
|
248
|
+
continue
|
|
249
|
+
owner = _record_kind(schema, operand)
|
|
250
|
+
if owner is None:
|
|
251
|
+
continue # parameters, the principal, maps, references, geo points
|
|
252
|
+
attribute = owner.attribute_by_api_name(node.field)
|
|
253
|
+
if attribute is not None:
|
|
254
|
+
found[attribute.id] = attribute
|
|
255
|
+
for attribute in attributes_named(schema, unresolved):
|
|
256
|
+
found.setdefault(attribute.id, attribute)
|
|
257
|
+
return list(found.values())
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _record_kind(schema: Schema, expr_type: ExprType) -> Kind | None:
|
|
261
|
+
"""The Kind of a `record(Kind)` type; `None` for any other type."""
|
|
262
|
+
if expr_type.kind is not TypeKind.RECORD or expr_type.name is None:
|
|
263
|
+
return None
|
|
264
|
+
return schema.kinds.get(expr_type.name)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _guessed_reads(schema: Schema, operation: Operation, root: Node) -> list[Attribute]:
|
|
268
|
+
"""`operation_reads` for an expression that does not type check, from its syntax alone:
|
|
269
|
+
fields of `record(p)` for a parameter `p` of type `ref(Kind)`, of `$step[n]` for a step
|
|
270
|
+
that writes a Record of a Kind and of `links(r, "rel")` resolve against that Kind; any
|
|
271
|
+
other field is matched by name against every Kind and Relation (a superset)."""
|
|
272
|
+
kinds_of_parameters = {
|
|
273
|
+
p.name: p.type.kind_ref for p in operation.parameters if p.type.kind_ref is not None
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
def parameter(node: Node) -> str | None:
|
|
277
|
+
if isinstance(node, Ident):
|
|
278
|
+
return node.name
|
|
279
|
+
if (
|
|
280
|
+
isinstance(node, Select)
|
|
281
|
+
and isinstance(node.operand, Ident)
|
|
282
|
+
and node.operand.name == PARAMS
|
|
283
|
+
):
|
|
284
|
+
return node.field
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
def kind_of(node: Node) -> Kind | None:
|
|
288
|
+
if isinstance(node, Call) and node.target is None and node.args:
|
|
289
|
+
if node.function == "record":
|
|
290
|
+
name = parameter(node.args[0])
|
|
291
|
+
kind_id = None if name is None else kinds_of_parameters.get(name)
|
|
292
|
+
return None if kind_id is None else schema.kinds.get(kind_id)
|
|
293
|
+
if node.function == "links" and len(node.args) == 2:
|
|
294
|
+
relation = node.args[1]
|
|
295
|
+
if isinstance(relation, Literal) and isinstance(relation.value, str):
|
|
296
|
+
found = schema.link_by_api_name(relation.value)
|
|
297
|
+
if found is not None:
|
|
298
|
+
rel, direction = found
|
|
299
|
+
return schema.kinds.get(rel.far_endpoint(direction))
|
|
300
|
+
return None
|
|
301
|
+
if (
|
|
302
|
+
isinstance(node, Index)
|
|
303
|
+
and isinstance(node.operand, Ident)
|
|
304
|
+
and node.operand.name in (STEP_SYNTAX, STEP_VARIABLE)
|
|
305
|
+
and isinstance(node.index, Literal)
|
|
306
|
+
and isinstance(node.index.value, int)
|
|
307
|
+
and 0 <= node.index.value < len(operation.steps)
|
|
308
|
+
):
|
|
309
|
+
step = operation.steps[node.index.value]
|
|
310
|
+
kind_id = getattr(step, "kind", None)
|
|
311
|
+
return None if kind_id is None else schema.kinds.get(kind_id)
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
found: dict[str, Attribute] = {}
|
|
315
|
+
unresolved: set[str] = set()
|
|
316
|
+
for node in _nodes(root):
|
|
317
|
+
if not isinstance(node, Select):
|
|
318
|
+
continue
|
|
319
|
+
operand = node.operand
|
|
320
|
+
if isinstance(operand, Ident) and operand.name in (PARAMS, PRINCIPAL):
|
|
321
|
+
continue
|
|
322
|
+
owner = kind_of(operand)
|
|
323
|
+
if owner is None:
|
|
324
|
+
unresolved.add(node.field)
|
|
325
|
+
continue
|
|
326
|
+
attribute = owner.attribute_by_api_name(node.field)
|
|
327
|
+
if attribute is not None:
|
|
328
|
+
found[attribute.id] = attribute
|
|
329
|
+
for attribute in attributes_named(schema, unresolved):
|
|
330
|
+
found.setdefault(attribute.id, attribute)
|
|
331
|
+
return list(found.values())
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def attributes_named(schema: Schema, names: Iterable[str]) -> list[Attribute]:
|
|
335
|
+
"""Attributes of any Kind or Relation with one of these api names."""
|
|
336
|
+
wanted = set(names)
|
|
337
|
+
return [a for a in all_attributes(schema) if a.api_name in wanted]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Static types of expressions and their mapping from Lorebase Attribute types.
|
|
2
|
+
|
|
3
|
+
CEL types plus three Lorebase additions: ``decimal`` (exact arithmetic), ``date`` and the
|
|
4
|
+
schema types ``ref(Kind)`` (a Record reference) and ``record(Kind)`` (a loaded Record whose
|
|
5
|
+
fields are the Kind's Attributes). Objects (``params``, ``principal``, ``links``) are named
|
|
6
|
+
types whose fields the Environment declares.
|
|
7
|
+
|
|
8
|
+
String types may carry ``values``: the closed set of strings they can hold, from an enum
|
|
9
|
+
Attribute or from string literals. It lets the checker reject comparisons that can never
|
|
10
|
+
be true and values that do not fit an enum.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
from lorebase_core.types import AttributeType, TypeSpec
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TypeKind(StrEnum):
|
|
23
|
+
BOOL = "bool"
|
|
24
|
+
INT = "int"
|
|
25
|
+
DOUBLE = "double"
|
|
26
|
+
DECIMAL = "decimal"
|
|
27
|
+
STRING = "string"
|
|
28
|
+
BYTES = "bytes"
|
|
29
|
+
NULL = "null"
|
|
30
|
+
TIMESTAMP = "timestamp"
|
|
31
|
+
DURATION = "duration"
|
|
32
|
+
DATE = "date"
|
|
33
|
+
LIST = "list"
|
|
34
|
+
MAP = "map"
|
|
35
|
+
REF = "ref"
|
|
36
|
+
RECORD = "record"
|
|
37
|
+
OBJECT = "object"
|
|
38
|
+
DYN = "dyn"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
#: Kinds with a numeric value; comparisons across them are allowed (CEL heterogeneous
|
|
42
|
+
#: numeric comparison).
|
|
43
|
+
NUMERIC = frozenset({TypeKind.INT, TypeKind.DOUBLE, TypeKind.DECIMAL})
|
|
44
|
+
|
|
45
|
+
#: Kinds with a total order (besides the numeric ones).
|
|
46
|
+
ORDERED = frozenset(
|
|
47
|
+
{
|
|
48
|
+
TypeKind.STRING,
|
|
49
|
+
TypeKind.BYTES,
|
|
50
|
+
TypeKind.BOOL,
|
|
51
|
+
TypeKind.TIMESTAMP,
|
|
52
|
+
TypeKind.DURATION,
|
|
53
|
+
TypeKind.DATE,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class ExprType:
|
|
60
|
+
"""A static type. ``name`` is the Kind id of ``ref`` and ``record`` types and the name of
|
|
61
|
+
``object`` types; ``args`` are the element type of a list and the key and value types of
|
|
62
|
+
a map; ``values`` and ``enum`` describe closed string types (see the module docstring).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
kind: TypeKind
|
|
66
|
+
args: tuple[ExprType, ...] = ()
|
|
67
|
+
name: str | None = None
|
|
68
|
+
values: frozenset[str] | None = None
|
|
69
|
+
enum: bool = False
|
|
70
|
+
|
|
71
|
+
def describe(self, names: Callable[[str], str] | None = None) -> str:
|
|
72
|
+
"""Readable form; ``names`` maps Kind ids to api names."""
|
|
73
|
+
label = names or (lambda kind_id: kind_id)
|
|
74
|
+
if self.kind is TypeKind.LIST:
|
|
75
|
+
return f"list({self.args[0].describe(names)})"
|
|
76
|
+
if self.kind is TypeKind.MAP:
|
|
77
|
+
return f"map({self.args[0].describe(names)}, {self.args[1].describe(names)})"
|
|
78
|
+
if self.kind in (TypeKind.REF, TypeKind.RECORD):
|
|
79
|
+
return f"{self.kind.value}({label(self.name or '?')})"
|
|
80
|
+
if self.kind is TypeKind.OBJECT:
|
|
81
|
+
return self.name or "object"
|
|
82
|
+
if self.kind is TypeKind.STRING and self.enum and self.values is not None:
|
|
83
|
+
return f"enum({', '.join(sorted(self.values))})"
|
|
84
|
+
return self.kind.value
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def element(self) -> ExprType:
|
|
88
|
+
"""Element type of a list, key type of a map, ``dyn`` otherwise."""
|
|
89
|
+
return self.args[0] if self.args else DYN
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_numeric(self) -> bool:
|
|
93
|
+
return self.kind in NUMERIC
|
|
94
|
+
|
|
95
|
+
def plain(self) -> ExprType:
|
|
96
|
+
"""The type without a closed value set (for results of string operations)."""
|
|
97
|
+
if self.values is None:
|
|
98
|
+
return self
|
|
99
|
+
return ExprType(self.kind, self.args, self.name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
BOOL = ExprType(TypeKind.BOOL)
|
|
103
|
+
INT = ExprType(TypeKind.INT)
|
|
104
|
+
DOUBLE = ExprType(TypeKind.DOUBLE)
|
|
105
|
+
DECIMAL = ExprType(TypeKind.DECIMAL)
|
|
106
|
+
STRING = ExprType(TypeKind.STRING)
|
|
107
|
+
BYTES = ExprType(TypeKind.BYTES)
|
|
108
|
+
NULL = ExprType(TypeKind.NULL)
|
|
109
|
+
TIMESTAMP = ExprType(TypeKind.TIMESTAMP)
|
|
110
|
+
DURATION = ExprType(TypeKind.DURATION)
|
|
111
|
+
DATE = ExprType(TypeKind.DATE)
|
|
112
|
+
DYN = ExprType(TypeKind.DYN)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def list_of(element: ExprType) -> ExprType:
|
|
116
|
+
return ExprType(TypeKind.LIST, (element,))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def map_of(key: ExprType, value: ExprType) -> ExprType:
|
|
120
|
+
return ExprType(TypeKind.MAP, (key, value))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def ref_of(kind_id: str) -> ExprType:
|
|
124
|
+
return ExprType(TypeKind.REF, name=kind_id)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def record_of(kind_id: str) -> ExprType:
|
|
128
|
+
return ExprType(TypeKind.RECORD, name=kind_id)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def object_of(name: str) -> ExprType:
|
|
132
|
+
return ExprType(TypeKind.OBJECT, name=name)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def string_values(values: frozenset[str], *, enum: bool) -> ExprType:
|
|
136
|
+
return ExprType(TypeKind.STRING, values=values, enum=enum)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
#: Object type of ``geo_point`` values.
|
|
140
|
+
GEO_POINT = "geo_point"
|
|
141
|
+
|
|
142
|
+
_SCALARS: dict[AttributeType, ExprType] = {
|
|
143
|
+
AttributeType.STRING: STRING,
|
|
144
|
+
AttributeType.TEXT: STRING,
|
|
145
|
+
AttributeType.URI: STRING,
|
|
146
|
+
AttributeType.INTEGER: INT,
|
|
147
|
+
AttributeType.DECIMAL: DECIMAL,
|
|
148
|
+
AttributeType.FLOAT: DOUBLE,
|
|
149
|
+
AttributeType.BOOLEAN: BOOL,
|
|
150
|
+
AttributeType.DATE: DATE,
|
|
151
|
+
AttributeType.TIMESTAMP: TIMESTAMP,
|
|
152
|
+
AttributeType.DURATION: DURATION,
|
|
153
|
+
AttributeType.JSON: DYN,
|
|
154
|
+
AttributeType.GEO_POINT: object_of(GEO_POINT),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def from_type_spec(spec: TypeSpec) -> ExprType:
|
|
159
|
+
"""The expression type of values of an Attribute (or parameter) of type ``spec``."""
|
|
160
|
+
if spec.name is AttributeType.ARRAY:
|
|
161
|
+
assert spec.element is not None
|
|
162
|
+
return list_of(from_type_spec(spec.element))
|
|
163
|
+
if spec.name is AttributeType.ENUM:
|
|
164
|
+
assert spec.enum_values is not None
|
|
165
|
+
return string_values(frozenset(spec.enum_values), enum=True)
|
|
166
|
+
if spec.name is AttributeType.REF:
|
|
167
|
+
assert spec.kind_ref is not None
|
|
168
|
+
return ref_of(spec.kind_ref)
|
|
169
|
+
return _SCALARS[spec.name]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def assignable(source: ExprType, target: ExprType, *, nullable: bool) -> bool:
|
|
173
|
+
"""Whether a value of type ``source`` can be used where ``target`` is expected.
|
|
174
|
+
|
|
175
|
+
``int`` widens to ``decimal`` and ``double``, ``decimal`` to ``double``, and a ``string``
|
|
176
|
+
is accepted as a Record key where a ``ref`` is expected. Closed string types must fit
|
|
177
|
+
the target's value set; open strings are checked when the value is encoded.
|
|
178
|
+
"""
|
|
179
|
+
if source.kind is TypeKind.DYN or target.kind is TypeKind.DYN:
|
|
180
|
+
return True
|
|
181
|
+
if source.kind is TypeKind.NULL:
|
|
182
|
+
return nullable
|
|
183
|
+
if source.kind is TypeKind.INT and target.kind in (TypeKind.DECIMAL, TypeKind.DOUBLE):
|
|
184
|
+
return True
|
|
185
|
+
if source.kind is TypeKind.DECIMAL and target.kind is TypeKind.DOUBLE:
|
|
186
|
+
return True
|
|
187
|
+
if source.kind is TypeKind.STRING and target.kind is TypeKind.REF:
|
|
188
|
+
return True
|
|
189
|
+
if source.kind is not target.kind:
|
|
190
|
+
return False
|
|
191
|
+
if source.kind is TypeKind.STRING:
|
|
192
|
+
if target.values is None or source.values is None:
|
|
193
|
+
return True
|
|
194
|
+
return source.values <= target.values
|
|
195
|
+
if source.kind in (TypeKind.REF, TypeKind.RECORD, TypeKind.OBJECT):
|
|
196
|
+
return source.name == target.name
|
|
197
|
+
if source.kind in (TypeKind.LIST, TypeKind.MAP):
|
|
198
|
+
return all(
|
|
199
|
+
assignable(s, t, nullable=False) for s, t in zip(source.args, target.args, strict=True)
|
|
200
|
+
)
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def join(first: ExprType, second: ExprType) -> ExprType:
|
|
205
|
+
"""The type of a value that is either ``first`` or ``second`` (branches, list items)."""
|
|
206
|
+
if first == second:
|
|
207
|
+
return first
|
|
208
|
+
if first.kind is TypeKind.NULL:
|
|
209
|
+
return second
|
|
210
|
+
if second.kind is TypeKind.NULL:
|
|
211
|
+
return first
|
|
212
|
+
if first.kind is TypeKind.DYN or second.kind is TypeKind.DYN:
|
|
213
|
+
return DYN
|
|
214
|
+
if first.kind is TypeKind.STRING and second.kind is TypeKind.STRING:
|
|
215
|
+
if first.values is None or second.values is None:
|
|
216
|
+
return STRING
|
|
217
|
+
return string_values(first.values | second.values, enum=first.enum and second.enum)
|
|
218
|
+
if first.is_numeric and second.is_numeric:
|
|
219
|
+
kinds = {first.kind, second.kind}
|
|
220
|
+
if TypeKind.DOUBLE in kinds:
|
|
221
|
+
return DOUBLE
|
|
222
|
+
return DECIMAL
|
|
223
|
+
if first.kind is second.kind and first.kind in (TypeKind.LIST, TypeKind.MAP):
|
|
224
|
+
return ExprType(
|
|
225
|
+
first.kind, tuple(join(a, b) for a, b in zip(first.args, second.args, strict=True))
|
|
226
|
+
)
|
|
227
|
+
return DYN
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Static validation of every expression of a Schema (parse and type check).
|
|
2
|
+
|
|
3
|
+
Used by ``validate_schema`` when expressions are enabled; each problem is a
|
|
4
|
+
``schema.expression_invalid`` issue whose path points at the expression and whose details
|
|
5
|
+
carry the source, line and column.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import JsonValue
|
|
11
|
+
|
|
12
|
+
from lorebase_core.expr.derivations import derivation_expression_errors
|
|
13
|
+
from lorebase_core.expr.errors import ExprError, PathPart
|
|
14
|
+
from lorebase_core.expr.limits import ExprLimits
|
|
15
|
+
from lorebase_core.expr.operations import operation_expression_errors
|
|
16
|
+
from lorebase_core.model.schema import Schema
|
|
17
|
+
from lorebase_core.validate.issues import Issue, IssueCode
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _issue(base: tuple[PathPart, ...], error: ExprError, *, kind: str | None = None) -> Issue:
|
|
21
|
+
details: dict[str, JsonValue] = {"error": error.code.value}
|
|
22
|
+
if error.source is not None:
|
|
23
|
+
details["expression"] = error.source
|
|
24
|
+
if error.position is not None:
|
|
25
|
+
details["line"] = error.position.line
|
|
26
|
+
details["column"] = error.position.column
|
|
27
|
+
return Issue(
|
|
28
|
+
code=IssueCode.SCHEMA_EXPRESSION_INVALID,
|
|
29
|
+
path=(*base, *error.path),
|
|
30
|
+
message=f"expression: {error.describe()}",
|
|
31
|
+
kind=kind,
|
|
32
|
+
details=details,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def schema_expression_issues(schema: Schema, limits: ExprLimits | None = None) -> list[Issue]:
|
|
37
|
+
"""``schema.expression_invalid`` issues of every Operation and Derivation expression."""
|
|
38
|
+
issues: list[Issue] = []
|
|
39
|
+
for operation in schema.operations.values():
|
|
40
|
+
base: tuple[PathPart, ...] = ("operations", operation.id)
|
|
41
|
+
issues.extend(
|
|
42
|
+
_issue(base, error) for error in operation_expression_errors(schema, operation, limits)
|
|
43
|
+
)
|
|
44
|
+
for derivation in schema.derivations.values():
|
|
45
|
+
base = ("derivations", derivation.id)
|
|
46
|
+
target = derivation.target
|
|
47
|
+
issues.extend(
|
|
48
|
+
_issue(base, error, kind=target[0] if target else None)
|
|
49
|
+
for error in derivation_expression_errors(schema, derivation, limits)
|
|
50
|
+
)
|
|
51
|
+
return issues
|