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,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