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.
Files changed (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. 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