lorebase-keeper 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_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""Built-in Operations (design 5.7): definitions, parameter binding and the edits they make.
|
|
2
|
+
|
|
3
|
+
For every Kind with the built-in enabled: `create_{kind}`, `update_{kind}`, `delete_{kind}`;
|
|
4
|
+
for every explicit Relation: `link_{relation}`, `unlink_{relation}`; the bulk upserts
|
|
5
|
+
`import_records`, `import_links` and `import_batch`; and the bulk delete `delete_records`.
|
|
6
|
+
Their parameters are derived from the
|
|
7
|
+
schema, so a definition always matches the Revision it is read from:
|
|
8
|
+
|
|
9
|
+
| Operation | Parameters |
|
|
10
|
+
|-----------|------------|
|
|
11
|
+
| `create_{kind}` | one per stored Attribute; `key` for Kinds without a natural key |
|
|
12
|
+
| `update_{kind}` | `key`, then every stored Attribute as optional; `null` removes a value |
|
|
13
|
+
| `delete_{kind}` | `key` |
|
|
14
|
+
| `link_{relation}` | `from`, `to` (Record keys), then the link Attributes |
|
|
15
|
+
| `unlink_{relation}` | `from`, `to` |
|
|
16
|
+
| `import_records` | `kind` (api name), `rows` (objects keyed by Attribute api name; `links`) |
|
|
17
|
+
| `import_links` | `relation` (forward api name), `rows` (`from`, `to` and link Attributes) |
|
|
18
|
+
| `import_batch` | `records` (Record rows with `kind`), `links` (Link rows with `relation`) |
|
|
19
|
+
| `delete_records` | `kind` (api name), `keys` (Record keys), `skip_missing` (optional boolean) |
|
|
20
|
+
|
|
21
|
+
A Record row's `links` maps a Relation name (forward from the Record's Kind, or inverse when the
|
|
22
|
+
Kind is its `to` end) to the key at the other end, an object with `key` and link Attributes, or
|
|
23
|
+
a list of these. A Record and its Links are one unit: bulk imports commit in batches of whole
|
|
24
|
+
units, so a Relation that is `required_from` is satisfied in the Revision that creates the
|
|
25
|
+
Record. Units of Records without Links come first, then Records with Links, then Links of
|
|
26
|
+
Records not in the import, so Links usually point to Records committed in an earlier or the
|
|
27
|
+
same batch.
|
|
28
|
+
|
|
29
|
+
`delete_records` deletes Records of one Kind whose `delete` built-in is enabled, all in one
|
|
30
|
+
Revision (it is not an import: no batching). Keys that do not exist fail the call, or with
|
|
31
|
+
`skip_missing` are left out and reported as warnings. Links of the deleted Records follow each
|
|
32
|
+
Relation's `on_delete` policy as for `delete_{kind}`. A `delete_{kind}` of a Kind whose snake
|
|
33
|
+
case name is `records` takes precedence over the bulk delete.
|
|
34
|
+
|
|
35
|
+
Everything here is pure: reads of existing Records go through `RecordReader`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from enum import StrEnum
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from pydantic import JsonValue
|
|
46
|
+
|
|
47
|
+
from lorebase_core.model import (
|
|
48
|
+
Attribute,
|
|
49
|
+
Kind,
|
|
50
|
+
Link,
|
|
51
|
+
LinkDirection,
|
|
52
|
+
Record,
|
|
53
|
+
Relation,
|
|
54
|
+
RelationBacking,
|
|
55
|
+
Schema,
|
|
56
|
+
builtin_link_operation_names,
|
|
57
|
+
builtin_operation_names,
|
|
58
|
+
new_ulid,
|
|
59
|
+
record_key,
|
|
60
|
+
snake_case,
|
|
61
|
+
)
|
|
62
|
+
from lorebase_core.types import AttributeType, TypeSpec, TypeSystemError, encode_value
|
|
63
|
+
from lorebase_core.types.encoding import normalize_key
|
|
64
|
+
from lorebase_core.validate import (
|
|
65
|
+
DataChange,
|
|
66
|
+
IssueCode,
|
|
67
|
+
LinkDelete,
|
|
68
|
+
LinkUpsert,
|
|
69
|
+
RecordDelete,
|
|
70
|
+
RecordUpsert,
|
|
71
|
+
)
|
|
72
|
+
from lorebase_keeper.reading.sensitivity import UNCLEARED, Masking
|
|
73
|
+
from lorebase_keeper.services.errors import Finding
|
|
74
|
+
|
|
75
|
+
IMPORT_RECORDS = "import_records"
|
|
76
|
+
IMPORT_LINKS = "import_links"
|
|
77
|
+
IMPORT_BATCH = "import_batch"
|
|
78
|
+
DELETE_RECORDS = "delete_records"
|
|
79
|
+
KEY = "key"
|
|
80
|
+
KEYS = "keys"
|
|
81
|
+
SKIP_MISSING = "skip_missing"
|
|
82
|
+
FROM = "from"
|
|
83
|
+
TO = "to"
|
|
84
|
+
LINKS = "links"
|
|
85
|
+
KIND = "kind"
|
|
86
|
+
RELATION = "relation"
|
|
87
|
+
|
|
88
|
+
PARAMETER_UNKNOWN = "parameter.unknown"
|
|
89
|
+
PARAMETER_REQUIRED = "parameter.required"
|
|
90
|
+
PARAMETER_INVALID = "parameter.invalid"
|
|
91
|
+
RECORD_EXISTS = "record.exists"
|
|
92
|
+
LIMIT_EXCEEDED = "parameter.limit_exceeded"
|
|
93
|
+
|
|
94
|
+
RecordReader = Callable[[str, Sequence[str]], Awaitable[Mapping[str, Mapping[str, JsonValue]]]]
|
|
95
|
+
"""Current values (Attribute id keyed) of Records of a Kind by key; absent keys do not exist."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Action(StrEnum):
|
|
99
|
+
CREATE = "create"
|
|
100
|
+
UPDATE = "update"
|
|
101
|
+
DELETE = "delete"
|
|
102
|
+
LINK = "link"
|
|
103
|
+
UNLINK = "unlink"
|
|
104
|
+
IMPORT_RECORDS = "import_records"
|
|
105
|
+
IMPORT_LINKS = "import_links"
|
|
106
|
+
IMPORT_BATCH = "import_batch"
|
|
107
|
+
DELETE_RECORDS = "delete_records"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class Parameter:
|
|
112
|
+
"""A parameter: `type` is a Lorebase type, or `None` for structured JSON (`rows`)."""
|
|
113
|
+
|
|
114
|
+
name: str
|
|
115
|
+
type: TypeSpec | None
|
|
116
|
+
required: bool
|
|
117
|
+
description: str = ""
|
|
118
|
+
attribute: str | None = None
|
|
119
|
+
|
|
120
|
+
def document(self) -> dict[str, Any]:
|
|
121
|
+
return {
|
|
122
|
+
"name": self.name,
|
|
123
|
+
"type": "json"
|
|
124
|
+
if self.type is None
|
|
125
|
+
else self.type.model_dump(mode="json", exclude_none=True),
|
|
126
|
+
"required": self.required,
|
|
127
|
+
"description": self.description,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class BuiltinOperation:
|
|
133
|
+
"""A built-in Operation of one Kind or Relation (or the bulk imports)."""
|
|
134
|
+
|
|
135
|
+
name: str
|
|
136
|
+
action: Action
|
|
137
|
+
description: str
|
|
138
|
+
parameters: tuple[Parameter, ...]
|
|
139
|
+
kind: Kind | None = None
|
|
140
|
+
relation: Relation | None = None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def is_import(self) -> bool:
|
|
144
|
+
return self.action in (Action.IMPORT_RECORDS, Action.IMPORT_LINKS, Action.IMPORT_BATCH)
|
|
145
|
+
|
|
146
|
+
def document(self) -> dict[str, Any]:
|
|
147
|
+
"""Description served by the schema API, shaped like a stored Operation."""
|
|
148
|
+
return {
|
|
149
|
+
"api_name": self.name,
|
|
150
|
+
"builtin": True,
|
|
151
|
+
"description": self.description,
|
|
152
|
+
"parameters": [p.document() for p in self.parameters],
|
|
153
|
+
"review_policy": {"mode": "never"},
|
|
154
|
+
"status": "active",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_STRING = TypeSpec.of(AttributeType.STRING)
|
|
159
|
+
_BOOLEAN = TypeSpec.of(AttributeType.BOOLEAN)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _stored(attributes: Mapping[str, Attribute]) -> list[Attribute]:
|
|
163
|
+
return [a for a in attributes.values() if not a.is_derived]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _attribute_parameters(
|
|
167
|
+
attributes: Mapping[str, Attribute], *, required: bool
|
|
168
|
+
) -> list[Parameter]:
|
|
169
|
+
return [
|
|
170
|
+
Parameter(a.api_name, a.type, required and a.required, a.description, a.id)
|
|
171
|
+
for a in _stored(attributes)
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def builtin_definition(schema: Schema, name: str) -> BuiltinOperation | None:
|
|
176
|
+
"""The built-in Operation called `name` in `schema`, if any."""
|
|
177
|
+
if name == IMPORT_RECORDS:
|
|
178
|
+
return BuiltinOperation(
|
|
179
|
+
name,
|
|
180
|
+
Action.IMPORT_RECORDS,
|
|
181
|
+
"Create or replace many Records of one Kind. Rows are objects keyed by Attribute "
|
|
182
|
+
"api name; a row's `links` maps Relation names to the keys at the other end, "
|
|
183
|
+
"committed with the Record. Large imports are committed in batches.",
|
|
184
|
+
(
|
|
185
|
+
Parameter("kind", _STRING, True, "Kind api name."),
|
|
186
|
+
Parameter("rows", None, True, "Records to upsert."),
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
if name == IMPORT_LINKS:
|
|
190
|
+
return BuiltinOperation(
|
|
191
|
+
name,
|
|
192
|
+
Action.IMPORT_LINKS,
|
|
193
|
+
"Create or replace many Links of one Relation. Rows hold `from`, `to` and link "
|
|
194
|
+
"Attributes by api name.",
|
|
195
|
+
(
|
|
196
|
+
Parameter("relation", _STRING, True, "Forward api name of the Relation."),
|
|
197
|
+
Parameter("rows", None, True, "Links to upsert."),
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
if name == IMPORT_BATCH:
|
|
201
|
+
return BuiltinOperation(
|
|
202
|
+
name,
|
|
203
|
+
Action.IMPORT_BATCH,
|
|
204
|
+
"Create or replace Records of several Kinds and Links of several Relations in one "
|
|
205
|
+
"call. `records` rows name their `kind` and may hold `links`; `links` rows name "
|
|
206
|
+
"their `relation`. A Record is committed in the same Revision as its Links.",
|
|
207
|
+
(
|
|
208
|
+
Parameter("records", None, False, "Records to upsert, each with `kind`."),
|
|
209
|
+
Parameter("links", None, False, "Links to upsert, each with `relation`."),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
for kind in schema.kinds.values():
|
|
213
|
+
if name not in builtin_operation_names(kind):
|
|
214
|
+
continue
|
|
215
|
+
action = Action(name.split("_", 1)[0])
|
|
216
|
+
key = Parameter(KEY, _STRING, True, f"Key of the {kind.api_name} Record.")
|
|
217
|
+
if action is Action.CREATE:
|
|
218
|
+
parameters = _attribute_parameters(kind.attributes, required=True)
|
|
219
|
+
if not kind.has_natural_key:
|
|
220
|
+
parameters.insert(0, Parameter(KEY, _STRING, False, "Key; minted when absent."))
|
|
221
|
+
text = f"Create a {kind.api_name} Record."
|
|
222
|
+
elif action is Action.UPDATE:
|
|
223
|
+
parameters = [key, *_attribute_parameters(kind.attributes, required=False)]
|
|
224
|
+
text = f"Change Attributes of a {kind.api_name} Record; null removes a value."
|
|
225
|
+
else:
|
|
226
|
+
parameters = [key]
|
|
227
|
+
text = f"Delete a {kind.api_name} Record and, per Relation policy, its Links."
|
|
228
|
+
return BuiltinOperation(name, action, text, tuple(parameters), kind=kind)
|
|
229
|
+
if name == DELETE_RECORDS:
|
|
230
|
+
return BuiltinOperation(
|
|
231
|
+
name,
|
|
232
|
+
Action.DELETE_RECORDS,
|
|
233
|
+
"Delete many Records of one Kind in one Revision and, per Relation policy, their "
|
|
234
|
+
"Links. Unknown keys fail the call unless `skip_missing` is true.",
|
|
235
|
+
(
|
|
236
|
+
Parameter(KIND, _STRING, True, "Kind api name."),
|
|
237
|
+
Parameter(KEYS, None, True, "Keys of the Records to delete."),
|
|
238
|
+
Parameter(
|
|
239
|
+
SKIP_MISSING,
|
|
240
|
+
_BOOLEAN,
|
|
241
|
+
False,
|
|
242
|
+
"Leave out keys that do not exist (reported as warnings) instead of failing.",
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
for relation in schema.relations.values():
|
|
247
|
+
if relation.backing is not RelationBacking.EXPLICIT:
|
|
248
|
+
continue
|
|
249
|
+
if name not in builtin_link_operation_names(relation):
|
|
250
|
+
continue
|
|
251
|
+
action = Action.LINK if name.startswith("link_") else Action.UNLINK
|
|
252
|
+
ends = [
|
|
253
|
+
Parameter(FROM, _STRING, True, "Key of the `from` Record."),
|
|
254
|
+
Parameter(TO, _STRING, True, "Key of the `to` Record."),
|
|
255
|
+
]
|
|
256
|
+
if action is Action.LINK:
|
|
257
|
+
ends.extend(_attribute_parameters(relation.attributes, required=True))
|
|
258
|
+
text = f"Link two Records through {relation.api_name}."
|
|
259
|
+
else:
|
|
260
|
+
text = f"Remove a {relation.api_name} Link."
|
|
261
|
+
return BuiltinOperation(name, action, text, tuple(ends), relation=relation)
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# Parameter binding ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass
|
|
269
|
+
class Bound:
|
|
270
|
+
"""Canonical parameter values and the findings of binding them."""
|
|
271
|
+
|
|
272
|
+
values: dict[str, JsonValue] = field(default_factory=dict)
|
|
273
|
+
findings: list[Finding] = field(default_factory=list)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _encode(
|
|
277
|
+
spec: TypeSpec | None, raw: JsonValue, path: tuple[str | int, ...], bound: Bound
|
|
278
|
+
) -> JsonValue:
|
|
279
|
+
if spec is None:
|
|
280
|
+
return raw
|
|
281
|
+
try:
|
|
282
|
+
return encode_value(spec, raw)
|
|
283
|
+
except (TypeSystemError, ValueError) as error:
|
|
284
|
+
bound.findings.append(Finding(PARAMETER_INVALID, str(error), path))
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def bind(
|
|
289
|
+
definition: BuiltinOperation, parameters: Mapping[str, JsonValue], *, allow_null: bool = False
|
|
290
|
+
) -> Bound:
|
|
291
|
+
"""Validate parameters against the definition and encode them to canonical values."""
|
|
292
|
+
bound = Bound()
|
|
293
|
+
known = {p.name: p for p in definition.parameters}
|
|
294
|
+
for name in parameters:
|
|
295
|
+
if name not in known:
|
|
296
|
+
bound.findings.append(
|
|
297
|
+
Finding(PARAMETER_UNKNOWN, f"unknown parameter {name!r}", ("parameters", name))
|
|
298
|
+
)
|
|
299
|
+
nullable = allow_null or definition.action is Action.UPDATE
|
|
300
|
+
for parameter in definition.parameters:
|
|
301
|
+
path: tuple[str | int, ...] = ("parameters", parameter.name)
|
|
302
|
+
if parameter.name not in parameters or (
|
|
303
|
+
parameters[parameter.name] is None and not nullable
|
|
304
|
+
):
|
|
305
|
+
if parameter.required:
|
|
306
|
+
bound.findings.append(
|
|
307
|
+
Finding(PARAMETER_REQUIRED, f"{parameter.name!r} is required", path)
|
|
308
|
+
)
|
|
309
|
+
continue
|
|
310
|
+
raw = parameters[parameter.name]
|
|
311
|
+
if raw is None:
|
|
312
|
+
if parameter.required:
|
|
313
|
+
bound.findings.append(
|
|
314
|
+
Finding(PARAMETER_REQUIRED, f"{parameter.name!r} cannot be null", path)
|
|
315
|
+
)
|
|
316
|
+
continue
|
|
317
|
+
bound.values[parameter.name] = None
|
|
318
|
+
continue
|
|
319
|
+
bound.values[parameter.name] = _encode(parameter.type, raw, path, bound)
|
|
320
|
+
return bound
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# Edits ----------------------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass
|
|
327
|
+
class Plan:
|
|
328
|
+
"""Data changes computed from bound parameters, or the findings preventing them.
|
|
329
|
+
|
|
330
|
+
`units` lists the sizes of consecutive runs of `changes` that must be committed in the
|
|
331
|
+
same Revision (a Record and its Links); empty means every change stands alone.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
changes: list[DataChange] = field(default_factory=list)
|
|
335
|
+
findings: list[Finding] = field(default_factory=list)
|
|
336
|
+
units: list[int] = field(default_factory=list)
|
|
337
|
+
warnings: list[Finding] = field(default_factory=list)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _values(
|
|
341
|
+
attributes: Mapping[str, Attribute], bound: Mapping[str, JsonValue]
|
|
342
|
+
) -> dict[str, JsonValue]:
|
|
343
|
+
by_name = {a.api_name: a for a in _stored(attributes)}
|
|
344
|
+
return {by_name[name].id: value for name, value in bound.items() if name in by_name}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _key(text: JsonValue, path: tuple[str | int, ...], plan: Plan) -> str | None:
|
|
348
|
+
try:
|
|
349
|
+
return normalize_key(text)
|
|
350
|
+
except (TypeSystemError, ValueError) as error:
|
|
351
|
+
plan.findings.append(Finding(PARAMETER_INVALID, str(error), path))
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
async def plan_edits(
|
|
356
|
+
definition: BuiltinOperation,
|
|
357
|
+
bound: Mapping[str, JsonValue],
|
|
358
|
+
read: RecordReader,
|
|
359
|
+
) -> Plan:
|
|
360
|
+
"""The data changes of a single-Record or single-Link built-in."""
|
|
361
|
+
plan = Plan()
|
|
362
|
+
if definition.kind is not None:
|
|
363
|
+
await _record_edit(definition, definition.kind, bound, read, plan)
|
|
364
|
+
elif definition.relation is not None:
|
|
365
|
+
_link_edit(definition, definition.relation, bound, plan)
|
|
366
|
+
return plan
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def _record_edit(
|
|
370
|
+
definition: BuiltinOperation,
|
|
371
|
+
kind: Kind,
|
|
372
|
+
bound: Mapping[str, JsonValue],
|
|
373
|
+
read: RecordReader,
|
|
374
|
+
plan: Plan,
|
|
375
|
+
) -> None:
|
|
376
|
+
values = _values(kind.attributes, bound)
|
|
377
|
+
if definition.action is Action.CREATE:
|
|
378
|
+
key = record_key(kind, values) if kind.has_natural_key else None
|
|
379
|
+
if key is None:
|
|
380
|
+
given = bound.get(KEY)
|
|
381
|
+
key = _key(given, ("parameters", KEY), plan) if given is not None else new_ulid()
|
|
382
|
+
if key is None:
|
|
383
|
+
return
|
|
384
|
+
if key in await read(kind.id, [key]):
|
|
385
|
+
plan.findings.append(
|
|
386
|
+
Finding(
|
|
387
|
+
RECORD_EXISTS,
|
|
388
|
+
f"{kind.api_name}/{key} exists already",
|
|
389
|
+
(),
|
|
390
|
+
kind=kind.id,
|
|
391
|
+
key=key,
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
return
|
|
395
|
+
plan.changes.append(RecordUpsert(record=Record(kind=kind.id, key=key, values=values)))
|
|
396
|
+
return
|
|
397
|
+
key = _key(bound.get(KEY), ("parameters", KEY), plan)
|
|
398
|
+
if key is None:
|
|
399
|
+
return
|
|
400
|
+
if definition.action is Action.DELETE:
|
|
401
|
+
plan.changes.append(RecordDelete(kind=kind.id, key=key))
|
|
402
|
+
return
|
|
403
|
+
current = (await read(kind.id, [key])).get(key)
|
|
404
|
+
if current is None:
|
|
405
|
+
plan.findings.append(
|
|
406
|
+
Finding(
|
|
407
|
+
IssueCode.RECORD_NOT_FOUND.value,
|
|
408
|
+
f"no {kind.api_name} Record {key!r}",
|
|
409
|
+
("parameters", KEY),
|
|
410
|
+
kind=kind.id,
|
|
411
|
+
key=key,
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
return
|
|
415
|
+
merged: dict[str, JsonValue] = dict(current)
|
|
416
|
+
for attribute_id, value in values.items():
|
|
417
|
+
if value is None:
|
|
418
|
+
merged.pop(attribute_id, None)
|
|
419
|
+
else:
|
|
420
|
+
merged[attribute_id] = value
|
|
421
|
+
plan.changes.append(RecordUpsert(record=Record(kind=kind.id, key=key, values=merged)))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _link_edit(
|
|
425
|
+
definition: BuiltinOperation, relation: Relation, bound: Mapping[str, JsonValue], plan: Plan
|
|
426
|
+
) -> None:
|
|
427
|
+
from_key = _key(bound.get(FROM), ("parameters", FROM), plan)
|
|
428
|
+
to_key = _key(bound.get(TO), ("parameters", TO), plan)
|
|
429
|
+
if from_key is None or to_key is None:
|
|
430
|
+
return
|
|
431
|
+
if definition.action is Action.UNLINK:
|
|
432
|
+
plan.changes.append(LinkDelete(relation=relation.id, from_key=from_key, to_key=to_key))
|
|
433
|
+
return
|
|
434
|
+
values = _values(relation.attributes, bound)
|
|
435
|
+
plan.changes.append(
|
|
436
|
+
LinkUpsert(link=Link(relation=relation.id, from_key=from_key, to_key=to_key, values=values))
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# Bulk delete ----------------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
async def delete_keys(
|
|
444
|
+
schema: Schema, bound: Mapping[str, JsonValue], read: RecordReader, *, max_keys: int
|
|
445
|
+
) -> Plan:
|
|
446
|
+
"""The deletes of `delete_records`, one per distinct key, or the findings of bad keys."""
|
|
447
|
+
plan = Plan()
|
|
448
|
+
kind = schema.kind_by_api_name(str(bound.get(KIND)))
|
|
449
|
+
if kind is None:
|
|
450
|
+
plan.findings.append(Finding(PARAMETER_INVALID, "unknown Kind", ("parameters", KIND)))
|
|
451
|
+
return plan
|
|
452
|
+
if f"{Action.DELETE.value}_{snake_case(kind.api_name)}" not in builtin_operation_names(kind):
|
|
453
|
+
plan.findings.append(
|
|
454
|
+
Finding(
|
|
455
|
+
PARAMETER_INVALID,
|
|
456
|
+
f"the delete built-in is not enabled for {kind.api_name}",
|
|
457
|
+
("parameters", KIND),
|
|
458
|
+
kind=kind.id,
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
return plan
|
|
462
|
+
raw = bound.get(KEYS)
|
|
463
|
+
if not isinstance(raw, list) or not raw:
|
|
464
|
+
plan.findings.append(
|
|
465
|
+
Finding(PARAMETER_INVALID, "must be a non-empty list of keys", ("parameters", KEYS))
|
|
466
|
+
)
|
|
467
|
+
return plan
|
|
468
|
+
if len(raw) > max_keys:
|
|
469
|
+
plan.findings.append(
|
|
470
|
+
Finding(LIMIT_EXCEEDED, f"at most {max_keys} keys per call", ("parameters", KEYS))
|
|
471
|
+
)
|
|
472
|
+
return plan
|
|
473
|
+
keys: dict[str, int] = {}
|
|
474
|
+
for index, text in enumerate(raw):
|
|
475
|
+
key = _key(text, ("parameters", KEYS, index), plan)
|
|
476
|
+
if key is not None:
|
|
477
|
+
keys.setdefault(key, index)
|
|
478
|
+
if plan.findings:
|
|
479
|
+
return plan
|
|
480
|
+
found = await read(kind.id, list(keys))
|
|
481
|
+
skip = bound.get(SKIP_MISSING) is True
|
|
482
|
+
for key, index in keys.items():
|
|
483
|
+
if key in found:
|
|
484
|
+
plan.changes.append(RecordDelete(kind=kind.id, key=key))
|
|
485
|
+
continue
|
|
486
|
+
missing = Finding(
|
|
487
|
+
IssueCode.RECORD_NOT_FOUND.value,
|
|
488
|
+
f"no {kind.api_name} Record {key!r}" + (" (skipped)" if skip else ""),
|
|
489
|
+
("parameters", KEYS, index),
|
|
490
|
+
severity="warning" if skip else "error",
|
|
491
|
+
kind=kind.id,
|
|
492
|
+
key=key,
|
|
493
|
+
)
|
|
494
|
+
(plan.warnings if skip else plan.findings).append(missing)
|
|
495
|
+
if plan.findings:
|
|
496
|
+
plan.changes = []
|
|
497
|
+
return plan
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# Bulk imports ---------------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
Path = tuple[str | int, ...]
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _rows(value: JsonValue, path: Path, plan: Plan) -> list[dict[str, JsonValue]] | None:
|
|
506
|
+
if not isinstance(value, list) or not all(isinstance(r, dict) for r in value):
|
|
507
|
+
plan.findings.append(Finding(PARAMETER_INVALID, "must be a list of objects", path))
|
|
508
|
+
return None
|
|
509
|
+
return [r for r in value if isinstance(r, dict)]
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def import_rows(
|
|
513
|
+
schema: Schema, definition: BuiltinOperation, bound: Mapping[str, JsonValue], *, max_rows: int
|
|
514
|
+
) -> Plan:
|
|
515
|
+
"""Upserts of `import_records`, `import_links` or `import_batch` rows grouped in units (a
|
|
516
|
+
Record with its Links), or the findings of bad rows."""
|
|
517
|
+
plan = Plan()
|
|
518
|
+
importer = _Importer(schema, plan)
|
|
519
|
+
if definition.action is Action.IMPORT_BATCH:
|
|
520
|
+
records = bound.get("records") or []
|
|
521
|
+
links = bound.get("links") or []
|
|
522
|
+
record_rows = _rows(records, ("parameters", "records"), plan)
|
|
523
|
+
link_rows = _rows(links, ("parameters", "links"), plan)
|
|
524
|
+
if record_rows is None or link_rows is None:
|
|
525
|
+
return plan
|
|
526
|
+
if not record_rows and not link_rows:
|
|
527
|
+
plan.findings.append(
|
|
528
|
+
Finding(PARAMETER_REQUIRED, "records or links are required", ("parameters",))
|
|
529
|
+
)
|
|
530
|
+
return plan
|
|
531
|
+
if len(record_rows) + len(link_rows) > max_rows:
|
|
532
|
+
plan.findings.append(
|
|
533
|
+
Finding(LIMIT_EXCEEDED, f"at most {max_rows} rows per call", ("parameters",))
|
|
534
|
+
)
|
|
535
|
+
return plan
|
|
536
|
+
for index, row in enumerate(record_rows):
|
|
537
|
+
path: Path = ("parameters", "records", index)
|
|
538
|
+
kind = schema.kind_by_api_name(str(row.get(KIND)))
|
|
539
|
+
if kind is None:
|
|
540
|
+
plan.findings.append(Finding(PARAMETER_INVALID, "unknown Kind", (*path, KIND)))
|
|
541
|
+
continue
|
|
542
|
+
importer.record(kind, {k: v for k, v in row.items() if k != KIND}, path)
|
|
543
|
+
for index, row in enumerate(link_rows):
|
|
544
|
+
path = ("parameters", "links", index)
|
|
545
|
+
relation = _stored_relation(schema, str(row.get(RELATION)))
|
|
546
|
+
if relation is None:
|
|
547
|
+
plan.findings.append(
|
|
548
|
+
Finding(PARAMETER_INVALID, "unknown or derived Relation", (*path, RELATION))
|
|
549
|
+
)
|
|
550
|
+
continue
|
|
551
|
+
importer.link(relation, {k: v for k, v in row.items() if k != RELATION}, path)
|
|
552
|
+
return importer.finish(max_rows)
|
|
553
|
+
rows = _rows(bound.get("rows"), ("parameters", "rows"), plan)
|
|
554
|
+
if rows is None:
|
|
555
|
+
return plan
|
|
556
|
+
if len(rows) > max_rows:
|
|
557
|
+
plan.findings.append(
|
|
558
|
+
Finding(LIMIT_EXCEEDED, f"at most {max_rows} rows per call", ("parameters", "rows"))
|
|
559
|
+
)
|
|
560
|
+
return plan
|
|
561
|
+
if definition.action is Action.IMPORT_RECORDS:
|
|
562
|
+
kind = schema.kind_by_api_name(str(bound.get("kind")))
|
|
563
|
+
if kind is None:
|
|
564
|
+
plan.findings.append(Finding(PARAMETER_INVALID, "unknown Kind", ("parameters", "kind")))
|
|
565
|
+
return plan
|
|
566
|
+
for index, row in enumerate(rows):
|
|
567
|
+
importer.record(kind, row, ("parameters", "rows", index))
|
|
568
|
+
return importer.finish(max_rows)
|
|
569
|
+
relation = _stored_relation(schema, str(bound.get("relation")))
|
|
570
|
+
if relation is None:
|
|
571
|
+
plan.findings.append(
|
|
572
|
+
Finding(PARAMETER_INVALID, "unknown or derived Relation", ("parameters", "relation"))
|
|
573
|
+
)
|
|
574
|
+
return plan
|
|
575
|
+
for index, row in enumerate(rows):
|
|
576
|
+
importer.link(relation, row, ("parameters", "rows", index))
|
|
577
|
+
return importer.finish(max_rows)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _stored_relation(schema: Schema, name: str) -> Relation | None:
|
|
581
|
+
"""The explicit Relation with that forward api name."""
|
|
582
|
+
found = schema.relation_by_api_name(name)
|
|
583
|
+
if found is None or found[1] is not LinkDirection.FORWARD:
|
|
584
|
+
return None
|
|
585
|
+
relation = found[0]
|
|
586
|
+
return relation if relation.backing is RelationBacking.EXPLICIT else None
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _relocate(findings: Sequence[Finding], path: Path, plan: Plan) -> bool:
|
|
590
|
+
"""Move parameter findings of a row under `path`; true when there were any."""
|
|
591
|
+
for finding in findings:
|
|
592
|
+
plan.findings.append(Finding(finding.code, finding.message, (*path, *finding.path[1:])))
|
|
593
|
+
return bool(findings)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _link_definition(relation: Relation) -> BuiltinOperation:
|
|
597
|
+
return BuiltinOperation(
|
|
598
|
+
"",
|
|
599
|
+
Action.LINK,
|
|
600
|
+
"",
|
|
601
|
+
(
|
|
602
|
+
Parameter(FROM, _STRING, True),
|
|
603
|
+
Parameter(TO, _STRING, True),
|
|
604
|
+
*_attribute_parameters(relation.attributes, required=True),
|
|
605
|
+
),
|
|
606
|
+
relation=relation,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class _Importer:
|
|
611
|
+
"""Collects the units of a bulk import: Records with the Links of their rows, Links."""
|
|
612
|
+
|
|
613
|
+
def __init__(self, schema: Schema, plan: Plan) -> None:
|
|
614
|
+
self.schema = schema
|
|
615
|
+
self.plan = plan
|
|
616
|
+
self.records: dict[tuple[str, str], list[DataChange]] = {}
|
|
617
|
+
self.loose: list[DataChange] = []
|
|
618
|
+
self._creates: dict[str, BuiltinOperation] = {}
|
|
619
|
+
self._links: dict[str, BuiltinOperation] = {}
|
|
620
|
+
|
|
621
|
+
def create(self, kind: Kind) -> BuiltinOperation:
|
|
622
|
+
definition = self._creates.get(kind.id)
|
|
623
|
+
if definition is None:
|
|
624
|
+
parameters = _attribute_parameters(kind.attributes, required=True)
|
|
625
|
+
if not kind.has_natural_key:
|
|
626
|
+
parameters.insert(0, Parameter(KEY, _STRING, False))
|
|
627
|
+
definition = BuiltinOperation("", Action.CREATE, "", tuple(parameters), kind=kind)
|
|
628
|
+
self._creates[kind.id] = definition
|
|
629
|
+
return definition
|
|
630
|
+
|
|
631
|
+
def link_definition(self, relation: Relation) -> BuiltinOperation:
|
|
632
|
+
definition = self._links.get(relation.id)
|
|
633
|
+
if definition is None:
|
|
634
|
+
definition = self._links[relation.id] = _link_definition(relation)
|
|
635
|
+
return definition
|
|
636
|
+
|
|
637
|
+
def record(self, kind: Kind, row: Mapping[str, JsonValue], path: Path) -> None:
|
|
638
|
+
links = None
|
|
639
|
+
if LINKS in row and kind.attribute_by_api_name(LINKS) is None:
|
|
640
|
+
links = row[LINKS]
|
|
641
|
+
row = {k: v for k, v in row.items() if k != LINKS}
|
|
642
|
+
row_bound = bind(self.create(kind), row)
|
|
643
|
+
if _relocate(row_bound.findings, path, self.plan):
|
|
644
|
+
return
|
|
645
|
+
values = _values(kind.attributes, row_bound.values)
|
|
646
|
+
key = record_key(kind, values) if kind.has_natural_key else None
|
|
647
|
+
if key is None:
|
|
648
|
+
given = row_bound.values.get(KEY)
|
|
649
|
+
key = _key(given, (*path, KEY), self.plan) if given else new_ulid()
|
|
650
|
+
if key is None:
|
|
651
|
+
return
|
|
652
|
+
unit: list[DataChange] = [RecordUpsert(record=Record(kind=kind.id, key=key, values=values))]
|
|
653
|
+
if links is not None:
|
|
654
|
+
unit.extend(self.row_links(kind, key, links, (*path, LINKS)))
|
|
655
|
+
self.records[(kind.id, key)] = unit
|
|
656
|
+
|
|
657
|
+
def row_links(self, kind: Kind, key: str, links: JsonValue, path: Path) -> list[DataChange]:
|
|
658
|
+
if not isinstance(links, dict):
|
|
659
|
+
self.plan.findings.append(
|
|
660
|
+
Finding(PARAMETER_INVALID, "must be an object keyed by Relation name", path)
|
|
661
|
+
)
|
|
662
|
+
return []
|
|
663
|
+
changes: list[DataChange] = []
|
|
664
|
+
for name, targets in links.items():
|
|
665
|
+
found = self.schema.relation_by_api_name(name)
|
|
666
|
+
if (
|
|
667
|
+
found is None
|
|
668
|
+
or found[0].backing is not RelationBacking.EXPLICIT
|
|
669
|
+
or found[0].endpoint(found[1]) != kind.id
|
|
670
|
+
):
|
|
671
|
+
self.plan.findings.append(
|
|
672
|
+
Finding(
|
|
673
|
+
PARAMETER_INVALID,
|
|
674
|
+
f"{name!r} is not a stored Relation of {kind.api_name}",
|
|
675
|
+
(*path, name),
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
continue
|
|
679
|
+
relation, direction = found
|
|
680
|
+
many = isinstance(targets, list)
|
|
681
|
+
items = targets if isinstance(targets, list) else [targets]
|
|
682
|
+
for position, target in enumerate(items):
|
|
683
|
+
where: Path = (*path, name, position) if many else (*path, name)
|
|
684
|
+
attributes: dict[str, JsonValue] = {}
|
|
685
|
+
other: JsonValue = target
|
|
686
|
+
if isinstance(target, dict):
|
|
687
|
+
other = target.get(KEY)
|
|
688
|
+
attributes = {k: v for k, v in target.items() if k != KEY}
|
|
689
|
+
ends = (
|
|
690
|
+
{FROM: key, TO: other}
|
|
691
|
+
if direction is LinkDirection.FORWARD
|
|
692
|
+
else {FROM: other, TO: key}
|
|
693
|
+
)
|
|
694
|
+
link = self.build_link(relation, {**attributes, **ends}, where)
|
|
695
|
+
if link is not None:
|
|
696
|
+
changes.append(link)
|
|
697
|
+
return changes
|
|
698
|
+
|
|
699
|
+
def build_link(
|
|
700
|
+
self, relation: Relation, row: Mapping[str, JsonValue], path: Path
|
|
701
|
+
) -> DataChange | None:
|
|
702
|
+
definition = self.link_definition(relation)
|
|
703
|
+
row_bound = bind(definition, row)
|
|
704
|
+
if _relocate(row_bound.findings, path, self.plan):
|
|
705
|
+
return None
|
|
706
|
+
plan = Plan()
|
|
707
|
+
_link_edit(definition, relation, row_bound.values, plan)
|
|
708
|
+
self.plan.findings.extend(
|
|
709
|
+
Finding(f.code, f.message, (*path, *f.path[1:])) for f in plan.findings
|
|
710
|
+
)
|
|
711
|
+
return plan.changes[0] if plan.changes else None
|
|
712
|
+
|
|
713
|
+
def link(self, relation: Relation, row: Mapping[str, JsonValue], path: Path) -> None:
|
|
714
|
+
link = self.build_link(relation, row, path)
|
|
715
|
+
if link is None:
|
|
716
|
+
return
|
|
717
|
+
assert isinstance(link, LinkUpsert)
|
|
718
|
+
owner = self.records.get((relation.from_kind, link.link.from_key)) or self.records.get(
|
|
719
|
+
(relation.to_kind, link.link.to_key)
|
|
720
|
+
)
|
|
721
|
+
if owner is None:
|
|
722
|
+
self.loose.append(link)
|
|
723
|
+
else:
|
|
724
|
+
owner.append(link)
|
|
725
|
+
|
|
726
|
+
def finish(self, max_rows: int) -> Plan:
|
|
727
|
+
"""Units in commit order: lone Records, Records with Links, then lone Links."""
|
|
728
|
+
units = [u for u in self.records.values() if len(u) == 1]
|
|
729
|
+
units += [u for u in self.records.values() if len(u) > 1]
|
|
730
|
+
units += [[link] for link in self.loose]
|
|
731
|
+
total = sum(len(u) for u in units)
|
|
732
|
+
if total > max_rows:
|
|
733
|
+
self.plan.findings.append(
|
|
734
|
+
Finding(
|
|
735
|
+
LIMIT_EXCEEDED,
|
|
736
|
+
f"at most {max_rows} Records and Links per call",
|
|
737
|
+
("parameters",),
|
|
738
|
+
)
|
|
739
|
+
)
|
|
740
|
+
return self.plan
|
|
741
|
+
for unit in units:
|
|
742
|
+
self.plan.changes.extend(unit)
|
|
743
|
+
self.plan.units.append(len(unit))
|
|
744
|
+
return self.plan
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def batches(
|
|
748
|
+
changes: Sequence[DataChange], units: Sequence[int], size: int
|
|
749
|
+
) -> list[list[DataChange]]:
|
|
750
|
+
"""`changes` in batches of about `size`, never splitting a unit (a unit larger than `size`
|
|
751
|
+
is a batch of its own). Without `units` every change is its own unit."""
|
|
752
|
+
sizes = list(units) or [1] * len(changes)
|
|
753
|
+
out: list[list[DataChange]] = []
|
|
754
|
+
current: list[DataChange] = []
|
|
755
|
+
position = 0
|
|
756
|
+
for count in sizes:
|
|
757
|
+
unit = list(changes[position : position + count])
|
|
758
|
+
position += count
|
|
759
|
+
if current and len(current) + len(unit) > size:
|
|
760
|
+
out.append(current)
|
|
761
|
+
current = []
|
|
762
|
+
current.extend(unit)
|
|
763
|
+
if current:
|
|
764
|
+
out.append(current)
|
|
765
|
+
return out
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
# Responses ------------------------------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def edit_document(
|
|
772
|
+
schema: Schema, change: DataChange, masking: Masking = UNCLEARED
|
|
773
|
+
) -> dict[str, Any]:
|
|
774
|
+
"""An edit with api names instead of ids, and the values the caller may not read masked
|
|
775
|
+
(an update carries the Record's current values too)."""
|
|
776
|
+
if isinstance(change, RecordUpsert | RecordDelete):
|
|
777
|
+
kind_id = change.record.kind if isinstance(change, RecordUpsert) else change.kind
|
|
778
|
+
kind = schema.kinds.get(kind_id)
|
|
779
|
+
body: dict[str, Any] = {
|
|
780
|
+
"op": change.op,
|
|
781
|
+
"kind": kind.api_name if kind else kind_id,
|
|
782
|
+
"key": change.identity.key,
|
|
783
|
+
}
|
|
784
|
+
if isinstance(change, RecordUpsert):
|
|
785
|
+
body["values"] = (
|
|
786
|
+
masking.kind_values(kind, change.record.values, omit=False)
|
|
787
|
+
if kind
|
|
788
|
+
else masking.value_map({}, (kind_id,), dict(change.record.values))
|
|
789
|
+
)
|
|
790
|
+
return body
|
|
791
|
+
relation_id = change.link.relation if isinstance(change, LinkUpsert) else change.relation
|
|
792
|
+
relation = schema.relations.get(relation_id)
|
|
793
|
+
identity = change.identity
|
|
794
|
+
body = {
|
|
795
|
+
"op": change.op,
|
|
796
|
+
"relation": relation.api_name if relation else relation_id,
|
|
797
|
+
"from": identity.from_key,
|
|
798
|
+
"to": identity.to_key,
|
|
799
|
+
}
|
|
800
|
+
if isinstance(change, LinkUpsert):
|
|
801
|
+
body["values"] = (
|
|
802
|
+
masking.link_values(schema, relation, change.link.values)
|
|
803
|
+
if relation
|
|
804
|
+
else masking.value_map({}, (), dict(change.link.values))
|
|
805
|
+
)
|
|
806
|
+
return body
|