lorebase-store 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_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- lorebase_store-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Data migrations implied by a schema change (design 6.6 step 2).
|
|
2
|
+
|
|
3
|
+
For every Kind or Relation whose definition changed, existing values are carried to the new
|
|
4
|
+
definition in the same Revision:
|
|
5
|
+
|
|
6
|
+
- values of removed Attributes are dropped;
|
|
7
|
+
- values of retyped Attributes are cast with the core cast rules; a value that cannot be cast
|
|
8
|
+
is an `XS3`-class violation naming the Record;
|
|
9
|
+
- Attributes that are new, or became required, receive their default when they have none;
|
|
10
|
+
- unchanged Records are re-validated (tightened constraints, new required Attributes) and
|
|
11
|
+
values of Attributes that became unique are checked for duplicates (`XS8` class).
|
|
12
|
+
|
|
13
|
+
Records and Links of removed Kinds and Relations are removed. Elements the commit itself
|
|
14
|
+
changes are left to the commit's own changes.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from pydantic import JsonValue
|
|
27
|
+
|
|
28
|
+
from lorebase_core.model import (
|
|
29
|
+
Attribute,
|
|
30
|
+
IdPrefix,
|
|
31
|
+
Link,
|
|
32
|
+
LinkIdentity,
|
|
33
|
+
ProvenanceEntry,
|
|
34
|
+
Record,
|
|
35
|
+
RecordIdentity,
|
|
36
|
+
Schema,
|
|
37
|
+
SchemaMigrationOrigin,
|
|
38
|
+
new_id,
|
|
39
|
+
)
|
|
40
|
+
from lorebase_core.types import CastError, TypeSpec, cast, format_timestamp
|
|
41
|
+
from lorebase_core.validate import (
|
|
42
|
+
DataChange,
|
|
43
|
+
Issue,
|
|
44
|
+
IssueCode,
|
|
45
|
+
LinkDelete,
|
|
46
|
+
LinkUpsert,
|
|
47
|
+
RecordDelete,
|
|
48
|
+
RecordUpsert,
|
|
49
|
+
validate_link,
|
|
50
|
+
validate_record,
|
|
51
|
+
)
|
|
52
|
+
from lorebase_store.commit.changes import Change
|
|
53
|
+
|
|
54
|
+
TYPE_COERCION_CLASS = "XS3"
|
|
55
|
+
UNIQUENESS_CLASS = "XS8"
|
|
56
|
+
_SAMPLE_SIZE = 10
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class MigrationPlan:
|
|
61
|
+
"""Changes implied by a schema change."""
|
|
62
|
+
|
|
63
|
+
#: Value migrations; validated with the commit's own changes.
|
|
64
|
+
changes: list[Change] = field(default_factory=list)
|
|
65
|
+
#: Records and Links of removed Kinds and Relations; not validated (their type is gone).
|
|
66
|
+
removals: list[DataChange] = field(default_factory=list)
|
|
67
|
+
provenance: list[ProvenanceEntry] = field(default_factory=list)
|
|
68
|
+
issues: list[Issue] = field(default_factory=list)
|
|
69
|
+
#: Kinds whose definition changed: titles and search vectors are recomputed.
|
|
70
|
+
refresh_kinds: list[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class _Attribution:
|
|
75
|
+
"""One provenance entry per migrated Attribute and type transition."""
|
|
76
|
+
|
|
77
|
+
actor: str
|
|
78
|
+
at: datetime
|
|
79
|
+
entries: dict[tuple[str, str | None, str | None], ProvenanceEntry] = field(default_factory=dict)
|
|
80
|
+
|
|
81
|
+
def entry(self, attribute_id: str, from_type: TypeSpec | None, to_type: TypeSpec | None) -> str:
|
|
82
|
+
memo = (
|
|
83
|
+
attribute_id,
|
|
84
|
+
None if from_type is None else from_type.describe(),
|
|
85
|
+
None if to_type is None else to_type.describe(),
|
|
86
|
+
)
|
|
87
|
+
found = self.entries.get(memo)
|
|
88
|
+
if found is None:
|
|
89
|
+
found = ProvenanceEntry(
|
|
90
|
+
id=new_id(IdPrefix.PROVENANCE),
|
|
91
|
+
origin=SchemaMigrationOrigin(
|
|
92
|
+
attribute_id=attribute_id, from_type=from_type, to_type=to_type
|
|
93
|
+
),
|
|
94
|
+
actor=self.actor,
|
|
95
|
+
at=format_timestamp(self.at),
|
|
96
|
+
)
|
|
97
|
+
self.entries[memo] = found
|
|
98
|
+
return found.id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def plan_migrations(
|
|
102
|
+
old: Schema,
|
|
103
|
+
new: Schema,
|
|
104
|
+
*,
|
|
105
|
+
records_of: Callable[[str], Iterable[Record]],
|
|
106
|
+
links_of: Callable[[str], Iterable[Link]],
|
|
107
|
+
skip: Iterable[RecordIdentity | LinkIdentity] = (),
|
|
108
|
+
actor: str,
|
|
109
|
+
at: datetime,
|
|
110
|
+
sensitive: Callable[[Attribute], bool] = lambda a: a.is_sensitive,
|
|
111
|
+
) -> MigrationPlan:
|
|
112
|
+
"""Data changes that carry existing values from `old` to `new`. Issues never quote
|
|
113
|
+
values of `sensitive` Attributes (by default, those with tags in the schemas)."""
|
|
114
|
+
plan = MigrationPlan()
|
|
115
|
+
attribution = _Attribution(actor, at)
|
|
116
|
+
skipped = set(skip)
|
|
117
|
+
for kind_id in sorted(old.kinds.keys() - new.kinds.keys()):
|
|
118
|
+
plan.removals.extend(
|
|
119
|
+
RecordDelete(kind=kind_id, key=record.key) for record in records_of(kind_id)
|
|
120
|
+
)
|
|
121
|
+
for relation_id in sorted(old.relations.keys() - new.relations.keys()):
|
|
122
|
+
plan.removals.extend(
|
|
123
|
+
LinkDelete(relation=relation_id, from_key=link.from_key, to_key=link.to_key)
|
|
124
|
+
for link in links_of(relation_id)
|
|
125
|
+
)
|
|
126
|
+
for kind_id in sorted(old.kinds.keys() & new.kinds.keys()):
|
|
127
|
+
old_kind, new_kind = old.kinds[kind_id], new.kinds[kind_id]
|
|
128
|
+
if old_kind == new_kind:
|
|
129
|
+
continue
|
|
130
|
+
plan.refresh_kinds.append(kind_id)
|
|
131
|
+
plan.issues.extend(_vault_changes(kind_id, old_kind.attributes, new_kind.attributes))
|
|
132
|
+
unique = [
|
|
133
|
+
a
|
|
134
|
+
for a in new_kind.attributes.values()
|
|
135
|
+
if a.unique and not (a.id in old_kind.attributes and old_kind.attributes[a.id].unique)
|
|
136
|
+
]
|
|
137
|
+
holders: dict[tuple[str, str], list[str]] = defaultdict(list)
|
|
138
|
+
for record in records_of(kind_id):
|
|
139
|
+
if record.identity in skipped:
|
|
140
|
+
continue
|
|
141
|
+
values, provenance, issues = _migrate_values(
|
|
142
|
+
old_kind.attributes, new_kind.attributes, record.values, attribution, sensitive
|
|
143
|
+
)
|
|
144
|
+
for issue in issues:
|
|
145
|
+
plan.issues.append(
|
|
146
|
+
issue.model_copy(
|
|
147
|
+
update={
|
|
148
|
+
"kind": kind_id,
|
|
149
|
+
"key": record.key,
|
|
150
|
+
"path": ("migrations", kind_id, record.key, *issue.path),
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
migrated = Record(kind=kind_id, key=record.key, values=values)
|
|
155
|
+
# A Record with a failed cast is already reported; validating it would repeat it.
|
|
156
|
+
if provenance and not issues:
|
|
157
|
+
plan.changes.append(Change(RecordUpsert(record=migrated), provenance))
|
|
158
|
+
elif not issues:
|
|
159
|
+
plan.issues.extend(
|
|
160
|
+
validate_record(new, migrated, path=("migrations", kind_id, record.key))
|
|
161
|
+
)
|
|
162
|
+
for attribute in unique:
|
|
163
|
+
value = migrated.values.get(attribute.id)
|
|
164
|
+
if value is not None:
|
|
165
|
+
holders[(attribute.id, _value_text(value))].append(record.key)
|
|
166
|
+
plan.issues.extend(_duplicates(kind_id, unique, holders, sensitive))
|
|
167
|
+
for relation_id in sorted(old.relations.keys() & new.relations.keys()):
|
|
168
|
+
old_relation, new_relation = old.relations[relation_id], new.relations[relation_id]
|
|
169
|
+
if old_relation.attributes == new_relation.attributes:
|
|
170
|
+
continue
|
|
171
|
+
for link in links_of(relation_id):
|
|
172
|
+
if link.identity in skipped:
|
|
173
|
+
continue
|
|
174
|
+
values, provenance, issues = _migrate_values(
|
|
175
|
+
old_relation.attributes,
|
|
176
|
+
new_relation.attributes,
|
|
177
|
+
link.values,
|
|
178
|
+
attribution,
|
|
179
|
+
sensitive,
|
|
180
|
+
)
|
|
181
|
+
plan.issues.extend(
|
|
182
|
+
issue.model_copy(
|
|
183
|
+
update={
|
|
184
|
+
"relation": relation_id,
|
|
185
|
+
"path": (
|
|
186
|
+
"migrations",
|
|
187
|
+
relation_id,
|
|
188
|
+
link.from_key,
|
|
189
|
+
link.to_key,
|
|
190
|
+
*issue.path,
|
|
191
|
+
),
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
for issue in issues
|
|
195
|
+
)
|
|
196
|
+
migrated_link = link.model_copy(update={"values": values})
|
|
197
|
+
if issues:
|
|
198
|
+
continue
|
|
199
|
+
if provenance:
|
|
200
|
+
plan.changes.append(Change(LinkUpsert(link=migrated_link), provenance))
|
|
201
|
+
else:
|
|
202
|
+
plan.issues.extend(
|
|
203
|
+
validate_link(new, migrated_link, path=("migrations", relation_id))
|
|
204
|
+
)
|
|
205
|
+
plan.provenance = list(attribution.entries.values())
|
|
206
|
+
return plan
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _migrate_values(
|
|
210
|
+
old_attributes: Mapping[str, Attribute],
|
|
211
|
+
new_attributes: Mapping[str, Attribute],
|
|
212
|
+
values: Mapping[str, JsonValue],
|
|
213
|
+
attribution: _Attribution,
|
|
214
|
+
is_sensitive: Callable[[Attribute], bool],
|
|
215
|
+
) -> tuple[dict[str, JsonValue], dict[str, str], list[Issue]]:
|
|
216
|
+
migrated: dict[str, JsonValue] = {}
|
|
217
|
+
provenance: dict[str, str] = {}
|
|
218
|
+
issues: list[Issue] = []
|
|
219
|
+
for attribute_id, value in values.items():
|
|
220
|
+
attribute = new_attributes.get(attribute_id)
|
|
221
|
+
before = old_attributes.get(attribute_id)
|
|
222
|
+
if attribute is None:
|
|
223
|
+
provenance[attribute_id] = attribution.entry(
|
|
224
|
+
attribute_id, None if before is None else before.type, None
|
|
225
|
+
)
|
|
226
|
+
continue
|
|
227
|
+
if before is None or before.type == attribute.type:
|
|
228
|
+
migrated[attribute_id] = value
|
|
229
|
+
continue
|
|
230
|
+
try:
|
|
231
|
+
converted: Any = cast(before.type, attribute.type, value)
|
|
232
|
+
except CastError as error:
|
|
233
|
+
# Sensitive values are not quoted: the caller may lack the clearance to read them.
|
|
234
|
+
sensitive = is_sensitive(before) or is_sensitive(attribute)
|
|
235
|
+
details: dict[str, JsonValue] = {
|
|
236
|
+
"class": TYPE_COERCION_CLASS,
|
|
237
|
+
"from_type": before.type.describe(),
|
|
238
|
+
"to_type": attribute.type.describe(),
|
|
239
|
+
}
|
|
240
|
+
if not sensitive:
|
|
241
|
+
details["value"] = value
|
|
242
|
+
shown = "a value" if sensitive else f"value {value!r}"
|
|
243
|
+
issues.append(
|
|
244
|
+
Issue(
|
|
245
|
+
code=IssueCode.VALUE_TYPE,
|
|
246
|
+
path=("values", attribute_id),
|
|
247
|
+
message=(
|
|
248
|
+
f"{attribute.api_name!r} {shown} cannot be cast from "
|
|
249
|
+
f"{before.type.describe()} to {attribute.type.describe()}"
|
|
250
|
+
+ ("" if sensitive else f": {error.message}")
|
|
251
|
+
),
|
|
252
|
+
attribute=attribute_id,
|
|
253
|
+
details=details,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
migrated[attribute_id] = value
|
|
257
|
+
continue
|
|
258
|
+
if converted is not None:
|
|
259
|
+
migrated[attribute_id] = converted
|
|
260
|
+
if converted != value:
|
|
261
|
+
provenance[attribute_id] = attribution.entry(attribute_id, before.type, attribute.type)
|
|
262
|
+
for attribute in new_attributes.values():
|
|
263
|
+
if attribute.id in migrated or attribute.default is None:
|
|
264
|
+
continue
|
|
265
|
+
before = old_attributes.get(attribute.id)
|
|
266
|
+
if before is None or (attribute.required and not before.required):
|
|
267
|
+
migrated[attribute.id] = attribute.default
|
|
268
|
+
provenance[attribute.id] = attribution.entry(attribute.id, None, attribute.type)
|
|
269
|
+
return migrated, provenance, issues
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _value_text(value: JsonValue) -> str:
|
|
273
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _vault_changes(
|
|
277
|
+
kind_id: str, old: Mapping[str, Attribute], new: Mapping[str, Attribute]
|
|
278
|
+
) -> list[Issue]:
|
|
279
|
+
"""Vault storage is chosen when an Attribute is created: turning it on or off, or
|
|
280
|
+
retyping a vault Attribute, would leave Revisions holding values the other way."""
|
|
281
|
+
issues: list[Issue] = []
|
|
282
|
+
for attribute_id, attribute in new.items():
|
|
283
|
+
before = old.get(attribute_id)
|
|
284
|
+
if before is None:
|
|
285
|
+
continue
|
|
286
|
+
if before.in_vault != attribute.in_vault:
|
|
287
|
+
message = "vault storage cannot be turned on or off; add a new attribute instead"
|
|
288
|
+
elif attribute.in_vault and before.type != attribute.type:
|
|
289
|
+
message = "the type of a vault attribute cannot change; add a new attribute instead"
|
|
290
|
+
else:
|
|
291
|
+
continue
|
|
292
|
+
issues.append(
|
|
293
|
+
Issue(
|
|
294
|
+
code=IssueCode.SCHEMA_VAULT_INVALID,
|
|
295
|
+
path=("kinds", kind_id, "attributes", attribute_id, "vault"),
|
|
296
|
+
message=f"{attribute.api_name!r}: {message}",
|
|
297
|
+
kind=kind_id,
|
|
298
|
+
attribute=attribute_id,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
return issues
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _duplicates(
|
|
305
|
+
kind_id: str,
|
|
306
|
+
unique: list[Attribute],
|
|
307
|
+
holders: Mapping[tuple[str, str], list[str]],
|
|
308
|
+
is_sensitive: Callable[[Attribute], bool],
|
|
309
|
+
) -> list[Issue]:
|
|
310
|
+
names = {attribute.id: attribute.api_name for attribute in unique}
|
|
311
|
+
sensitive = {attribute.id for attribute in unique if is_sensitive(attribute)}
|
|
312
|
+
issues: list[Issue] = []
|
|
313
|
+
for (attribute_id, value), keys in sorted(holders.items()):
|
|
314
|
+
if len(keys) < 2:
|
|
315
|
+
continue
|
|
316
|
+
ordered = sorted(keys)
|
|
317
|
+
issues.append(
|
|
318
|
+
Issue(
|
|
319
|
+
code=IssueCode.VALUE_UNIQUE,
|
|
320
|
+
path=("migrations", kind_id, ordered[0], "values", attribute_id),
|
|
321
|
+
message=(
|
|
322
|
+
f"{names[attribute_id]!r} became unique but {len(keys)} Records share "
|
|
323
|
+
+ ("a value" if attribute_id in sensitive else f"the value {value}")
|
|
324
|
+
),
|
|
325
|
+
kind=kind_id,
|
|
326
|
+
key=ordered[0],
|
|
327
|
+
attribute=attribute_id,
|
|
328
|
+
details={
|
|
329
|
+
"class": UNIQUENESS_CLASS,
|
|
330
|
+
"count": len(keys),
|
|
331
|
+
"keys": list(ordered[:_SAMPLE_SIZE]),
|
|
332
|
+
},
|
|
333
|
+
)
|
|
334
|
+
)
|
|
335
|
+
return issues
|