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,114 @@
|
|
|
1
|
+
"""Lineage (design 5.11, AC-08): provenance entries attached to changes, not to content.
|
|
2
|
+
|
|
3
|
+
A provenance entry describes one origin. Each change in a Revision changeset carries a
|
|
4
|
+
``LineageMap`` from the Attribute ids it changed to provenance entry ids. Provenance is never
|
|
5
|
+
part of content hashes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Annotated, Literal, Self
|
|
12
|
+
|
|
13
|
+
from pydantic import Field, field_validator, model_validator
|
|
14
|
+
|
|
15
|
+
from lorebase_core.model.base import Model
|
|
16
|
+
from lorebase_core.model.ids import AttributeId, OperationId, ProvenanceId, SourceId
|
|
17
|
+
from lorebase_core.types import AttributeType, TypeSpec, encode_value
|
|
18
|
+
|
|
19
|
+
_TIMESTAMP = TypeSpec.of(AttributeType.TIMESTAMP)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OriginType(StrEnum):
|
|
23
|
+
OPERATION = "operation"
|
|
24
|
+
SOURCE_SYNC = "source_sync"
|
|
25
|
+
EXTRACTION = "extraction"
|
|
26
|
+
MERGE_RESOLUTION = "merge_resolution"
|
|
27
|
+
SCHEMA_MIGRATION = "schema_migration"
|
|
28
|
+
IMPORT = "import"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OperationOrigin(Model):
|
|
32
|
+
type: Literal["operation"] = "operation"
|
|
33
|
+
invocation_id: str
|
|
34
|
+
#: Operation api name; built-in Operations (``create_supplier``) have no stored id.
|
|
35
|
+
operation: str
|
|
36
|
+
operation_id: OperationId | None = None
|
|
37
|
+
principal: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SourceSyncOrigin(Model):
|
|
41
|
+
type: Literal["source_sync"] = "source_sync"
|
|
42
|
+
source_id: SourceId
|
|
43
|
+
run_id: str
|
|
44
|
+
row_locator: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ExtractionOrigin(Model):
|
|
48
|
+
type: Literal["extraction"] = "extraction"
|
|
49
|
+
job_id: str
|
|
50
|
+
document_id: str
|
|
51
|
+
span_start: int = Field(ge=0)
|
|
52
|
+
span_end: int = Field(ge=0)
|
|
53
|
+
model_id: str
|
|
54
|
+
prompt_version: str
|
|
55
|
+
|
|
56
|
+
@model_validator(mode="after")
|
|
57
|
+
def _check_span(self) -> Self:
|
|
58
|
+
if self.span_end < self.span_start:
|
|
59
|
+
raise ValueError("span_end must not precede span_start")
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class MergeResolutionOrigin(Model):
|
|
64
|
+
type: Literal["merge_resolution"] = "merge_resolution"
|
|
65
|
+
proposal_id: str
|
|
66
|
+
conflict_id: str
|
|
67
|
+
resolver: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SchemaMigrationOrigin(Model):
|
|
71
|
+
"""A value written by a schema change (cast or default) on ``attribute_id``."""
|
|
72
|
+
|
|
73
|
+
type: Literal["schema_migration"] = "schema_migration"
|
|
74
|
+
attribute_id: AttributeId
|
|
75
|
+
from_type: TypeSpec | None = None
|
|
76
|
+
to_type: TypeSpec | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ImportOrigin(Model):
|
|
80
|
+
type: Literal["import"] = "import"
|
|
81
|
+
batch_id: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
Origin = Annotated[
|
|
85
|
+
OperationOrigin
|
|
86
|
+
| SourceSyncOrigin
|
|
87
|
+
| ExtractionOrigin
|
|
88
|
+
| MergeResolutionOrigin
|
|
89
|
+
| SchemaMigrationOrigin
|
|
90
|
+
| ImportOrigin,
|
|
91
|
+
Field(discriminator="type"),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ProvenanceEntry(Model):
|
|
96
|
+
"""One Lineage origin: ``{id, origin, actor, at}``; ``at`` is a canonical timestamp."""
|
|
97
|
+
|
|
98
|
+
id: ProvenanceId
|
|
99
|
+
origin: Origin
|
|
100
|
+
actor: str
|
|
101
|
+
at: str
|
|
102
|
+
|
|
103
|
+
@field_validator("at", mode="before")
|
|
104
|
+
@classmethod
|
|
105
|
+
def _canonical_timestamp(cls, value: object) -> object:
|
|
106
|
+
return encode_value(_TIMESTAMP, value)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def origin_type(self) -> OriginType:
|
|
110
|
+
return OriginType(self.origin.type)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
#: Per change: changed Attribute id -> provenance entry id.
|
|
114
|
+
LineageMap = dict[AttributeId, ProvenanceId]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Operation (design 5.7): a named, typed, governed write.
|
|
2
|
+
|
|
3
|
+
Expressions (preconditions, step values, review policies) are CEL source strings (AC-06).
|
|
4
|
+
They are stored as written; type checking and evaluation belong to the expression module.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Annotated, ClassVar, Literal, Self
|
|
11
|
+
|
|
12
|
+
from pydantic import Field, model_validator
|
|
13
|
+
|
|
14
|
+
from lorebase_core.model.base import ElementStatus, Model, SchemaElement, snake_case
|
|
15
|
+
from lorebase_core.model.ids import AttributeId, KindId, OperationId, RelationId
|
|
16
|
+
from lorebase_core.model.kind import BuiltinOperation, Kind
|
|
17
|
+
from lorebase_core.model.relation import Relation
|
|
18
|
+
from lorebase_core.types import TypeSpec
|
|
19
|
+
|
|
20
|
+
#: A CEL expression, stored as source text.
|
|
21
|
+
Expression = Annotated[str, Field(min_length=1)]
|
|
22
|
+
|
|
23
|
+
SIDE_EFFECT_MAX_LENGTH = 64
|
|
24
|
+
"""Longest side effect name (static validation); the outbox event type is
|
|
25
|
+
`operation.<name>`."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OperationParameter(Model):
|
|
29
|
+
"""A parameter. Record reference parameters use a ``ref`` type naming the Kind."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
type: TypeSpec
|
|
33
|
+
required: bool = False
|
|
34
|
+
description: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Precondition(Model):
|
|
38
|
+
expression: Expression
|
|
39
|
+
message: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CreateRecordStep(Model):
|
|
43
|
+
step: Literal["create_record"] = "create_record"
|
|
44
|
+
kind: KindId
|
|
45
|
+
values: dict[AttributeId, Expression] = Field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UpdateRecordStep(Model):
|
|
49
|
+
step: Literal["update_record"] = "update_record"
|
|
50
|
+
kind: KindId
|
|
51
|
+
target: Expression
|
|
52
|
+
values: dict[AttributeId, Expression] = Field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DeleteRecordStep(Model):
|
|
56
|
+
step: Literal["delete_record"] = "delete_record"
|
|
57
|
+
kind: KindId
|
|
58
|
+
target: Expression
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CreateLinkStep(Model):
|
|
62
|
+
step: Literal["create_link"] = "create_link"
|
|
63
|
+
relation: RelationId
|
|
64
|
+
from_: Expression = Field(alias="from")
|
|
65
|
+
to: Expression
|
|
66
|
+
values: dict[AttributeId, Expression] = Field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DeleteLinkStep(Model):
|
|
70
|
+
step: Literal["delete_link"] = "delete_link"
|
|
71
|
+
relation: RelationId
|
|
72
|
+
from_: Expression = Field(alias="from")
|
|
73
|
+
to: Expression
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
OperationStep = Annotated[
|
|
77
|
+
CreateRecordStep | UpdateRecordStep | DeleteRecordStep | CreateLinkStep | DeleteLinkStep,
|
|
78
|
+
Field(discriminator="step"),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ReviewMode(StrEnum):
|
|
83
|
+
NEVER = "never"
|
|
84
|
+
ALWAYS = "always"
|
|
85
|
+
EXPRESSION = "expression"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ReviewPolicy(Model):
|
|
89
|
+
"""``never``, ``always``, or a CEL ``expression`` (true sends the edits to a Proposal)."""
|
|
90
|
+
|
|
91
|
+
mode: ReviewMode = ReviewMode.NEVER
|
|
92
|
+
expression: Expression | None = None
|
|
93
|
+
|
|
94
|
+
@model_validator(mode="after")
|
|
95
|
+
def _check_expression(self) -> Self:
|
|
96
|
+
if (self.mode is ReviewMode.EXPRESSION) != (self.expression is not None):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
"review policy has an expression if and only if its mode is 'expression'"
|
|
99
|
+
)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RateLimit(Model):
|
|
104
|
+
max_calls: int = Field(ge=1)
|
|
105
|
+
per_seconds: int = Field(ge=1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Operation(SchemaElement):
|
|
109
|
+
object_type: ClassVar[str] = "schema.operation"
|
|
110
|
+
|
|
111
|
+
id: OperationId
|
|
112
|
+
parameters: tuple[OperationParameter, ...] = ()
|
|
113
|
+
preconditions: tuple[Precondition, ...] = ()
|
|
114
|
+
steps: tuple[OperationStep, ...] = ()
|
|
115
|
+
review_policy: ReviewPolicy = Field(default_factory=ReviewPolicy)
|
|
116
|
+
permissions: tuple[str, ...] = ()
|
|
117
|
+
side_effects: tuple[str, ...] = ()
|
|
118
|
+
"""Named events written to the outbox in the commit transaction (design 10.5); unique
|
|
119
|
+
snake_case names, checked by static validation so that stored schemas always load."""
|
|
120
|
+
rate_limit: RateLimit | None = None
|
|
121
|
+
status: ElementStatus = ElementStatus.ACTIVE
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def uses_expressions(self) -> bool:
|
|
125
|
+
"""True when the Operation carries CEL expressions."""
|
|
126
|
+
return (
|
|
127
|
+
bool(self.preconditions or self.steps)
|
|
128
|
+
or self.review_policy.mode is ReviewMode.EXPRESSION
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def builtin_operation_names(kind: Kind) -> list[str]:
|
|
133
|
+
"""Names of the built-in Operations enabled for ``kind``: ``create_{kind}`` and so on."""
|
|
134
|
+
base = snake_case(kind.api_name)
|
|
135
|
+
return [f"{operation.value}_{base}" for operation in kind.operations_builtin]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def builtin_link_operation_names(relation: Relation) -> list[str]:
|
|
139
|
+
"""``link_{relation}`` and ``unlink_{relation}`` for an explicit Relation."""
|
|
140
|
+
return [f"link_{relation.api_name}", f"unlink_{relation.api_name}"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
__all__ = [
|
|
144
|
+
"SIDE_EFFECT_MAX_LENGTH",
|
|
145
|
+
"BuiltinOperation",
|
|
146
|
+
"CreateLinkStep",
|
|
147
|
+
"CreateRecordStep",
|
|
148
|
+
"DeleteLinkStep",
|
|
149
|
+
"DeleteRecordStep",
|
|
150
|
+
"Expression",
|
|
151
|
+
"Operation",
|
|
152
|
+
"OperationParameter",
|
|
153
|
+
"OperationStep",
|
|
154
|
+
"Precondition",
|
|
155
|
+
"RateLimit",
|
|
156
|
+
"ReviewMode",
|
|
157
|
+
"ReviewPolicy",
|
|
158
|
+
"UpdateRecordStep",
|
|
159
|
+
"builtin_link_operation_names",
|
|
160
|
+
"builtin_operation_names",
|
|
161
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Path Derivations as virtual Relations (design 5.8).
|
|
2
|
+
|
|
3
|
+
A `path` Derivation composes Relations, `A -r1-> B -r2-> C`, into a virtual Relation `A -> C`
|
|
4
|
+
named by the Derivation's `api_name` (forward) and `inverse_api_name` (inverse, optional).
|
|
5
|
+
`PathRelation` is what readers and the materializer work with: the steps resolved to
|
|
6
|
+
Relations and a virtual `Relation` (id = the Derivation id, explicit backing, no Attributes)
|
|
7
|
+
that code reading Links by Relation can use unchanged.
|
|
8
|
+
|
|
9
|
+
Semantics, shared by every evaluation (materialized rows, reads at the head, reads at a past
|
|
10
|
+
Revision): the Links of the path are the distinct `(a, c)` pairs such that `c` is reached from
|
|
11
|
+
`a` through one Link per step. The first step follows every Link of `a`; every later step
|
|
12
|
+
follows, from each intermediate Record, at most `fanout` Links, those with the smallest far
|
|
13
|
+
keys. The bound depends on the intermediate Record only, never on where the walk started, so
|
|
14
|
+
the pairs of one `a` can be recomputed alone. Paths have a fixed length, so a cycle in the data
|
|
15
|
+
ends the walk like any other step (a path may lead back to its start: `(a, a)` is a pair).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from lorebase_core.model.derivation import Derivation, PathDerivation
|
|
24
|
+
from lorebase_core.model.relation import Cardinality, LinkDirection, Relation, RelationBacking
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class PathHop:
|
|
29
|
+
"""One step of a path: a Relation followed in a direction."""
|
|
30
|
+
|
|
31
|
+
relation: Relation
|
|
32
|
+
direction: LinkDirection
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def near_kind(self) -> str:
|
|
36
|
+
return self.relation.endpoint(self.direction)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def far_kind(self) -> str:
|
|
40
|
+
return self.relation.far_endpoint(self.direction)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class PathRelation:
|
|
45
|
+
"""A valid path Derivation with its steps resolved (see the module docstring)."""
|
|
46
|
+
|
|
47
|
+
derivation: Derivation
|
|
48
|
+
relation: Relation
|
|
49
|
+
steps: tuple[PathHop, ...]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def id(self) -> str:
|
|
53
|
+
return self.derivation.id
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def materialized(self) -> bool:
|
|
57
|
+
definition = self.derivation.definition
|
|
58
|
+
assert isinstance(definition, PathDerivation)
|
|
59
|
+
return definition.materialized
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def kinds(self) -> tuple[str, ...]:
|
|
63
|
+
"""Kind ids along the path, from the first `near` Kind to the last `far` Kind."""
|
|
64
|
+
return (self.steps[0].near_kind, *(step.far_kind for step in self.steps))
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def inner_kinds(self) -> tuple[str, ...]:
|
|
68
|
+
"""Kind ids of the intermediate Records."""
|
|
69
|
+
return self.kinds[1:-1]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def path_relation(derivation: Derivation, relations: Mapping[str, Relation]) -> PathRelation | None:
|
|
73
|
+
"""The resolved path of a `path` Derivation; `None` for other variants and for paths
|
|
74
|
+
naming unknown Relations or not continuing from one step to the next (schema validation
|
|
75
|
+
reports those)."""
|
|
76
|
+
definition = derivation.definition
|
|
77
|
+
if not isinstance(definition, PathDerivation):
|
|
78
|
+
return None
|
|
79
|
+
steps: list[PathHop] = []
|
|
80
|
+
for step in definition.steps:
|
|
81
|
+
relation = relations.get(step.relation)
|
|
82
|
+
if relation is None:
|
|
83
|
+
return None
|
|
84
|
+
hop = PathHop(relation, step.direction)
|
|
85
|
+
if steps and steps[-1].far_kind != hop.near_kind:
|
|
86
|
+
return None
|
|
87
|
+
steps.append(hop)
|
|
88
|
+
virtual = Relation.model_construct(
|
|
89
|
+
id=derivation.id,
|
|
90
|
+
api_name=derivation.api_name,
|
|
91
|
+
display_name=derivation.display_name,
|
|
92
|
+
description=derivation.description,
|
|
93
|
+
inverse_api_name=definition.inverse_api_name or "",
|
|
94
|
+
from_kind=steps[0].near_kind,
|
|
95
|
+
to_kind=steps[-1].far_kind,
|
|
96
|
+
cardinality=Cardinality.MANY_TO_MANY,
|
|
97
|
+
attributes={},
|
|
98
|
+
backing=RelationBacking.EXPLICIT,
|
|
99
|
+
status=derivation.status,
|
|
100
|
+
)
|
|
101
|
+
return PathRelation(derivation, virtual, tuple(steps))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def path_relations(
|
|
105
|
+
derivations: Iterable[Derivation], relations: Mapping[str, Relation]
|
|
106
|
+
) -> dict[str, PathRelation]:
|
|
107
|
+
"""The valid path Derivations by Derivation id."""
|
|
108
|
+
found: dict[str, PathRelation] = {}
|
|
109
|
+
for derivation in derivations:
|
|
110
|
+
path = path_relation(derivation, relations)
|
|
111
|
+
if path is not None:
|
|
112
|
+
found[derivation.id] = path
|
|
113
|
+
return found
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def bounded(far_keys: Iterable[str], fanout: int | None) -> list[str]:
|
|
117
|
+
"""The far keys one intermediate Record contributes: distinct, smallest first, at most
|
|
118
|
+
`fanout` (all when `None`)."""
|
|
119
|
+
ordered = sorted(set(far_keys))
|
|
120
|
+
return ordered if fanout is None else ordered[:fanout]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def walk(
|
|
124
|
+
starts: Iterable[str],
|
|
125
|
+
steps: Sequence[Callable[[Sequence[str]], Mapping[str, Sequence[str]]]],
|
|
126
|
+
fanout: int | None,
|
|
127
|
+
) -> dict[str, set[str]]:
|
|
128
|
+
"""Pairs of a path from `starts`, with each step given as a function from near keys to
|
|
129
|
+
their far keys (synchronous loaders: tree reads hold every Link in memory)."""
|
|
130
|
+
reached: dict[str, set[str]] = {start: {start} for start in starts}
|
|
131
|
+
for index, step in enumerate(steps):
|
|
132
|
+
frontier = sorted({key for keys in reached.values() for key in keys})
|
|
133
|
+
found = step(frontier) if frontier else {}
|
|
134
|
+
nexts = {
|
|
135
|
+
near: (list(far) if index == 0 else bounded(far, fanout)) for near, far in found.items()
|
|
136
|
+
}
|
|
137
|
+
reached = {
|
|
138
|
+
start: {far for near in keys for far in nexts.get(near, ())}
|
|
139
|
+
for start, keys in reached.items()
|
|
140
|
+
}
|
|
141
|
+
return reached
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Record and Link (design 5.1, 5.10).
|
|
2
|
+
|
|
3
|
+
A Record is identified by ``(kind, key)`` (AC-02); a Link by ``(relation, from_key, to_key)``,
|
|
4
|
+
with no parallel Links (AC-03). Values are keyed by Attribute id and hold canonical values;
|
|
5
|
+
null and absent are the same, so ``None`` values are dropped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from typing import ClassVar, NamedTuple
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from pydantic import Field, JsonValue, field_validator
|
|
15
|
+
|
|
16
|
+
from lorebase_core.model.base import Model
|
|
17
|
+
from lorebase_core.model.ids import AttributeId, KindId, RelationId
|
|
18
|
+
from lorebase_core.model.kind import Kind
|
|
19
|
+
from lorebase_core.types import normalize_key, to_text
|
|
20
|
+
|
|
21
|
+
#: Separator between Kind api name and key in external references (``Supplier/S-1001``).
|
|
22
|
+
REF_SEPARATOR = "/"
|
|
23
|
+
#: Separator between components of composite keys (components are percent-encoded).
|
|
24
|
+
KEY_COMPONENT_SEPARATOR = ","
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RecordIdentity(NamedTuple):
|
|
28
|
+
kind: str
|
|
29
|
+
key: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LinkIdentity(NamedTuple):
|
|
33
|
+
relation: str
|
|
34
|
+
from_key: str
|
|
35
|
+
to_key: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _drop_absent(values: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
|
39
|
+
return {attribute: value for attribute, value in values.items() if value is not None}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Record(Model):
|
|
43
|
+
object_type: ClassVar[str] = "record"
|
|
44
|
+
|
|
45
|
+
kind: KindId
|
|
46
|
+
key: str
|
|
47
|
+
values: dict[AttributeId, JsonValue] = Field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
@field_validator("key")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _normalize_key(cls, key: str) -> str:
|
|
52
|
+
return normalize_key(key)
|
|
53
|
+
|
|
54
|
+
@field_validator("values")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _drop_absent_values(cls, values: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
|
57
|
+
return _drop_absent(values)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def identity(self) -> RecordIdentity:
|
|
61
|
+
return RecordIdentity(self.kind, self.key)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Link(Model):
|
|
65
|
+
object_type: ClassVar[str] = "link"
|
|
66
|
+
|
|
67
|
+
relation: RelationId
|
|
68
|
+
from_key: str = Field(alias="from")
|
|
69
|
+
to_key: str = Field(alias="to")
|
|
70
|
+
values: dict[AttributeId, JsonValue] = Field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
@field_validator("from_key", "to_key")
|
|
73
|
+
@classmethod
|
|
74
|
+
def _normalize_key(cls, key: str) -> str:
|
|
75
|
+
return normalize_key(key)
|
|
76
|
+
|
|
77
|
+
@field_validator("values")
|
|
78
|
+
@classmethod
|
|
79
|
+
def _drop_absent_values(cls, values: dict[str, JsonValue]) -> dict[str, JsonValue]:
|
|
80
|
+
return _drop_absent(values)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def identity(self) -> LinkIdentity:
|
|
84
|
+
return LinkIdentity(self.relation, self.from_key, self.to_key)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def render_key(components: Sequence[str]) -> str:
|
|
88
|
+
"""Render primary key component texts as a canonical key string.
|
|
89
|
+
|
|
90
|
+
A single component is the key itself; composite keys percent-encode each component and
|
|
91
|
+
join them with ``,`` so that rendering is unambiguous.
|
|
92
|
+
"""
|
|
93
|
+
if not components:
|
|
94
|
+
raise ValueError("a key needs at least one component")
|
|
95
|
+
if len(components) == 1:
|
|
96
|
+
return components[0]
|
|
97
|
+
return KEY_COMPONENT_SEPARATOR.join(quote(component, safe="") for component in components)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def record_key(kind: Kind, values: Mapping[str, JsonValue]) -> str | None:
|
|
101
|
+
"""The key of a Record of ``kind`` from its canonical primary key values.
|
|
102
|
+
|
|
103
|
+
Returns ``None`` when the Kind has no natural key or a primary key value is absent.
|
|
104
|
+
"""
|
|
105
|
+
if not kind.primary_key:
|
|
106
|
+
return None
|
|
107
|
+
components: list[str] = []
|
|
108
|
+
for attribute_id in kind.primary_key:
|
|
109
|
+
value = values.get(attribute_id)
|
|
110
|
+
attribute = kind.attributes.get(attribute_id)
|
|
111
|
+
if value is None or attribute is None:
|
|
112
|
+
return None
|
|
113
|
+
components.append(to_text(attribute.type, value))
|
|
114
|
+
return render_key(components)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def format_ref(kind_api_name: str, key: str) -> str:
|
|
118
|
+
"""External reference ``{kind_api_name}/{key}`` (design 5.1)."""
|
|
119
|
+
return f"{kind_api_name}{REF_SEPARATOR}{key}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_ref(ref: str) -> tuple[str, str]:
|
|
123
|
+
"""Split an external reference into ``(kind_api_name, key)``; keys may contain ``/``."""
|
|
124
|
+
kind_api_name, separator, key = ref.partition(REF_SEPARATOR)
|
|
125
|
+
if not separator or not kind_api_name or not key:
|
|
126
|
+
raise ValueError(f"not a Record reference: {ref!r}")
|
|
127
|
+
return kind_api_name, key
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Relation (design 5.5): a typed, directional connection between two Kinds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import ClassVar, Self
|
|
7
|
+
|
|
8
|
+
from pydantic import Field, model_validator
|
|
9
|
+
|
|
10
|
+
from lorebase_core.model.attribute import Attribute
|
|
11
|
+
from lorebase_core.model.base import ElementStatus, SchemaElement
|
|
12
|
+
from lorebase_core.model.ids import AttributeId, KindId, RelationId
|
|
13
|
+
from lorebase_core.model.kind import check_attribute_keys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Cardinality(StrEnum):
|
|
17
|
+
"""``x_to_y``: how many ``from`` Records (x) and ``to`` Records (y) one Link side allows.
|
|
18
|
+
|
|
19
|
+
``one_to_many``: each ``to`` Record has at most one incoming Link;
|
|
20
|
+
``many_to_one``: each ``from`` Record has at most one outgoing Link.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
ONE_TO_ONE = "one_to_one"
|
|
24
|
+
ONE_TO_MANY = "one_to_many"
|
|
25
|
+
MANY_TO_ONE = "many_to_one"
|
|
26
|
+
MANY_TO_MANY = "many_to_many"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OnDelete(StrEnum):
|
|
30
|
+
CASCADE = "cascade"
|
|
31
|
+
RESTRICT = "restrict"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RelationBacking(StrEnum):
|
|
35
|
+
"""``explicit``: Links are stored. ``foreign_key``: Links derive from a ``ref`` Attribute."""
|
|
36
|
+
|
|
37
|
+
EXPLICIT = "explicit"
|
|
38
|
+
FOREIGN_KEY = "foreign_key"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LinkDirection(StrEnum):
|
|
42
|
+
FORWARD = "forward"
|
|
43
|
+
INVERSE = "inverse"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Relation(SchemaElement):
|
|
47
|
+
object_type: ClassVar[str] = "schema.relation"
|
|
48
|
+
|
|
49
|
+
id: RelationId
|
|
50
|
+
inverse_api_name: str
|
|
51
|
+
from_kind: KindId
|
|
52
|
+
to_kind: KindId
|
|
53
|
+
cardinality: Cardinality = Cardinality.MANY_TO_MANY
|
|
54
|
+
required_from: bool = False
|
|
55
|
+
attributes: dict[AttributeId, Attribute] = Field(default_factory=dict)
|
|
56
|
+
on_delete: OnDelete = OnDelete.CASCADE
|
|
57
|
+
backing: RelationBacking = RelationBacking.EXPLICIT
|
|
58
|
+
#: For ``foreign_key`` backing: the ``ref`` Attribute of ``from_kind`` holding the ``to`` key.
|
|
59
|
+
foreign_key_attribute: AttributeId | None = None
|
|
60
|
+
status: ElementStatus = ElementStatus.ACTIVE
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def _check_keys(self) -> Self:
|
|
64
|
+
check_attribute_keys(self.attributes, f"Relation {self.id}")
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def single_per_from(self) -> bool:
|
|
69
|
+
"""Each ``from`` Record has at most one Link of this Relation."""
|
|
70
|
+
return self.cardinality in (Cardinality.ONE_TO_ONE, Cardinality.MANY_TO_ONE)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def single_per_to(self) -> bool:
|
|
74
|
+
"""Each ``to`` Record has at most one Link of this Relation."""
|
|
75
|
+
return self.cardinality in (Cardinality.ONE_TO_ONE, Cardinality.ONE_TO_MANY)
|
|
76
|
+
|
|
77
|
+
def endpoint(self, direction: LinkDirection) -> KindId:
|
|
78
|
+
"""The Kind a traversal in ``direction`` starts from."""
|
|
79
|
+
return self.from_kind if direction is LinkDirection.FORWARD else self.to_kind
|
|
80
|
+
|
|
81
|
+
def far_endpoint(self, direction: LinkDirection) -> KindId:
|
|
82
|
+
"""The Kind a traversal in ``direction`` arrives at."""
|
|
83
|
+
return self.to_kind if direction is LinkDirection.FORWARD else self.from_kind
|