sg-python-api 0.1.2__py3-none-any.whl → 0.2.0__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.
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/METADATA +21 -24
- sg_python_api-0.2.0.dist-info/RECORD +58 -0
- shotgun_api3/__init__.py +4 -0
- shotgun_api3/_async_client.py +347 -281
- shotgun_api3/_async_driver.py +25 -55
- shotgun_api3/_async_httpx2.py +92 -59
- shotgun_api3/_async_transport.py +38 -3
- shotgun_api3/_capabilities.py +4 -3
- shotgun_api3/_client.py +296 -422
- shotgun_api3/_config.py +245 -120
- shotgun_api3/_connections.py +144 -0
- shotgun_api3/_crud.py +25 -250
- shotgun_api3/_driver.py +29 -54
- shotgun_api3/_execution.py +1 -3
- shotgun_api3/_files.py +193 -21
- shotgun_api3/_methods.py +59 -411
- shotgun_api3/_protocol.py +27 -53
- shotgun_api3/_transport.py +82 -407
- shotgun_api3/_version.py +7 -2
- shotgun_api3/lib/mockgun/__init__.py +20 -7
- shotgun_api3/lib/mockgun/_activity.py +510 -0
- shotgun_api3/lib/mockgun/_async_client.py +196 -0
- shotgun_api3/lib/mockgun/_dates.py +164 -0
- shotgun_api3/lib/mockgun/_errors.py +18 -0
- shotgun_api3/lib/mockgun/_events.py +1031 -0
- shotgun_api3/lib/mockgun/_follows.py +217 -0
- shotgun_api3/lib/mockgun/_handlers.py +558 -0
- shotgun_api3/lib/mockgun/_links.py +440 -0
- shotgun_api3/lib/mockgun/_new_client.py +92 -0
- shotgun_api3/lib/mockgun/_presets.py +492 -0
- shotgun_api3/lib/mockgun/_query.py +2163 -0
- shotgun_api3/lib/mockgun/_retirement.py +368 -0
- shotgun_api3/lib/mockgun/_schema.py +172 -0
- shotgun_api3/lib/mockgun/_schema_writes.py +425 -0
- shotgun_api3/lib/mockgun/_search.py +378 -0
- shotgun_api3/lib/mockgun/_server.py +169 -0
- shotgun_api3/lib/mockgun/_sessions.py +206 -0
- shotgun_api3/lib/mockgun/_site.py +150 -0
- shotgun_api3/lib/mockgun/_site_data.py +584 -0
- shotgun_api3/lib/mockgun/_storage.py +208 -0
- shotgun_api3/lib/mockgun/_store.py +1446 -0
- shotgun_api3/lib/mockgun/_summaries.py +964 -0
- shotgun_api3/lib/mockgun/_sync_client.py +190 -0
- shotgun_api3/lib/mockgun/_threads.py +226 -0
- shotgun_api3/lib/mockgun/_thumbnails.py +146 -0
- shotgun_api3/lib/mockgun/_transport.py +145 -0
- shotgun_api3/lib/mockgun/_uploads.py +417 -0
- shotgun_api3/lib/mockgun/_wire.py +120 -0
- shotgun_api3/lib/mockgun/mockgun.py +14 -0
- shotgun_api3/lib/sgtimezone.py +24 -1
- sg_python_api-0.1.2.dist-info/RECORD +0 -31
- shotgun_api3/_signatures.py +0 -53
- shotgun_api3/lib/mockgun/_async.py +0 -315
- shotgun_api3/lib/mockgun/_mockgun.py +0 -1666
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/WHEEL +0 -0
- {sg_python_api-0.1.2.dist-info → sg_python_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,2163 @@
|
|
|
1
|
+
"""Evaluate existing mockgun filters, ordering, projection, and server paging."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import datetime
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from itertools import combinations, groupby, product
|
|
9
|
+
from math import prod
|
|
10
|
+
from string import ascii_letters, ascii_lowercase, ascii_uppercase, digits
|
|
11
|
+
from typing import Protocol, Self, TypeAlias, cast
|
|
12
|
+
|
|
13
|
+
from ._dates import in_window, validate_relative
|
|
14
|
+
from ._errors import MockgunError, ServerFaultError
|
|
15
|
+
from ._events import check_meta
|
|
16
|
+
from ._links import (
|
|
17
|
+
RecordState,
|
|
18
|
+
deciding_record,
|
|
19
|
+
derived_name,
|
|
20
|
+
display_name,
|
|
21
|
+
hidden,
|
|
22
|
+
named_link,
|
|
23
|
+
)
|
|
24
|
+
from ._presets import (
|
|
25
|
+
Preset,
|
|
26
|
+
apply_presets,
|
|
27
|
+
assuming,
|
|
28
|
+
latest_groups,
|
|
29
|
+
latest_rank_key,
|
|
30
|
+
preset_dependencies,
|
|
31
|
+
validate_presets,
|
|
32
|
+
)
|
|
33
|
+
from ._schema import SiteSchema
|
|
34
|
+
from ._sessions import RequestContext
|
|
35
|
+
from ._store import UPLOAD_LINK_REASON_PREFIX, FieldValue, Row, Store
|
|
36
|
+
from ._wire import format_wire_datetime, parse_wire_datetime
|
|
37
|
+
|
|
38
|
+
_Bit: TypeAlias = tuple[str, int, str, str, int]
|
|
39
|
+
_Decision: TypeAlias = 'bool | _Failure | _Fork'
|
|
40
|
+
_StateReads: TypeAlias = dict[tuple[str, int], set[tuple[str, str, int] | None]]
|
|
41
|
+
_STATE_LIMIT = 64
|
|
42
|
+
_ASCII_LOWER = str.maketrans(ascii_uppercase, ascii_lowercase)
|
|
43
|
+
_COLLATION_CHARACTERS = frozenset(ascii_letters + digits)
|
|
44
|
+
_COLLATION_IGNORED = str.maketrans('', '', '_-. ')
|
|
45
|
+
_EntityLink: TypeAlias = dict[str, FieldValue]
|
|
46
|
+
_LINK_PATH_PARTS = 3
|
|
47
|
+
_NOTE_LINK_PAIR = {'Asset', 'Shot'}
|
|
48
|
+
_NUMERIC_TYPES = frozenset({'date', 'date_time', 'duration', 'float', 'number', 'percent'})
|
|
49
|
+
_ORDER_TYPES = _NUMERIC_TYPES | {
|
|
50
|
+
'checkbox',
|
|
51
|
+
'entity',
|
|
52
|
+
'entity_type',
|
|
53
|
+
'image',
|
|
54
|
+
'list',
|
|
55
|
+
'multi_entity',
|
|
56
|
+
'status_list',
|
|
57
|
+
'text',
|
|
58
|
+
'url',
|
|
59
|
+
}
|
|
60
|
+
_RELATIVE_RELATIONS = frozenset(
|
|
61
|
+
{
|
|
62
|
+
'in_last',
|
|
63
|
+
'in_next',
|
|
64
|
+
'not_in_last',
|
|
65
|
+
'not_in_next',
|
|
66
|
+
'in_calendar_day',
|
|
67
|
+
'in_calendar_week',
|
|
68
|
+
'in_calendar_month',
|
|
69
|
+
'in_calendar_year',
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
_UNFILTERABLE_TYPES = frozenset({'calculated', 'serializable', 'summary', 'url'})
|
|
73
|
+
_VALID_RELATIONS: dict[str, tuple[str, ...]] = {
|
|
74
|
+
'checkbox': ('is', 'is_not'),
|
|
75
|
+
'color': ('is', 'is_not', 'in', 'not_in'),
|
|
76
|
+
'date': (
|
|
77
|
+
'is',
|
|
78
|
+
'is_not',
|
|
79
|
+
'greater_than',
|
|
80
|
+
'less_than',
|
|
81
|
+
'in_last',
|
|
82
|
+
'not_in_last',
|
|
83
|
+
'in_next',
|
|
84
|
+
'not_in_next',
|
|
85
|
+
'in_calendar_week',
|
|
86
|
+
'in_calendar_month',
|
|
87
|
+
'in_calendar_day',
|
|
88
|
+
'in_calendar_year',
|
|
89
|
+
'between',
|
|
90
|
+
'in',
|
|
91
|
+
'not_in',
|
|
92
|
+
),
|
|
93
|
+
'date_time': (
|
|
94
|
+
'is',
|
|
95
|
+
'is_not',
|
|
96
|
+
'greater_than',
|
|
97
|
+
'less_than',
|
|
98
|
+
'in_last',
|
|
99
|
+
'not_in_last',
|
|
100
|
+
'in_next',
|
|
101
|
+
'not_in_next',
|
|
102
|
+
'in_calendar_week',
|
|
103
|
+
'in_calendar_month',
|
|
104
|
+
'in_calendar_day',
|
|
105
|
+
'in_calendar_year',
|
|
106
|
+
'between',
|
|
107
|
+
'in',
|
|
108
|
+
'not_in',
|
|
109
|
+
),
|
|
110
|
+
'duration': ('is', 'is_not', 'greater_than', 'less_than', 'between', 'in', 'not_in'),
|
|
111
|
+
'entity': (
|
|
112
|
+
'is',
|
|
113
|
+
'is_not',
|
|
114
|
+
'name_contains',
|
|
115
|
+
'name_not_contains',
|
|
116
|
+
'name_is',
|
|
117
|
+
'type_is',
|
|
118
|
+
'type_is_not',
|
|
119
|
+
'in',
|
|
120
|
+
'not_in',
|
|
121
|
+
),
|
|
122
|
+
'float': ('is', 'is_not', 'greater_than', 'less_than', 'between', 'in', 'not_in'),
|
|
123
|
+
'image': ('is', 'is_not'),
|
|
124
|
+
'list': ('is', 'is_not', 'in', 'not_in'),
|
|
125
|
+
'multi_entity': (
|
|
126
|
+
'is',
|
|
127
|
+
'is_not',
|
|
128
|
+
'name_contains',
|
|
129
|
+
'name_not_contains',
|
|
130
|
+
'name_is',
|
|
131
|
+
'type_is',
|
|
132
|
+
'type_is_not',
|
|
133
|
+
'in',
|
|
134
|
+
'not_in',
|
|
135
|
+
),
|
|
136
|
+
'number': ('is', 'is_not', 'greater_than', 'less_than', 'between', 'in', 'not_in'),
|
|
137
|
+
'percent': ('is', 'is_not', 'greater_than', 'less_than', 'between', 'in', 'not_in'),
|
|
138
|
+
'pivot_column': ('is', 'is_not'),
|
|
139
|
+
'status_list': ('is', 'is_not', 'in', 'not_in'),
|
|
140
|
+
'text': (
|
|
141
|
+
'contains',
|
|
142
|
+
'not_contains',
|
|
143
|
+
'is',
|
|
144
|
+
'is_not',
|
|
145
|
+
'starts_with',
|
|
146
|
+
'ends_with',
|
|
147
|
+
'in',
|
|
148
|
+
'not_in',
|
|
149
|
+
),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class _ComparableFieldValue(Protocol):
|
|
154
|
+
"""Describe values with ordered comparisons."""
|
|
155
|
+
|
|
156
|
+
def __gt__(self, other: Self, /) -> bool:
|
|
157
|
+
"""Return whether this value sorts after another value."""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
def __le__(self, other: Self, /) -> bool:
|
|
161
|
+
"""Return whether this value does not sort after another value."""
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
def __lt__(self, other: Self, /) -> bool:
|
|
165
|
+
"""Return whether this value sorts before another value."""
|
|
166
|
+
...
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class ReadScope:
|
|
171
|
+
"""Immutable selection context for retirement and archive scope.
|
|
172
|
+
|
|
173
|
+
Attributes:
|
|
174
|
+
retired: Select retired records instead of active records.
|
|
175
|
+
context: Actor and timestamp captured for the request.
|
|
176
|
+
include_archived_projects: Whether archived Projects and their records
|
|
177
|
+
remain visible.
|
|
178
|
+
presets: Additional filter presets to validate and apply before
|
|
179
|
+
ordering.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
retired: bool
|
|
183
|
+
context: RequestContext
|
|
184
|
+
include_archived_projects: bool = True
|
|
185
|
+
presets: object = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class _Failure:
|
|
190
|
+
"""Keep a reachable evaluation error while reducing symbolic conditions."""
|
|
191
|
+
|
|
192
|
+
message: str
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True)
|
|
196
|
+
class _Fork:
|
|
197
|
+
"""Share the two outcomes of one retirement or outgoing visibility bit."""
|
|
198
|
+
|
|
199
|
+
bit: _Bit
|
|
200
|
+
off: _Decision
|
|
201
|
+
on: _Decision
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class _Candidate:
|
|
206
|
+
"""Keep scope, group admission, and selection separate until reduction ends."""
|
|
207
|
+
|
|
208
|
+
row: Row
|
|
209
|
+
member: _Decision
|
|
210
|
+
archive: _Decision
|
|
211
|
+
selected: _Decision
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _RowReader:
|
|
215
|
+
"""Read stored fields and link visibility for selection or projection."""
|
|
216
|
+
|
|
217
|
+
def __init__(self, store: Store, schema: SiteSchema) -> None:
|
|
218
|
+
"""Bind row sources without requiring a selection scope."""
|
|
219
|
+
self._store = store
|
|
220
|
+
self._schema = schema
|
|
221
|
+
self._assume: dict[tuple[str, int], RecordState] | None = None
|
|
222
|
+
|
|
223
|
+
def _direct_field_value(
|
|
224
|
+
self, entity_type: str, row: Row, field_name: str, *, membership: bool = False
|
|
225
|
+
) -> FieldValue:
|
|
226
|
+
"""Read a stored field or compute its non-retained display cache."""
|
|
227
|
+
if entity_type == 'Project' and field_name == 'last_accessed_by_current_user':
|
|
228
|
+
context = self._store.context
|
|
229
|
+
actor = context.actor if context is not None else None
|
|
230
|
+
value = (
|
|
231
|
+
self._store.side_get(
|
|
232
|
+
'last_access', (cast('int', row['id']), actor.entity_type, actor.entity_id)
|
|
233
|
+
)
|
|
234
|
+
if actor is not None
|
|
235
|
+
else None
|
|
236
|
+
)
|
|
237
|
+
if value is None:
|
|
238
|
+
raise MockgunError(
|
|
239
|
+
'mockgun cannot read Project.last_accessed_by_current_user '
|
|
240
|
+
f'on record {row["id"]}: '
|
|
241
|
+
'actor has never accessed the Project'
|
|
242
|
+
)
|
|
243
|
+
return value
|
|
244
|
+
if '__unknown' in row or (not membership and '__unordered' in row):
|
|
245
|
+
self._store.check_known(row, field_name, membership=membership)
|
|
246
|
+
if entity_type == 'EventLogEntry' and field_name == 'meta':
|
|
247
|
+
check_meta(self._store, row)
|
|
248
|
+
if field_name == 'cached_display_name' and entity_type != 'EventLogEntry':
|
|
249
|
+
return display_name(self._store, entity_type, cast('int', row['id']))
|
|
250
|
+
return row.get(field_name)
|
|
251
|
+
|
|
252
|
+
def _hidden(self, row: Row, field: str, link: Mapping[str, FieldValue]) -> bool:
|
|
253
|
+
"""Resolve link visibility under the reduced unit's concrete assumption."""
|
|
254
|
+
return hidden(self._store, row, field, link, assume=self._assume)
|
|
255
|
+
|
|
256
|
+
def _visible_value(self, row: Row, field_name: str, value: FieldValue) -> FieldValue:
|
|
257
|
+
"""Suppress listed retired links while preserving stored values."""
|
|
258
|
+
if isinstance(value, dict):
|
|
259
|
+
return None if self._hidden(row, field_name, value) else value
|
|
260
|
+
if isinstance(value, list):
|
|
261
|
+
return [
|
|
262
|
+
member
|
|
263
|
+
for member in value
|
|
264
|
+
if not isinstance(member, dict) or not self._hidden(row, field_name, member)
|
|
265
|
+
]
|
|
266
|
+
return value
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class _Projection(_RowReader):
|
|
270
|
+
"""Project detached fields with explicit context for session-specific ordering."""
|
|
271
|
+
|
|
272
|
+
def __init__(self, store: Store, schema: SiteSchema, context: RequestContext | None) -> None:
|
|
273
|
+
"""Bind projection with its explicit context for session-specific ordering."""
|
|
274
|
+
super().__init__(store, schema)
|
|
275
|
+
self._projection_context = context
|
|
276
|
+
|
|
277
|
+
def _project_value(self, entity_type: str, row: Row, path: str) -> tuple[bool, FieldValue]:
|
|
278
|
+
"""Follow one valid single-link path and distinguish omission from null."""
|
|
279
|
+
if entity_type == 'EventLogEntry' and path == 'audit_trail':
|
|
280
|
+
return False, None
|
|
281
|
+
parts = path.split('.', 2)
|
|
282
|
+
if len(parts) != _LINK_PATH_PARTS:
|
|
283
|
+
value = self._direct_field_value(entity_type, row, path)
|
|
284
|
+
visible = self._visible_value(row, path, value)
|
|
285
|
+
return True, _ordered_note_links(row, path, value, visible, self._projection_context)
|
|
286
|
+
field, linked_type, rest = parts
|
|
287
|
+
link_value = self._direct_field_value(entity_type, row, field)
|
|
288
|
+
if self._visible_value(row, field, link_value) != link_value:
|
|
289
|
+
raise MockgunError(
|
|
290
|
+
f'mockgun cannot project {entity_type}.{path}: hidden {entity_type}.{field} link'
|
|
291
|
+
)
|
|
292
|
+
if link_value is None:
|
|
293
|
+
if not self._schema.declares(linked_type):
|
|
294
|
+
raise MockgunError(
|
|
295
|
+
f'mockgun cannot project {entity_type}.{path}: '
|
|
296
|
+
f'{linked_type} is not a declared entity type'
|
|
297
|
+
)
|
|
298
|
+
return True, None
|
|
299
|
+
if not isinstance(link_value, dict):
|
|
300
|
+
raise MockgunError(f'mockgun cannot project {entity_type}.{path}: invalid link')
|
|
301
|
+
if link_value['type'] != linked_type:
|
|
302
|
+
return False, None
|
|
303
|
+
if not self._schema.declares(linked_type):
|
|
304
|
+
return False, None
|
|
305
|
+
linked_row = self._store.row(linked_type, cast('int', link_value['id']))
|
|
306
|
+
if linked_row is None:
|
|
307
|
+
raise MockgunError(
|
|
308
|
+
f'mockgun cannot project {entity_type}.{path}: missing {linked_type} link'
|
|
309
|
+
)
|
|
310
|
+
return self._project_value(linked_type, linked_row, rest)
|
|
311
|
+
|
|
312
|
+
def _return_field_type(self, entity_type: str, path: str) -> str | None:
|
|
313
|
+
"""Resolve a readable path, omitting statically invalid positions."""
|
|
314
|
+
parts = path.split('.', 2)
|
|
315
|
+
field = parts[0] if len(parts) == _LINK_PATH_PARTS else path
|
|
316
|
+
if field not in {'id', 'type'} and not self._schema.declares(entity_type, field):
|
|
317
|
+
return None
|
|
318
|
+
field_type = self._schema.field_type(entity_type, field)
|
|
319
|
+
if len(parts) != _LINK_PATH_PARTS:
|
|
320
|
+
return field_type
|
|
321
|
+
if field_type != 'entity':
|
|
322
|
+
return None
|
|
323
|
+
linked_type, rest = parts[1:]
|
|
324
|
+
if not self._schema.declares(linked_type):
|
|
325
|
+
return 'unknown'
|
|
326
|
+
return self._return_field_type(linked_type, rest)
|
|
327
|
+
|
|
328
|
+
def project(self, rows: list[Row], return_fields: list[str]) -> list[dict[str, object]]:
|
|
329
|
+
"""Project detached output fields and format declared datetime values.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
rows: Stored rows in the desired result order.
|
|
333
|
+
return_fields: Direct or linked field paths to project.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Detached dictionaries in input order, always containing `type` and
|
|
337
|
+
`id`. Unsupported projection paths are omitted when the projection
|
|
338
|
+
rules permit omission.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
ServerFaultError: If resolving a declared field or linked entity
|
|
342
|
+
encounters a modeled schema fault.
|
|
343
|
+
MockgunError: If projected values or linked identity state are
|
|
344
|
+
unknown or unsupported.
|
|
345
|
+
OverflowError: If converting a projected datetime to UTC exceeds
|
|
346
|
+
the supported datetime range.
|
|
347
|
+
ValueError: If the platform cannot convert a naive datetime.
|
|
348
|
+
OSError: If local timezone conversion fails.
|
|
349
|
+
"""
|
|
350
|
+
result: list[dict[str, object]] = []
|
|
351
|
+
field_types = (
|
|
352
|
+
{
|
|
353
|
+
name: self._return_field_type(cast('str', rows[0]['type']), name)
|
|
354
|
+
for name in return_fields
|
|
355
|
+
}
|
|
356
|
+
if rows
|
|
357
|
+
else {}
|
|
358
|
+
)
|
|
359
|
+
for row in rows:
|
|
360
|
+
entity_type = cast('str', row['type'])
|
|
361
|
+
item: dict[str, object] = {}
|
|
362
|
+
for name in return_fields:
|
|
363
|
+
field_type = field_types[name]
|
|
364
|
+
if field_type is None:
|
|
365
|
+
continue
|
|
366
|
+
present, value = self._project_value(entity_type, row, name)
|
|
367
|
+
if not present:
|
|
368
|
+
continue
|
|
369
|
+
if field_type in {'entity', 'multi_entity'}:
|
|
370
|
+
if isinstance(value, list):
|
|
371
|
+
value = [
|
|
372
|
+
named_link(self._store, cast('_EntityLink', item)) for item in value
|
|
373
|
+
]
|
|
374
|
+
elif isinstance(value, dict):
|
|
375
|
+
value = named_link(self._store, value)
|
|
376
|
+
elif field_type == 'date_time':
|
|
377
|
+
value = _format_datetimes(value)
|
|
378
|
+
item[name] = copy.deepcopy(value)
|
|
379
|
+
item['type'] = entity_type
|
|
380
|
+
item['id'] = row['id']
|
|
381
|
+
result.append(item)
|
|
382
|
+
return result
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class _Query(_RowReader):
|
|
386
|
+
"""Evaluate a query against one schema and store snapshot."""
|
|
387
|
+
|
|
388
|
+
def __init__(
|
|
389
|
+
self,
|
|
390
|
+
store: Store,
|
|
391
|
+
schema: SiteSchema,
|
|
392
|
+
scope: ReadScope,
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Bind the store and schema for one serialized operation."""
|
|
395
|
+
super().__init__(store, schema)
|
|
396
|
+
self._scope = scope
|
|
397
|
+
self._external: _Decision = True
|
|
398
|
+
self._nodes: dict[tuple[_Bit, _Decision, _Decision], _Fork] = {}
|
|
399
|
+
self._choices: dict[tuple[_Decision, _Decision, _Decision], _Decision] = {}
|
|
400
|
+
|
|
401
|
+
def _compare_value(
|
|
402
|
+
self, field_type: str, actual: FieldValue, relation: str, values: list[FieldValue]
|
|
403
|
+
) -> bool:
|
|
404
|
+
"""Use request time for relative filters and scalar comparison otherwise."""
|
|
405
|
+
if field_type in {'date', 'date_time'} and relation in _RELATIVE_RELATIONS:
|
|
406
|
+
return in_window(field_type, relation, values, actual, now=self._scope.context.now)
|
|
407
|
+
return _compare(field_type, actual, relation, values)
|
|
408
|
+
|
|
409
|
+
def _entity_order_keys(self, values: list[FieldValue]) -> list[FieldValue]:
|
|
410
|
+
"""Read names only for distinct non-null targets that must be compared."""
|
|
411
|
+
identities = {
|
|
412
|
+
(cast('str', value['type']), cast('int', value['id']))
|
|
413
|
+
for value in values
|
|
414
|
+
if isinstance(value, dict)
|
|
415
|
+
}
|
|
416
|
+
if len(identities) <= 1:
|
|
417
|
+
return [None if value is None else 0 for value in values]
|
|
418
|
+
names: dict[tuple[str, int], str] = {}
|
|
419
|
+
for identity in identities:
|
|
420
|
+
name = display_name(self._store, *identity)
|
|
421
|
+
if name is None or derived_name(self._store, *identity):
|
|
422
|
+
raise MockgunError('entity order needs a modeled, non-derived name')
|
|
423
|
+
names[identity] = name
|
|
424
|
+
if len({name.translate(_ASCII_LOWER) for name in names.values()}) != len(names):
|
|
425
|
+
raise MockgunError('distinct entity targets have equal display names')
|
|
426
|
+
return [
|
|
427
|
+
None
|
|
428
|
+
if value is None
|
|
429
|
+
else names[
|
|
430
|
+
(
|
|
431
|
+
cast('str', cast('_EntityLink', value)['type']),
|
|
432
|
+
cast('int', cast('_EntityLink', value)['id']),
|
|
433
|
+
)
|
|
434
|
+
]
|
|
435
|
+
for value in values
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
def _filter_field_type(self, entity_type: str, path: str) -> str:
|
|
439
|
+
"""Resolve filter fields with the server's missing-field fault."""
|
|
440
|
+
if entity_type == 'Project' and path == 'last_accessed_by_current_user':
|
|
441
|
+
raise MockgunError(
|
|
442
|
+
'mockgun cannot read Project.last_accessed_by_current_user: '
|
|
443
|
+
'filtering and ordering are unsettled'
|
|
444
|
+
)
|
|
445
|
+
parts = path.split('.', 2)
|
|
446
|
+
field = parts[0] if len(parts) == _LINK_PATH_PARTS else path
|
|
447
|
+
if (
|
|
448
|
+
self._schema.declares(entity_type)
|
|
449
|
+
and field not in {'id', 'type'}
|
|
450
|
+
and not self._schema.declares(entity_type, field)
|
|
451
|
+
):
|
|
452
|
+
raise ServerFaultError(
|
|
453
|
+
f"API read() {entity_type}.{field} doesn't exist", error_code=103
|
|
454
|
+
)
|
|
455
|
+
field_type = self._schema.field_type(entity_type, field)
|
|
456
|
+
if len(parts) == _LINK_PATH_PARTS:
|
|
457
|
+
if field_type not in {'entity', 'multi_entity'}:
|
|
458
|
+
return self._schema.field_type(entity_type, path)
|
|
459
|
+
return self._filter_field_type(parts[1], parts[2])
|
|
460
|
+
return field_type
|
|
461
|
+
|
|
462
|
+
def _fork(self, bit: _Bit, off: _Decision, on: _Decision) -> _Decision:
|
|
463
|
+
"""Discard a bit when its two outcomes are identical."""
|
|
464
|
+
if off == on:
|
|
465
|
+
return off
|
|
466
|
+
return self._nodes.setdefault((bit, off, on), _Fork(bit, off, on))
|
|
467
|
+
|
|
468
|
+
def _choose(self, condition: _Decision, yes: _Decision, no: _Decision) -> _Decision:
|
|
469
|
+
"""Preserve short circuits and errors while composing ordered branches."""
|
|
470
|
+
if isinstance(condition, bool):
|
|
471
|
+
return yes if condition else no
|
|
472
|
+
if isinstance(condition, _Failure):
|
|
473
|
+
return condition
|
|
474
|
+
key = (condition, yes, no)
|
|
475
|
+
if key in self._choices:
|
|
476
|
+
return self._choices[key]
|
|
477
|
+
bit = min(item.bit for item in key if isinstance(item, _Fork))
|
|
478
|
+
branches: list[_Decision] = []
|
|
479
|
+
for enabled in (False, True):
|
|
480
|
+
parts = [
|
|
481
|
+
(item.on if enabled else item.off)
|
|
482
|
+
if isinstance(item, _Fork) and item.bit == bit
|
|
483
|
+
else item
|
|
484
|
+
for item in key
|
|
485
|
+
]
|
|
486
|
+
branches.append(self._choose(parts[0], yes=parts[1], no=parts[2]))
|
|
487
|
+
result = self._fork(bit, off=branches[0], on=branches[1])
|
|
488
|
+
self._choices[key] = result
|
|
489
|
+
return result
|
|
490
|
+
|
|
491
|
+
def _all(self, left: _Decision, right: _Decision) -> _Decision:
|
|
492
|
+
"""Compose ordered conjunction without suppressing an earlier error."""
|
|
493
|
+
return self._choose(left, yes=right, no=False)
|
|
494
|
+
|
|
495
|
+
def _any(self, left: _Decision, right: _Decision) -> _Decision:
|
|
496
|
+
"""Compose ordered disjunction without suppressing an earlier error."""
|
|
497
|
+
return self._choose(left, yes=True, no=right)
|
|
498
|
+
|
|
499
|
+
def _archive_guard(self, left: _Candidate, right: _Candidate) -> _Decision:
|
|
500
|
+
"""Reject LATEST groups mixing archived and active Projects before filtering."""
|
|
501
|
+
different = self._choose(
|
|
502
|
+
left.archive, yes=self._choose(right.archive, yes=False, no=True), no=right.archive
|
|
503
|
+
)
|
|
504
|
+
together = self._all(left.member, right.member)
|
|
505
|
+
return self._choose(
|
|
506
|
+
self._all(together, different),
|
|
507
|
+
yes=_Failure('LATEST group mixes archived and active Projects'),
|
|
508
|
+
no=True,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _errors(self, value: _Decision) -> _Decision:
|
|
512
|
+
"""Retain only branches capable of raising, with successful leaves equal."""
|
|
513
|
+
if isinstance(value, bool):
|
|
514
|
+
return True
|
|
515
|
+
if isinstance(value, _Failure):
|
|
516
|
+
return value
|
|
517
|
+
return self._fork(value.bit, off=self._errors(value.off), on=self._errors(value.on))
|
|
518
|
+
|
|
519
|
+
def _guard_uploaded_file(self, row: Row, path: str) -> None:
|
|
520
|
+
"""Reject only upload-marked Attachment file dictionaries along this path."""
|
|
521
|
+
if path != 'this_file' and not path.endswith('.Attachment.this_file'):
|
|
522
|
+
return
|
|
523
|
+
if row['type'] == 'Attachment' and path == 'this_file':
|
|
524
|
+
unknown = cast('dict[str, FieldValue]', row.get('__unknown', {}))
|
|
525
|
+
reason = unknown.get('this_file')
|
|
526
|
+
if isinstance(reason, str) and reason.startswith(UPLOAD_LINK_REASON_PREFIX):
|
|
527
|
+
self._store.check_known(row, 'this_file')
|
|
528
|
+
return
|
|
529
|
+
parts = path.split('.', 2)
|
|
530
|
+
if len(parts) != _LINK_PATH_PARTS:
|
|
531
|
+
return
|
|
532
|
+
value = row.get(parts[0])
|
|
533
|
+
links = value if isinstance(value, list) else [value]
|
|
534
|
+
for link in links:
|
|
535
|
+
if isinstance(link, dict) and link.get('type') == parts[1]:
|
|
536
|
+
target = self._store.row(parts[1], cast('int', link['id']))
|
|
537
|
+
if target is not None:
|
|
538
|
+
self._guard_uploaded_file(target, parts[2])
|
|
539
|
+
|
|
540
|
+
def _has_uncertainty(self) -> bool:
|
|
541
|
+
"""Check whether taints or a retired Cut can affect any query path."""
|
|
542
|
+
if any(self._store.tainted_rows(kind) for kind in self._schema.fields):
|
|
543
|
+
return True
|
|
544
|
+
return self._schema.declares('Cut') and bool(self._store.rows('Cut', retired=True))
|
|
545
|
+
|
|
546
|
+
def _hidden_filter_value(
|
|
547
|
+
self,
|
|
548
|
+
row: Row,
|
|
549
|
+
field_name: str,
|
|
550
|
+
value: FieldValue,
|
|
551
|
+
relation: str,
|
|
552
|
+
values: list[FieldValue],
|
|
553
|
+
) -> FieldValue:
|
|
554
|
+
"""Apply the narrow modeled filter rule for hidden links."""
|
|
555
|
+
visible = self._visible_value(row, field_name, value)
|
|
556
|
+
if visible != value and (relation != 'is' or not values or values[0] is None):
|
|
557
|
+
raise MockgunError(f'hidden {row["type"]}.{field_name} link has unmodeled {relation}')
|
|
558
|
+
return visible
|
|
559
|
+
|
|
560
|
+
def _in_archive_scope(self, entity_type: str, row: Row, scope: ReadScope) -> bool:
|
|
561
|
+
"""Keep rows outside an archived Project when the flag excludes archives."""
|
|
562
|
+
if scope.include_archived_projects or not self._schema.declares('Project', 'archived'):
|
|
563
|
+
return True
|
|
564
|
+
if entity_type == 'Project':
|
|
565
|
+
return row.get('archived') is not True
|
|
566
|
+
if not self._schema.declares(entity_type, 'project') or (
|
|
567
|
+
self._schema.field_type(entity_type, 'project') != 'entity'
|
|
568
|
+
):
|
|
569
|
+
return True
|
|
570
|
+
project = self._visible_value(row, 'project', row.get('project'))
|
|
571
|
+
if isinstance(project, dict) and project.get('type') == 'Project':
|
|
572
|
+
project_id = project.get('id')
|
|
573
|
+
if isinstance(project_id, int):
|
|
574
|
+
project_row = self._store.row('Project', project_id)
|
|
575
|
+
return project_row is None or project_row.get('archived') is not True
|
|
576
|
+
return True
|
|
577
|
+
|
|
578
|
+
def _linked_row(self, link: _EntityLink) -> Row:
|
|
579
|
+
"""Resolve a stored link, rejecting a target type absent from the schema."""
|
|
580
|
+
entity_id = cast('int', link['id'])
|
|
581
|
+
try:
|
|
582
|
+
row = self._store.row(cast('str', link['type']), entity_id)
|
|
583
|
+
except ServerFaultError as exc:
|
|
584
|
+
raise MockgunError(f'stored linked target is outside the schema: {link!r}') from exc
|
|
585
|
+
if row is None:
|
|
586
|
+
raise KeyError(entity_id)
|
|
587
|
+
return row
|
|
588
|
+
|
|
589
|
+
def _members(self, left: _Decision, right: _Decision) -> _Decision:
|
|
590
|
+
"""Evaluate every multi-link member, as the ordinary deep filter does."""
|
|
591
|
+
return self._all(self._errors(left), self._choose(right, yes=True, no=left))
|
|
592
|
+
|
|
593
|
+
def _name_relation_value(self, value: FieldValue) -> FieldValue:
|
|
594
|
+
"""Resolve every required name and reject unresolved comparison semantics."""
|
|
595
|
+
if value is None:
|
|
596
|
+
return None
|
|
597
|
+
if isinstance(value, list):
|
|
598
|
+
return [self._name_relation_value(member) for member in value]
|
|
599
|
+
link = cast('_EntityLink', value)
|
|
600
|
+
kind, entity_id = cast('str', link['type']), cast('int', link['id'])
|
|
601
|
+
name = display_name(self._store, kind, entity_id)
|
|
602
|
+
if name is None or derived_name(self._store, kind, entity_id):
|
|
603
|
+
raise MockgunError(f'{kind} display-name comparison is not modeled')
|
|
604
|
+
return {'type': kind, 'id': entity_id, 'name': name}
|
|
605
|
+
|
|
606
|
+
def _matches_direct(
|
|
607
|
+
self, entity_type: str, row: Row, path: str, relation: str, values: list[FieldValue]
|
|
608
|
+
) -> bool:
|
|
609
|
+
"""Reduce identity conditions before reading link visibility."""
|
|
610
|
+
field_type = self._schema.field_type(entity_type, path)
|
|
611
|
+
actual = self._direct_field_value(entity_type, row, path, membership=True)
|
|
612
|
+
if (
|
|
613
|
+
field_type in {'entity', 'multi_entity'}
|
|
614
|
+
and relation == 'is'
|
|
615
|
+
and values
|
|
616
|
+
and values[0] is not None
|
|
617
|
+
):
|
|
618
|
+
# A hidden nonmatching identity cannot change this condition.
|
|
619
|
+
if not self._compare_value(field_type, actual, relation, values):
|
|
620
|
+
return False
|
|
621
|
+
if isinstance(actual, list):
|
|
622
|
+
actual = [
|
|
623
|
+
member
|
|
624
|
+
for member in actual
|
|
625
|
+
if self._compare_value('entity', member, relation, values)
|
|
626
|
+
]
|
|
627
|
+
if field_type in {'entity', 'multi_entity'}:
|
|
628
|
+
actual = self._hidden_filter_value(row, path, actual, relation, values)
|
|
629
|
+
if field_type == 'checkbox' and actual is None:
|
|
630
|
+
raise MockgunError('stored checkbox is null')
|
|
631
|
+
if field_type in {'entity', 'multi_entity'} and relation.startswith('name_'):
|
|
632
|
+
actual = self._name_relation_value(actual)
|
|
633
|
+
return self._compare_value(field_type, actual, relation, values)
|
|
634
|
+
|
|
635
|
+
def _matches_field(
|
|
636
|
+
self,
|
|
637
|
+
entity_type: str,
|
|
638
|
+
row: Row,
|
|
639
|
+
path: str,
|
|
640
|
+
relation: str,
|
|
641
|
+
values: list[FieldValue],
|
|
642
|
+
) -> bool:
|
|
643
|
+
"""Traverse filter links, rejecting a wrong-type path only if null matches."""
|
|
644
|
+
field_type = self._schema.field_type(entity_type, path)
|
|
645
|
+
parts = path.split('.', 2)
|
|
646
|
+
if len(parts) != _LINK_PATH_PARTS:
|
|
647
|
+
return self._matches_direct(entity_type, row, path, relation, values)
|
|
648
|
+
field, linked_type, rest = parts
|
|
649
|
+
actual = self._direct_field_value(entity_type, row, field, membership=True)
|
|
650
|
+
actual = self._hidden_filter_value(row, field, actual, relation, values)
|
|
651
|
+
links = actual if isinstance(actual, list) else [actual]
|
|
652
|
+
matches: list[bool] = []
|
|
653
|
+
for link in links:
|
|
654
|
+
if link is None:
|
|
655
|
+
matches.append(self._compare_value(field_type, None, relation, values))
|
|
656
|
+
continue
|
|
657
|
+
if not isinstance(link, dict):
|
|
658
|
+
raise ServerFaultError(
|
|
659
|
+
f'linked field path {entity_type}.{path} contains a non-entity value'
|
|
660
|
+
)
|
|
661
|
+
linked_row = self._linked_row(link)
|
|
662
|
+
if link['type'] != linked_type:
|
|
663
|
+
if self._compare_value(field_type, None, relation, values):
|
|
664
|
+
raise MockgunError('a different linked type matches as null')
|
|
665
|
+
matches.append(False)
|
|
666
|
+
continue
|
|
667
|
+
matches.append(self._matches_field(linked_type, linked_row, rest, relation, values))
|
|
668
|
+
return any(matches)
|
|
669
|
+
|
|
670
|
+
def _matches_tree(
|
|
671
|
+
self,
|
|
672
|
+
entity_type: str,
|
|
673
|
+
row: Row,
|
|
674
|
+
tree: dict[str, object],
|
|
675
|
+
) -> bool:
|
|
676
|
+
"""Evaluate a compiled logical condition tree for one row."""
|
|
677
|
+
conditions = cast('list[dict[str, object]]', tree['conditions'])
|
|
678
|
+
for condition in conditions:
|
|
679
|
+
if 'logical_operator' in condition:
|
|
680
|
+
matches = self._matches_tree(entity_type, row, condition)
|
|
681
|
+
else:
|
|
682
|
+
field_name = cast('str', condition['path'])
|
|
683
|
+
relation = cast('str', condition['relation'])
|
|
684
|
+
values = cast('list[FieldValue]', condition['values'])
|
|
685
|
+
field_type = self._schema.field_type(entity_type, field_name)
|
|
686
|
+
try:
|
|
687
|
+
matches = self._matches_field(entity_type, row, field_name, relation, values)
|
|
688
|
+
except (MockgunError, TypeError, AttributeError, KeyError) as exc:
|
|
689
|
+
raise MockgunError(
|
|
690
|
+
f'mockgun cannot evaluate {entity_type}.{field_name} '
|
|
691
|
+
f'{field_type} {relation} {values!r}: {exc}'
|
|
692
|
+
) from exc
|
|
693
|
+
if tree['logical_operator'] == 'and' and not matches:
|
|
694
|
+
return False
|
|
695
|
+
if tree['logical_operator'] == 'or' and matches:
|
|
696
|
+
return True
|
|
697
|
+
return tree['logical_operator'] == 'and'
|
|
698
|
+
|
|
699
|
+
def _order_field(self, entity_type: str, path: str) -> tuple[str, str, str]:
|
|
700
|
+
"""Validate a complete order path and locate its terminal schema field."""
|
|
701
|
+
if entity_type == 'Project' and path == 'last_accessed_by_current_user':
|
|
702
|
+
raise MockgunError(
|
|
703
|
+
'mockgun cannot read Project.last_accessed_by_current_user: '
|
|
704
|
+
'filtering and ordering are unsettled'
|
|
705
|
+
)
|
|
706
|
+
parts = path.split('.', 2)
|
|
707
|
+
field_name = parts[0]
|
|
708
|
+
field_type = self._schema.field_type(entity_type, field_name)
|
|
709
|
+
if len(parts) == 1:
|
|
710
|
+
return entity_type, field_name, field_type
|
|
711
|
+
if len(parts) != _LINK_PATH_PARTS or field_type != 'entity':
|
|
712
|
+
raise MockgunError('order paths must traverse single-entity fields')
|
|
713
|
+
return self._order_field(parts[1], parts[2])
|
|
714
|
+
|
|
715
|
+
def _order_keys(
|
|
716
|
+
self, values: list[FieldValue], field: tuple[str, str, str]
|
|
717
|
+
) -> list[FieldValue]:
|
|
718
|
+
"""Apply the terminal data type's key and uncertainty rules to a tie group."""
|
|
719
|
+
entity_type, field_name, field_type = field
|
|
720
|
+
if field_type not in {'multi_entity', 'url', 'image', 'text'} and all(
|
|
721
|
+
value == values[0] for value in values
|
|
722
|
+
):
|
|
723
|
+
return [0] * len(values)
|
|
724
|
+
if field_type in {'multi_entity', 'url', 'image'}:
|
|
725
|
+
occupied = [
|
|
726
|
+
bool(value) if field_type == 'multi_entity' else value is not None
|
|
727
|
+
for value in values
|
|
728
|
+
]
|
|
729
|
+
if sum(occupied) > 1:
|
|
730
|
+
raise MockgunError('multiple opaque values have unsettled order')
|
|
731
|
+
return [0 if present else None for present in occupied]
|
|
732
|
+
if field_type == 'checkbox' and None in values:
|
|
733
|
+
raise MockgunError('null checkbox has unsettled order')
|
|
734
|
+
if field_type == 'status_list':
|
|
735
|
+
metadata = cast('dict[str, object]', self._schema.fields[entity_type][field_name])
|
|
736
|
+
properties = cast('dict[str, object]', metadata.get('properties', {}))
|
|
737
|
+
valid = cast('dict[str, object]', properties.get('valid_values', {})).get('value')
|
|
738
|
+
if isinstance(valid, list):
|
|
739
|
+
allowed = cast('list[FieldValue]', valid)
|
|
740
|
+
if any(value is not None and value not in allowed for value in values):
|
|
741
|
+
raise MockgunError('status value is outside valid_values')
|
|
742
|
+
return [None if value is None else allowed.index(value) for value in values]
|
|
743
|
+
if field_type == 'entity':
|
|
744
|
+
values = self._entity_order_keys(values)
|
|
745
|
+
if field_type in {'text', 'entity'}:
|
|
746
|
+
names = {value for value in values if isinstance(value, str)}
|
|
747
|
+
check_collation(names, entity_type=entity_type, field_name=field_name)
|
|
748
|
+
return [collation_key(value) if isinstance(value, str) else value for value in values]
|
|
749
|
+
return values
|
|
750
|
+
|
|
751
|
+
def _order_value(self, entity_type: str, row: Row, path: str) -> FieldValue:
|
|
752
|
+
"""Resolve an order value, rejecting hidden and invalid intermediate links."""
|
|
753
|
+
parts = path.split('.', 2)
|
|
754
|
+
field_name = parts[0]
|
|
755
|
+
value = self._direct_field_value(entity_type, row, field_name)
|
|
756
|
+
if self._visible_value(row, field_name, value) != value:
|
|
757
|
+
raise MockgunError('hidden link has unmodeled order')
|
|
758
|
+
if len(parts) == 1 or value is None:
|
|
759
|
+
return value
|
|
760
|
+
if not isinstance(value, dict) or value.get('type') != parts[1]:
|
|
761
|
+
raise MockgunError('order path has a different linked type')
|
|
762
|
+
return self._order_value(parts[1], self._linked_row(value), parts[2])
|
|
763
|
+
|
|
764
|
+
def _order_group(
|
|
765
|
+
self, rows: list[Row], entity_type: str, entry: dict[str, str]
|
|
766
|
+
) -> list[list[Row]]:
|
|
767
|
+
"""Refine one earlier-key tie group while keeping nulls and final IDs stable."""
|
|
768
|
+
if len(rows) <= 1:
|
|
769
|
+
return [rows]
|
|
770
|
+
path = entry['field_name']
|
|
771
|
+
field = self._order_field(entity_type, path)
|
|
772
|
+
try:
|
|
773
|
+
values = [self._order_value(entity_type, row, path) for row in rows]
|
|
774
|
+
keys = self._order_keys(values, field)
|
|
775
|
+
populated = [
|
|
776
|
+
(key, row) for key, row in zip(keys, rows, strict=True) if key is not None
|
|
777
|
+
]
|
|
778
|
+
nulls = [row for key, row in zip(keys, rows, strict=True) if key is None]
|
|
779
|
+
populated.sort(
|
|
780
|
+
key=lambda pair: cast('_ComparableFieldValue', pair[0]),
|
|
781
|
+
reverse=entry['direction'] == 'desc',
|
|
782
|
+
)
|
|
783
|
+
groups = [
|
|
784
|
+
[row for _, row in group]
|
|
785
|
+
for _, group in groupby(populated, key=lambda pair: pair[0])
|
|
786
|
+
]
|
|
787
|
+
if nulls:
|
|
788
|
+
if field[2] == 'image' and entry['direction'] == 'asc':
|
|
789
|
+
groups.insert(0, nulls)
|
|
790
|
+
else:
|
|
791
|
+
groups.append(nulls)
|
|
792
|
+
except (MockgunError, TypeError, AttributeError, KeyError) as exc:
|
|
793
|
+
raise MockgunError(
|
|
794
|
+
f'mockgun cannot order {entity_type}.{path} {field[2]}: {exc}'
|
|
795
|
+
) from exc
|
|
796
|
+
return groups
|
|
797
|
+
|
|
798
|
+
def _ordinary_rows(
|
|
799
|
+
self,
|
|
800
|
+
entity_type: str,
|
|
801
|
+
adapted: dict[str, object],
|
|
802
|
+
presets: tuple[Preset, ...],
|
|
803
|
+
scope: ReadScope,
|
|
804
|
+
) -> list[Row]:
|
|
805
|
+
"""Keep the established evaluator when no uncertain state is present."""
|
|
806
|
+
source_rows = self._store.rows(entity_type, retired=scope.retired)
|
|
807
|
+
latest = any(preset.name == 'LATEST' for preset in presets)
|
|
808
|
+
if not latest:
|
|
809
|
+
source_rows = [
|
|
810
|
+
row for row in source_rows if self._in_archive_scope(entity_type, row, scope)
|
|
811
|
+
]
|
|
812
|
+
rows = [row for row in source_rows if self._matches_tree(entity_type, row, adapted)]
|
|
813
|
+
if not scope.include_archived_projects and latest:
|
|
814
|
+
with assuming(None):
|
|
815
|
+
for group in latest_groups(self._store, entity_type, rows):
|
|
816
|
+
if len({self._in_archive_scope(entity_type, row, scope) for row in group}) > 1:
|
|
817
|
+
raise MockgunError(
|
|
818
|
+
f'mockgun cannot select {entity_type} LATEST preset: '
|
|
819
|
+
'project entity group mixes archived and active Projects'
|
|
820
|
+
)
|
|
821
|
+
rows = [row for row in rows if self._in_archive_scope(entity_type, row, scope)]
|
|
822
|
+
with assuming(None):
|
|
823
|
+
return apply_presets(self._store, entity_type, rows, presets, retired=scope.retired)
|
|
824
|
+
|
|
825
|
+
def _preset_paths(
|
|
826
|
+
self, row: Row, presets: tuple[Preset, ...], *, group_only: bool
|
|
827
|
+
) -> list[tuple[Row, str]]:
|
|
828
|
+
"""List guarded link reads in preset order, respecting membership exits."""
|
|
829
|
+
if not presets:
|
|
830
|
+
return []
|
|
831
|
+
kind = cast('str', row['type'])
|
|
832
|
+
paths = [(row, 'entity')]
|
|
833
|
+
if kind != 'Cut' and row.get('entity') is None:
|
|
834
|
+
return paths
|
|
835
|
+
if kind == 'PublishedFile':
|
|
836
|
+
paths.append((row, 'published_file_type'))
|
|
837
|
+
if row.get('published_file_type') is None:
|
|
838
|
+
return paths
|
|
839
|
+
paths.append((row, 'task'))
|
|
840
|
+
if not group_only and any(
|
|
841
|
+
preset.latest_by == 'BY_PIPELINE_STEP_NUMBER_AND_ENTITIES_CREATED_AT'
|
|
842
|
+
for preset in presets
|
|
843
|
+
):
|
|
844
|
+
paths.append((row, 'sg_task'))
|
|
845
|
+
task = row.get('sg_task')
|
|
846
|
+
if isinstance(task, dict):
|
|
847
|
+
paths.append((self._linked_row(task), 'step'))
|
|
848
|
+
return paths
|
|
849
|
+
|
|
850
|
+
@staticmethod
|
|
851
|
+
def _reads(value: _Decision) -> _StateReads:
|
|
852
|
+
"""Collect reachable reduced bits without generating any possible states."""
|
|
853
|
+
reads: _StateReads = {}
|
|
854
|
+
pending = [value]
|
|
855
|
+
visited: set[_Fork] = set()
|
|
856
|
+
while pending:
|
|
857
|
+
item = pending.pop()
|
|
858
|
+
if not isinstance(item, _Fork) or item in visited:
|
|
859
|
+
continue
|
|
860
|
+
visited.add(item)
|
|
861
|
+
kind, entity_id, field, target, target_id = item.bit
|
|
862
|
+
token = (field, target, target_id) if field else None
|
|
863
|
+
reads.setdefault((kind, entity_id), set()).add(token)
|
|
864
|
+
pending.extend((item.off, item.on))
|
|
865
|
+
return reads
|
|
866
|
+
|
|
867
|
+
def _select_assumed(
|
|
868
|
+
self,
|
|
869
|
+
entity_type: str,
|
|
870
|
+
rows: list[Row],
|
|
871
|
+
adapted: dict[str, object],
|
|
872
|
+
presets: tuple[Preset, ...],
|
|
873
|
+
) -> list[Row]:
|
|
874
|
+
"""Evaluate one final reduced state, keeping the ordinary guard order."""
|
|
875
|
+
scope = self._scope
|
|
876
|
+
assume = self._assume or {}
|
|
877
|
+
latest = any(preset.name == 'LATEST' for preset in presets)
|
|
878
|
+
selected: list[Row] = []
|
|
879
|
+
for row in rows:
|
|
880
|
+
identity = (entity_type, cast('int', row['id']))
|
|
881
|
+
state = assume.get(identity)
|
|
882
|
+
if (state.retired if state is not None else bool(row['__retired'])) != scope.retired:
|
|
883
|
+
continue
|
|
884
|
+
if (latest or self._in_archive_scope(entity_type, row, scope)) and self._matches_tree(
|
|
885
|
+
entity_type, row, adapted
|
|
886
|
+
):
|
|
887
|
+
selected.append(row)
|
|
888
|
+
if not scope.include_archived_projects and latest:
|
|
889
|
+
with assuming(assume):
|
|
890
|
+
for group in latest_groups(self._store, entity_type, selected):
|
|
891
|
+
if len({self._in_archive_scope(entity_type, row, scope) for row in group}) > 1:
|
|
892
|
+
raise MockgunError(
|
|
893
|
+
f'{entity_type} LATEST group mixes archived and active Projects'
|
|
894
|
+
)
|
|
895
|
+
selected = [row for row in selected if self._in_archive_scope(entity_type, row, scope)]
|
|
896
|
+
with assuming(assume):
|
|
897
|
+
return apply_presets(
|
|
898
|
+
self._store, entity_type, selected, presets, retired=scope.retired
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
def _state_counts(self, reads: _StateReads) -> dict[tuple[str, int], int]:
|
|
902
|
+
"""Count reduced states without materializing unread link combinations."""
|
|
903
|
+
counts: dict[tuple[str, int], int] = {}
|
|
904
|
+
for identity, tokens in reads.items():
|
|
905
|
+
row = self._store.require(*identity)
|
|
906
|
+
taint = cast('dict[str, FieldValue]', row.get('__taint', {}))
|
|
907
|
+
varies = bool(taint) and not taint.get('active')
|
|
908
|
+
if row['__retired'] and not varies:
|
|
909
|
+
counts[identity] = 1
|
|
910
|
+
else:
|
|
911
|
+
counts[identity] = 2 ** len(tokens - {None}) + int(varies and None in tokens)
|
|
912
|
+
return counts
|
|
913
|
+
|
|
914
|
+
def _states(self, reads: _StateReads) -> dict[tuple[str, int], tuple[RecordState, ...]]:
|
|
915
|
+
"""Build reduced states from only the retirement and link bits read."""
|
|
916
|
+
dimensions: dict[tuple[str, int], tuple[RecordState, ...]] = {}
|
|
917
|
+
for identity, count in self._state_counts(reads).items():
|
|
918
|
+
if count <= 1:
|
|
919
|
+
continue
|
|
920
|
+
tokens = reads[identity]
|
|
921
|
+
links = [token for token in tokens if token is not None]
|
|
922
|
+
states = [
|
|
923
|
+
RecordState(
|
|
924
|
+
retired=False,
|
|
925
|
+
hidden=frozenset(
|
|
926
|
+
link for link, hide in zip(links, flags, strict=True) if hide
|
|
927
|
+
),
|
|
928
|
+
)
|
|
929
|
+
for flags in product((False, True), repeat=len(links))
|
|
930
|
+
]
|
|
931
|
+
row = self._store.require(*identity)
|
|
932
|
+
taint = cast('dict[str, FieldValue]', row.get('__taint', {}))
|
|
933
|
+
if None in tokens and taint and not taint.get('active'):
|
|
934
|
+
states.append(RecordState(retired=True))
|
|
935
|
+
dimensions[identity] = tuple(states)
|
|
936
|
+
return dimensions
|
|
937
|
+
|
|
938
|
+
def _combinations(
|
|
939
|
+
self, reads: _StateReads, label: str
|
|
940
|
+
) -> list[dict[tuple[str, int], RecordState]]:
|
|
941
|
+
"""Check reduced cardinality before materializing any state combinations."""
|
|
942
|
+
count = prod(self._state_counts(reads).values())
|
|
943
|
+
if count > _STATE_LIMIT:
|
|
944
|
+
raise MockgunError(f'{label}: {count} combinations exceeds limit of {_STATE_LIMIT}')
|
|
945
|
+
dimensions = self._states(reads)
|
|
946
|
+
return [
|
|
947
|
+
dict(zip(dimensions, states, strict=True)) for states in product(*dimensions.values())
|
|
948
|
+
]
|
|
949
|
+
|
|
950
|
+
def _symbolic_retired(self, row: Row) -> _Decision:
|
|
951
|
+
"""Represent only an unsettled retirement, keeping known scope constant."""
|
|
952
|
+
taint = cast('dict[str, FieldValue]', row.get('__taint', {}))
|
|
953
|
+
if taint and not taint.get('active'):
|
|
954
|
+
return self._fork(
|
|
955
|
+
(cast('str', row['type']), cast('int', row['id']), '', '', 0), off=False, on=True
|
|
956
|
+
)
|
|
957
|
+
return bool(row['__retired'])
|
|
958
|
+
|
|
959
|
+
def _symbolic_hidden(self, row: Row, field: str, link: _EntityLink) -> _Decision:
|
|
960
|
+
"""Express hiding through the deciding record and the holder's scope."""
|
|
961
|
+
deciding = deciding_record(self._store, row, field, link)
|
|
962
|
+
if deciding is row:
|
|
963
|
+
outcome = self._fork(
|
|
964
|
+
(
|
|
965
|
+
cast('str', row['type']),
|
|
966
|
+
cast('int', row['id']),
|
|
967
|
+
field,
|
|
968
|
+
cast('str', link['type']),
|
|
969
|
+
cast('int', link['id']),
|
|
970
|
+
),
|
|
971
|
+
off=False,
|
|
972
|
+
on=True,
|
|
973
|
+
)
|
|
974
|
+
elif deciding is not None:
|
|
975
|
+
outcome = self._symbolic_retired(deciding)
|
|
976
|
+
else:
|
|
977
|
+
active = {
|
|
978
|
+
(cast('str', row['type']), cast('int', row['id'])): RecordState(retired=False)
|
|
979
|
+
}
|
|
980
|
+
outcome = hidden(self._store, row, field, link, assume=active)
|
|
981
|
+
return self._choose(self._symbolic_retired(row), yes=False, no=outcome)
|
|
982
|
+
|
|
983
|
+
def _symbolic_archive(self, row: Row, scope: ReadScope) -> _Decision:
|
|
984
|
+
"""Retain a project visibility bit only when its archive status matters."""
|
|
985
|
+
if scope.include_archived_projects or not self._schema.declares('Project', 'archived'):
|
|
986
|
+
return True
|
|
987
|
+
kind = cast('str', row['type'])
|
|
988
|
+
if kind == 'Project':
|
|
989
|
+
return row.get('archived') is not True
|
|
990
|
+
project = row.get('project')
|
|
991
|
+
if not self._schema.declares(kind, 'project') or not isinstance(project, dict):
|
|
992
|
+
return True
|
|
993
|
+
if project.get('type') != 'Project' or not isinstance(project.get('id'), int):
|
|
994
|
+
return True
|
|
995
|
+
target = self._store.row('Project', cast('int', project['id']))
|
|
996
|
+
if target is None or target.get('archived') is not True:
|
|
997
|
+
return True
|
|
998
|
+
return self._symbolic_hidden(row, 'project', project)
|
|
999
|
+
|
|
1000
|
+
def _symbolic_direct(self, row: Row, condition: dict[str, object]) -> _Decision:
|
|
1001
|
+
"""Reduce a direct condition before consulting matching link visibility."""
|
|
1002
|
+
kind = cast('str', row['type'])
|
|
1003
|
+
path = cast('str', condition['path'])
|
|
1004
|
+
relation = cast('str', condition['relation'])
|
|
1005
|
+
values = cast('list[FieldValue]', condition['values'])
|
|
1006
|
+
field_type = self._schema.field_type(kind, path)
|
|
1007
|
+
actual = self._direct_field_value(kind, row, path, membership=True)
|
|
1008
|
+
if field_type not in {'entity', 'multi_entity'}:
|
|
1009
|
+
return self._matches_direct(kind, row, path, relation, values)
|
|
1010
|
+
links = actual if isinstance(actual, list) else [actual]
|
|
1011
|
+
result: _Decision = False
|
|
1012
|
+
hides: _Decision = False
|
|
1013
|
+
for link in links:
|
|
1014
|
+
if not isinstance(link, dict):
|
|
1015
|
+
continue
|
|
1016
|
+
visibility = self._symbolic_hidden(row, path, link)
|
|
1017
|
+
if relation == 'is' and values and values[0] is not None:
|
|
1018
|
+
if self._compare_value('entity', link, relation, values):
|
|
1019
|
+
result = self._any(result, self._choose(visibility, yes=False, no=True))
|
|
1020
|
+
else:
|
|
1021
|
+
hides = self._any(hides, visibility)
|
|
1022
|
+
if relation == 'is' and values and values[0] is not None:
|
|
1023
|
+
return result
|
|
1024
|
+
if relation.startswith('name_'):
|
|
1025
|
+
actual = self._name_relation_value(actual)
|
|
1026
|
+
result = self._compare_value(field_type, actual, relation, values)
|
|
1027
|
+
return self._choose(
|
|
1028
|
+
hides,
|
|
1029
|
+
yes=_Failure(f'hidden {kind} {row["id"]}.{path} link has unmodeled {relation}'),
|
|
1030
|
+
no=result,
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
def _symbolic_field(self, row: Row, condition: dict[str, object]) -> _Decision:
|
|
1034
|
+
"""Build one condition's graph, preserving terminal reads and failure leaves."""
|
|
1035
|
+
try:
|
|
1036
|
+
path = cast('str', condition['path'])
|
|
1037
|
+
if len(path.split('.', 2)) != _LINK_PATH_PARTS:
|
|
1038
|
+
return self._symbolic_direct(row, condition)
|
|
1039
|
+
field = path.split('.', 1)[0]
|
|
1040
|
+
actual = self._direct_field_value(
|
|
1041
|
+
cast('str', row['type']), row, field, membership=True
|
|
1042
|
+
)
|
|
1043
|
+
links = actual if isinstance(actual, list) else [actual]
|
|
1044
|
+
result: _Decision = False
|
|
1045
|
+
for link in links:
|
|
1046
|
+
result = self._members(result, self._symbolic_link(row, link, condition))
|
|
1047
|
+
except (MockgunError, ServerFaultError, TypeError, AttributeError, KeyError) as exc:
|
|
1048
|
+
return _Failure(str(exc))
|
|
1049
|
+
else:
|
|
1050
|
+
return result
|
|
1051
|
+
|
|
1052
|
+
def _symbolic_link(
|
|
1053
|
+
self, row: Row, link: FieldValue, condition: dict[str, object]
|
|
1054
|
+
) -> _Decision:
|
|
1055
|
+
"""Resolve one deep member and conditionally retain errors from its target."""
|
|
1056
|
+
path = cast('str', condition['path'])
|
|
1057
|
+
field, linked_type, rest = path.split('.', 2)
|
|
1058
|
+
relation = cast('str', condition['relation'])
|
|
1059
|
+
values = cast('list[FieldValue]', condition['values'])
|
|
1060
|
+
field_type = self._schema.field_type(cast('str', row['type']), path)
|
|
1061
|
+
if link is None:
|
|
1062
|
+
return self._compare_value(field_type, None, relation, values)
|
|
1063
|
+
if not isinstance(link, dict):
|
|
1064
|
+
return _Failure(f'linked field path {row["type"]}.{path} contains a non-entity value')
|
|
1065
|
+
visibility = self._symbolic_hidden(row, field, link)
|
|
1066
|
+
try:
|
|
1067
|
+
linked = self._linked_row(link)
|
|
1068
|
+
if link['type'] != linked_type:
|
|
1069
|
+
outcome: _Decision = (
|
|
1070
|
+
_Failure('a different linked type matches as null')
|
|
1071
|
+
if self._compare_value(field_type, None, relation, values)
|
|
1072
|
+
else False
|
|
1073
|
+
)
|
|
1074
|
+
else:
|
|
1075
|
+
outcome = self._symbolic_field(linked, {**condition, 'path': rest})
|
|
1076
|
+
except (MockgunError, ServerFaultError) as exc:
|
|
1077
|
+
outcome = _Failure(str(exc))
|
|
1078
|
+
hidden_outcome: _Decision = (
|
|
1079
|
+
False
|
|
1080
|
+
if relation == 'is' and values and values[0] is not None
|
|
1081
|
+
else _Failure(
|
|
1082
|
+
f'hidden {row["type"]} {row["id"]}.{field} link has unmodeled {relation}'
|
|
1083
|
+
)
|
|
1084
|
+
)
|
|
1085
|
+
return self._choose(visibility, yes=hidden_outcome, no=outcome)
|
|
1086
|
+
|
|
1087
|
+
def _symbolic_paths(self, paths: list[tuple[Row, str]], outcome: _Decision) -> _Decision:
|
|
1088
|
+
"""Retain possible hidden-link failures for every reached preset dependency."""
|
|
1089
|
+
for holder, field in reversed(paths):
|
|
1090
|
+
value = holder.get(field)
|
|
1091
|
+
if isinstance(value, dict):
|
|
1092
|
+
outcome = self._choose(
|
|
1093
|
+
self._symbolic_hidden(holder, field, value),
|
|
1094
|
+
yes=_Failure(f'{holder["type"]} {holder["id"]}.{field}: hidden preset link'),
|
|
1095
|
+
no=outcome,
|
|
1096
|
+
)
|
|
1097
|
+
return outcome
|
|
1098
|
+
|
|
1099
|
+
def _symbolic_tree(self, row: Row, tree: dict[str, object]) -> _Decision:
|
|
1100
|
+
"""Apply condition order and short circuits before discovering dimensions."""
|
|
1101
|
+
conjunction = tree['logical_operator'] == 'and'
|
|
1102
|
+
result: _Decision = conjunction
|
|
1103
|
+
for condition in cast('list[dict[str, object]]', tree['conditions']):
|
|
1104
|
+
if result is (not conjunction):
|
|
1105
|
+
break
|
|
1106
|
+
outcome = (
|
|
1107
|
+
self._symbolic_tree(row, condition)
|
|
1108
|
+
if 'logical_operator' in condition
|
|
1109
|
+
else self._symbolic_field(row, condition)
|
|
1110
|
+
)
|
|
1111
|
+
result = self._all(result, outcome) if conjunction else self._any(result, outcome)
|
|
1112
|
+
return result
|
|
1113
|
+
|
|
1114
|
+
@staticmethod
|
|
1115
|
+
def _validate_relation(
|
|
1116
|
+
entity_type: str,
|
|
1117
|
+
field_name: str,
|
|
1118
|
+
field_type: str,
|
|
1119
|
+
relation: str,
|
|
1120
|
+
values: Sequence[object],
|
|
1121
|
+
) -> bool:
|
|
1122
|
+
"""Validate vocabulary and known arity, returning evaluator availability."""
|
|
1123
|
+
arities: dict[str, dict[str, int | None]] = {
|
|
1124
|
+
'checkbox': {'is': 1, 'is_not': 1},
|
|
1125
|
+
'entity': {
|
|
1126
|
+
'in': None,
|
|
1127
|
+
'not_in': None,
|
|
1128
|
+
'is': 1,
|
|
1129
|
+
'is_not': 1,
|
|
1130
|
+
'name_is': 1,
|
|
1131
|
+
'name_contains': 1,
|
|
1132
|
+
'name_ends_with': 1,
|
|
1133
|
+
'name_not_contains': 1,
|
|
1134
|
+
'name_starts_with': 1,
|
|
1135
|
+
'type_is': 1,
|
|
1136
|
+
'type_is_not': 1,
|
|
1137
|
+
},
|
|
1138
|
+
'entity_type': {'is': 1},
|
|
1139
|
+
'list': {'in': None, 'is': 1, 'is_not': 1, 'not_in': None},
|
|
1140
|
+
'multi_entity': {
|
|
1141
|
+
'in': None,
|
|
1142
|
+
'is': 1,
|
|
1143
|
+
'is_not': 1,
|
|
1144
|
+
'name_contains': 1,
|
|
1145
|
+
'name_is': 1,
|
|
1146
|
+
'name_not_contains': 1,
|
|
1147
|
+
'not_in': None,
|
|
1148
|
+
'type_is': 1,
|
|
1149
|
+
'type_is_not': 1,
|
|
1150
|
+
},
|
|
1151
|
+
'status_list': {'in': None, 'is': 1, 'is_not': 1, 'not_in': None},
|
|
1152
|
+
'text': {
|
|
1153
|
+
'contains': 1,
|
|
1154
|
+
'ends_with': 1,
|
|
1155
|
+
'in': None,
|
|
1156
|
+
'is': 1,
|
|
1157
|
+
'is_not': 1,
|
|
1158
|
+
'not_contains': 1,
|
|
1159
|
+
'not_in': None,
|
|
1160
|
+
'starts_with': 1,
|
|
1161
|
+
},
|
|
1162
|
+
}
|
|
1163
|
+
for numeric_type in _NUMERIC_TYPES:
|
|
1164
|
+
arities[numeric_type] = {
|
|
1165
|
+
'between': 2,
|
|
1166
|
+
'greater_than': 1,
|
|
1167
|
+
'in': None,
|
|
1168
|
+
'is': 1,
|
|
1169
|
+
'is_not': 1,
|
|
1170
|
+
'less_than': 1,
|
|
1171
|
+
'not_in': None,
|
|
1172
|
+
}
|
|
1173
|
+
for null_type in ('currency', 'image', 'timecode'):
|
|
1174
|
+
arities[null_type] = {'is': 1, 'is_not': 1}
|
|
1175
|
+
if field_type in {'currency', 'timecode'} and relation == 'not_between':
|
|
1176
|
+
raise ServerFaultError(
|
|
1177
|
+
f'API read() {entity_type}.{field_name} {field_type} does not support not_between',
|
|
1178
|
+
error_code=103,
|
|
1179
|
+
)
|
|
1180
|
+
if field_type in _UNFILTERABLE_TYPES:
|
|
1181
|
+
raise ServerFaultError(
|
|
1182
|
+
f'API read() {entity_type}.{field_name} {field_type} '
|
|
1183
|
+
'data type cannot be used in a filter',
|
|
1184
|
+
error_code=103,
|
|
1185
|
+
)
|
|
1186
|
+
valid = _VALID_RELATIONS.get(field_type)
|
|
1187
|
+
if valid is not None and relation not in valid:
|
|
1188
|
+
raise ServerFaultError(
|
|
1189
|
+
f'API read() {entity_type}.{field_name} {field_type} '
|
|
1190
|
+
f'does not support {relation}. Valid relations: {", ".join(valid)}',
|
|
1191
|
+
error_code=103,
|
|
1192
|
+
)
|
|
1193
|
+
if field_type == 'color':
|
|
1194
|
+
return (
|
|
1195
|
+
entity_type == 'Task'
|
|
1196
|
+
and field_name == 'color'
|
|
1197
|
+
and relation == 'is'
|
|
1198
|
+
and list(values) == ['pipeline_step']
|
|
1199
|
+
)
|
|
1200
|
+
field_relations = arities.get(field_type, {})
|
|
1201
|
+
expected = field_relations.get(relation)
|
|
1202
|
+
if expected is not None and len(values) != expected:
|
|
1203
|
+
if field_type == 'multi_entity' and relation == 'is':
|
|
1204
|
+
raise ServerFaultError(
|
|
1205
|
+
"API read() 'is' relation expects a single value for "
|
|
1206
|
+
f'{entity_type}.{field_name}',
|
|
1207
|
+
error_code=103,
|
|
1208
|
+
)
|
|
1209
|
+
raise ServerFaultError(f'{relation} requires exactly {expected} value(s)')
|
|
1210
|
+
return relation in field_relations
|
|
1211
|
+
|
|
1212
|
+
def _validate_tree(self, entity_type: str, tree: dict[str, object]) -> None:
|
|
1213
|
+
"""Validate all filter relations before evaluating any database row."""
|
|
1214
|
+
conditions = cast('list[dict[str, object]]', tree['conditions'])
|
|
1215
|
+
for condition in conditions:
|
|
1216
|
+
if 'logical_operator' in condition:
|
|
1217
|
+
self._validate_tree(entity_type, condition)
|
|
1218
|
+
continue
|
|
1219
|
+
field_name = cast('str', condition['path'])
|
|
1220
|
+
field_type = self._filter_field_type(entity_type, field_name)
|
|
1221
|
+
relation = cast('str', condition['relation'])
|
|
1222
|
+
if field_type == 'url':
|
|
1223
|
+
for row in self._store.rows(entity_type, retired=self._scope.retired):
|
|
1224
|
+
self._guard_uploaded_file(row, field_name)
|
|
1225
|
+
if field_type in {'date', 'date_time'} and relation in _RELATIVE_RELATIONS:
|
|
1226
|
+
validate_relative(
|
|
1227
|
+
f'{entity_type}.{field_name}',
|
|
1228
|
+
field_type,
|
|
1229
|
+
relation,
|
|
1230
|
+
cast('list[FieldValue]', condition['values']),
|
|
1231
|
+
context=self._scope.context,
|
|
1232
|
+
)
|
|
1233
|
+
continue
|
|
1234
|
+
implemented = self._validate_relation(
|
|
1235
|
+
entity_type,
|
|
1236
|
+
field_name,
|
|
1237
|
+
field_type,
|
|
1238
|
+
cast('str', condition['relation']),
|
|
1239
|
+
cast('list[FieldValue]', condition['values']),
|
|
1240
|
+
)
|
|
1241
|
+
_validate_operands(
|
|
1242
|
+
entity_type,
|
|
1243
|
+
field_name,
|
|
1244
|
+
field_type,
|
|
1245
|
+
cast('str', condition['relation']),
|
|
1246
|
+
cast('list[FieldValue]', condition['values']),
|
|
1247
|
+
)
|
|
1248
|
+
if not implemented:
|
|
1249
|
+
raise MockgunError(
|
|
1250
|
+
f'mockgun does not implement {entity_type}.{field_name} '
|
|
1251
|
+
f'{field_type} relation {condition["relation"]}'
|
|
1252
|
+
)
|
|
1253
|
+
if field_type == 'date_time':
|
|
1254
|
+
try:
|
|
1255
|
+
condition['values'] = [
|
|
1256
|
+
parse_wire_datetime(value) if isinstance(value, str) else value
|
|
1257
|
+
for value in cast('list[object]', condition['values'])
|
|
1258
|
+
]
|
|
1259
|
+
except (ValueError, OverflowError) as exc:
|
|
1260
|
+
raise MockgunError(
|
|
1261
|
+
f'mockgun cannot parse {entity_type}.{field_name} '
|
|
1262
|
+
f'{field_type} operand: {exc}'
|
|
1263
|
+
) from exc
|
|
1264
|
+
|
|
1265
|
+
def _validate_selection(
|
|
1266
|
+
self, entity_type: str, tree: dict[str, object], scope: ReadScope
|
|
1267
|
+
) -> tuple[dict[str, object], tuple[Preset, ...]]:
|
|
1268
|
+
"""Validate filters and presets together before candidate selection."""
|
|
1269
|
+
preset_error: MockgunError | ServerFaultError | None = None
|
|
1270
|
+
try:
|
|
1271
|
+
presets = validate_presets(
|
|
1272
|
+
self._schema, entity_type, [] if scope.presets is None else scope.presets
|
|
1273
|
+
)
|
|
1274
|
+
except (MockgunError, ServerFaultError) as exc:
|
|
1275
|
+
preset_error = exc
|
|
1276
|
+
presets = ()
|
|
1277
|
+
try:
|
|
1278
|
+
adapted = _adapt_condition_tree(tree)
|
|
1279
|
+
self._validate_tree(entity_type, adapted)
|
|
1280
|
+
except (MockgunError, ServerFaultError) as exc:
|
|
1281
|
+
if preset_error is not None:
|
|
1282
|
+
raise MockgunError(
|
|
1283
|
+
f'mockgun cannot select {entity_type}: invalid filters and presets'
|
|
1284
|
+
) from exc
|
|
1285
|
+
raise
|
|
1286
|
+
if preset_error is not None:
|
|
1287
|
+
raise preset_error
|
|
1288
|
+
return adapted, presets
|
|
1289
|
+
|
|
1290
|
+
def _visible_assumption(self) -> dict[tuple[str, int], RecordState]:
|
|
1291
|
+
"""Supply one canonical visible state without generating state combinations."""
|
|
1292
|
+
return {
|
|
1293
|
+
(kind, cast('int', row['id'])): RecordState(retired=retired)
|
|
1294
|
+
for kind in self._schema.fields
|
|
1295
|
+
for retired in (False, True)
|
|
1296
|
+
for row in self._store.rows(kind, retired=retired)
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
def _dominance_safe(
|
|
1300
|
+
self, candidate: _Candidate, group: list[_Candidate], presets: tuple[Preset, ...]
|
|
1301
|
+
) -> bool:
|
|
1302
|
+
"""Prove group guards unchanged using their pairwise witnesses and certain rows."""
|
|
1303
|
+
if self._errors(candidate.selected) is not True:
|
|
1304
|
+
return False
|
|
1305
|
+
if any(
|
|
1306
|
+
self._archive_guard(candidate, other) is not True
|
|
1307
|
+
for other in group
|
|
1308
|
+
if other is not candidate
|
|
1309
|
+
):
|
|
1310
|
+
return False
|
|
1311
|
+
certain = [other.row for other in group if other.selected is True]
|
|
1312
|
+
peers = [
|
|
1313
|
+
certain,
|
|
1314
|
+
*[
|
|
1315
|
+
[*certain, other.row]
|
|
1316
|
+
for other in group
|
|
1317
|
+
if other is not candidate
|
|
1318
|
+
and other.selected is not True
|
|
1319
|
+
and other.selected is not False
|
|
1320
|
+
],
|
|
1321
|
+
]
|
|
1322
|
+
kind = cast('str', candidate.row['type'])
|
|
1323
|
+
assume = self._visible_assumption()
|
|
1324
|
+
for rows in peers:
|
|
1325
|
+
errors: list[bool] = []
|
|
1326
|
+
for comparison in (rows, [*rows, candidate.row]):
|
|
1327
|
+
try:
|
|
1328
|
+
with assuming(assume):
|
|
1329
|
+
apply_presets(self._store, kind, comparison, presets, retired=False)
|
|
1330
|
+
except MockgunError:
|
|
1331
|
+
errors.append(True)
|
|
1332
|
+
else:
|
|
1333
|
+
errors.append(False)
|
|
1334
|
+
if errors[0] != errors[1]:
|
|
1335
|
+
return False
|
|
1336
|
+
return True
|
|
1337
|
+
|
|
1338
|
+
def _dominance(
|
|
1339
|
+
self, entity_type: str, group: list[_Candidate], presets: tuple[Preset, ...]
|
|
1340
|
+
) -> list[_Candidate]:
|
|
1341
|
+
"""Drop strictly losing candidates before counting any state product."""
|
|
1342
|
+
mode = cast('str', next(preset for preset in presets if preset.name == 'LATEST').latest_by)
|
|
1343
|
+
certain = [candidate.row for candidate in group if candidate.selected is True]
|
|
1344
|
+
best = max(
|
|
1345
|
+
(latest_rank_key(self._store, entity_type, row, mode) for row in certain), default=None
|
|
1346
|
+
)
|
|
1347
|
+
retained: list[_Candidate] = []
|
|
1348
|
+
for candidate in group:
|
|
1349
|
+
if candidate.selected is not True and best is not None:
|
|
1350
|
+
try:
|
|
1351
|
+
loses = latest_rank_key(self._store, entity_type, candidate.row, mode) < best
|
|
1352
|
+
except MockgunError:
|
|
1353
|
+
loses = False
|
|
1354
|
+
if loses and self._dominance_safe(candidate, group, presets):
|
|
1355
|
+
continue
|
|
1356
|
+
retained.append(candidate)
|
|
1357
|
+
return retained
|
|
1358
|
+
|
|
1359
|
+
def _symbolic_external(self, entity_type: str, presets: tuple[Preset, ...]) -> _Decision:
|
|
1360
|
+
"""Keep external preset reads independent of candidate and dominance rejection."""
|
|
1361
|
+
try:
|
|
1362
|
+
paths = preset_dependencies(self._store, presets)
|
|
1363
|
+
with assuming(self._visible_assumption()):
|
|
1364
|
+
apply_presets(
|
|
1365
|
+
self._store,
|
|
1366
|
+
entity_type,
|
|
1367
|
+
[],
|
|
1368
|
+
presets,
|
|
1369
|
+
retired=self._scope.retired,
|
|
1370
|
+
)
|
|
1371
|
+
except (MockgunError, ServerFaultError) as exc:
|
|
1372
|
+
return _Failure(str(exc))
|
|
1373
|
+
return self._symbolic_paths(paths, outcome=True)
|
|
1374
|
+
|
|
1375
|
+
def _symbolic_preset(
|
|
1376
|
+
self, row: Row, presets: tuple[Preset, ...], *, group_only: bool = False
|
|
1377
|
+
) -> _Decision:
|
|
1378
|
+
"""Keep preset failures conditional, without enumerating a candidate's states."""
|
|
1379
|
+
if not presets:
|
|
1380
|
+
return True
|
|
1381
|
+
kind = cast('str', row['type'])
|
|
1382
|
+
assume = self._visible_assumption()
|
|
1383
|
+
try:
|
|
1384
|
+
paths = self._preset_paths(row, presets, group_only=group_only)
|
|
1385
|
+
with assuming(assume):
|
|
1386
|
+
outcome: _Decision = bool(
|
|
1387
|
+
latest_groups(self._store, kind, [row])
|
|
1388
|
+
if group_only
|
|
1389
|
+
else apply_presets(
|
|
1390
|
+
self._store,
|
|
1391
|
+
kind,
|
|
1392
|
+
[row],
|
|
1393
|
+
presets,
|
|
1394
|
+
retired=self._scope.retired,
|
|
1395
|
+
)
|
|
1396
|
+
)
|
|
1397
|
+
except (MockgunError, ServerFaultError) as exc:
|
|
1398
|
+
return _Failure(str(exc))
|
|
1399
|
+
return self._symbolic_paths(paths, outcome)
|
|
1400
|
+
|
|
1401
|
+
def _candidate(
|
|
1402
|
+
self, row: Row, adapted: dict[str, object], presets: tuple[Preset, ...]
|
|
1403
|
+
) -> _Candidate | None:
|
|
1404
|
+
"""Reject known scope and constant candidacy before retaining any dimensions."""
|
|
1405
|
+
scope = self._scope
|
|
1406
|
+
admitted = self._choose(
|
|
1407
|
+
self._symbolic_retired(row), yes=scope.retired, no=not scope.retired
|
|
1408
|
+
)
|
|
1409
|
+
if admitted is False:
|
|
1410
|
+
return None
|
|
1411
|
+
latest = any(preset.name == 'LATEST' for preset in presets)
|
|
1412
|
+
archive = self._symbolic_archive(row, scope)
|
|
1413
|
+
if not latest:
|
|
1414
|
+
admitted = self._all(admitted, archive)
|
|
1415
|
+
if admitted is False:
|
|
1416
|
+
return None
|
|
1417
|
+
member = self._all(admitted, self._symbolic_tree(row, adapted))
|
|
1418
|
+
if member is False:
|
|
1419
|
+
return None
|
|
1420
|
+
if latest:
|
|
1421
|
+
member = self._all(member, self._symbolic_preset(row, presets, group_only=True))
|
|
1422
|
+
selected = self._all(member, archive)
|
|
1423
|
+
selected = self._all(selected, self._symbolic_preset(row, presets))
|
|
1424
|
+
if selected is False and (not latest or member is False):
|
|
1425
|
+
return None
|
|
1426
|
+
return _Candidate(row, member, archive, selected)
|
|
1427
|
+
|
|
1428
|
+
def _uncertainty_error(self, reads: _StateReads, detail: str = '') -> MockgunError:
|
|
1429
|
+
"""Name deciding identities, fields and taint sources for a state-dependent answer."""
|
|
1430
|
+
reasons: list[str] = []
|
|
1431
|
+
for identity, tokens in reads.items():
|
|
1432
|
+
row = self._store.require(*identity)
|
|
1433
|
+
reason = self._store.tainted(row)
|
|
1434
|
+
links = [token for token in tokens if token is not None]
|
|
1435
|
+
fields = ','.join(token[0] for token in links) or 'retirement'
|
|
1436
|
+
if reason is None:
|
|
1437
|
+
reason = ', '.join(
|
|
1438
|
+
f'retired {kind} {entity_id} keep link' for _, kind, entity_id in links
|
|
1439
|
+
)
|
|
1440
|
+
reasons.append(f'{identity[0]} {identity[1]}.{fields}: {reason}')
|
|
1441
|
+
return MockgunError('; '.join([*reasons, detail] if detail else reasons))
|
|
1442
|
+
|
|
1443
|
+
def _select_unit(
|
|
1444
|
+
self,
|
|
1445
|
+
entity_type: str,
|
|
1446
|
+
rows: list[Row],
|
|
1447
|
+
reads: _StateReads,
|
|
1448
|
+
adapted: dict[str, object],
|
|
1449
|
+
presets: tuple[Preset, ...],
|
|
1450
|
+
) -> list[Row]:
|
|
1451
|
+
"""Count only reduced dimensions, then require identical outcomes in every state."""
|
|
1452
|
+
for identity, tokens in self._reads(self._external).items():
|
|
1453
|
+
reads.setdefault(identity, set()).update(tokens)
|
|
1454
|
+
answer: list[Row] | None = None
|
|
1455
|
+
for combination in self._combinations(
|
|
1456
|
+
reads, f'{entity_type} group {[row["id"] for row in rows]}'
|
|
1457
|
+
):
|
|
1458
|
+
self._assume = {**self._visible_assumption(), **combination}
|
|
1459
|
+
try:
|
|
1460
|
+
selected = self._select_assumed(entity_type, rows, adapted, presets)
|
|
1461
|
+
except MockgunError as exc:
|
|
1462
|
+
if reads:
|
|
1463
|
+
raise self._uncertainty_error(reads, str(exc)) from exc
|
|
1464
|
+
raise
|
|
1465
|
+
if answer is not None and [row['id'] for row in selected] != [
|
|
1466
|
+
row['id'] for row in answer
|
|
1467
|
+
]:
|
|
1468
|
+
raise self._uncertainty_error(reads)
|
|
1469
|
+
answer = selected
|
|
1470
|
+
return answer or []
|
|
1471
|
+
|
|
1472
|
+
@staticmethod
|
|
1473
|
+
def _stored_groups(entity_type: str, candidates: list[_Candidate]) -> list[list[_Candidate]]:
|
|
1474
|
+
"""Partition possible candidates by stored preset group keys."""
|
|
1475
|
+
groups: dict[tuple[object, ...], list[_Candidate]] = {}
|
|
1476
|
+
for candidate in candidates:
|
|
1477
|
+
row = candidate.row
|
|
1478
|
+
entity = row.get('entity')
|
|
1479
|
+
identity = (entity.get('type'), entity.get('id')) if isinstance(entity, dict) else None
|
|
1480
|
+
task = row.get('task')
|
|
1481
|
+
task_key = (task.get('type'), task.get('id')) if isinstance(task, dict) else None
|
|
1482
|
+
key = (
|
|
1483
|
+
(identity, task_key, row.get('name'))
|
|
1484
|
+
if entity_type == 'PublishedFile'
|
|
1485
|
+
else (row.get('code'), identity)
|
|
1486
|
+
if entity_type == 'Cut'
|
|
1487
|
+
else (identity,)
|
|
1488
|
+
)
|
|
1489
|
+
groups.setdefault(key, []).append(candidate)
|
|
1490
|
+
return list(groups.values())
|
|
1491
|
+
|
|
1492
|
+
def _latest_units(
|
|
1493
|
+
self,
|
|
1494
|
+
entity_type: str,
|
|
1495
|
+
candidates: list[_Candidate],
|
|
1496
|
+
adapted: dict[str, object],
|
|
1497
|
+
presets: tuple[Preset, ...],
|
|
1498
|
+
) -> list[Row]:
|
|
1499
|
+
"""Apply all reductions before collecting each group's final state dimensions."""
|
|
1500
|
+
result: list[Row] = []
|
|
1501
|
+
for group in self._stored_groups(entity_type, candidates):
|
|
1502
|
+
retained = self._dominance(entity_type, group, presets)
|
|
1503
|
+
decisions = [candidate.selected for candidate in retained]
|
|
1504
|
+
decisions.extend(self._errors(candidate.member) for candidate in retained)
|
|
1505
|
+
decisions.extend(
|
|
1506
|
+
self._archive_guard(left, right) for left, right in combinations(retained, 2)
|
|
1507
|
+
)
|
|
1508
|
+
reads: _StateReads = {}
|
|
1509
|
+
for decision in decisions:
|
|
1510
|
+
for key, tokens in self._reads(decision).items():
|
|
1511
|
+
reads.setdefault(key, set()).update(tokens)
|
|
1512
|
+
result.extend(
|
|
1513
|
+
self._select_unit(
|
|
1514
|
+
entity_type, [candidate.row for candidate in retained], reads, adapted, presets
|
|
1515
|
+
)
|
|
1516
|
+
)
|
|
1517
|
+
return result
|
|
1518
|
+
|
|
1519
|
+
def _preset_rows(
|
|
1520
|
+
self,
|
|
1521
|
+
entity_type: str,
|
|
1522
|
+
adapted: dict[str, object],
|
|
1523
|
+
presets: tuple[Preset, ...],
|
|
1524
|
+
scope: ReadScope,
|
|
1525
|
+
) -> list[Row]:
|
|
1526
|
+
"""Reduce symbolic candidate outcomes, then perform bounded concrete evaluation."""
|
|
1527
|
+
if not self._has_uncertainty():
|
|
1528
|
+
return self._ordinary_rows(entity_type, adapted, presets, scope)
|
|
1529
|
+
source_rows = self._store.rows(entity_type, retired=scope.retired)
|
|
1530
|
+
for row in self._store.tainted_rows(entity_type):
|
|
1531
|
+
if row not in source_rows:
|
|
1532
|
+
source_rows.append(row)
|
|
1533
|
+
try:
|
|
1534
|
+
self._external = self._symbolic_external(entity_type, presets)
|
|
1535
|
+
candidates = [
|
|
1536
|
+
candidate
|
|
1537
|
+
for row in source_rows
|
|
1538
|
+
if (candidate := self._candidate(row, adapted, presets)) is not None
|
|
1539
|
+
]
|
|
1540
|
+
if not candidates:
|
|
1541
|
+
return self._select_unit(entity_type, [], {}, adapted, presets)
|
|
1542
|
+
if not any(preset.name == 'LATEST' for preset in presets):
|
|
1543
|
+
return [
|
|
1544
|
+
selected
|
|
1545
|
+
for candidate in candidates
|
|
1546
|
+
for selected in self._select_unit(
|
|
1547
|
+
entity_type,
|
|
1548
|
+
[candidate.row],
|
|
1549
|
+
self._reads(candidate.selected),
|
|
1550
|
+
adapted,
|
|
1551
|
+
presets,
|
|
1552
|
+
)
|
|
1553
|
+
]
|
|
1554
|
+
return self._latest_units(entity_type, candidates, adapted, presets)
|
|
1555
|
+
finally:
|
|
1556
|
+
self._assume = None
|
|
1557
|
+
|
|
1558
|
+
def select(
|
|
1559
|
+
self,
|
|
1560
|
+
entity_type: str,
|
|
1561
|
+
tree: dict[str, object],
|
|
1562
|
+
order: list[dict[str, str]],
|
|
1563
|
+
) -> list[Row]:
|
|
1564
|
+
"""Validate and order candidates by clauses, then ascending record ID.
|
|
1565
|
+
|
|
1566
|
+
Args:
|
|
1567
|
+
entity_type: Entity type to query.
|
|
1568
|
+
tree: Wire filter tree with `logical_operator` and `conditions`;
|
|
1569
|
+
leaf conditions contain `path`, `relation`, and `values`.
|
|
1570
|
+
order: Ordered mappings containing `field_name` and an `asc` or
|
|
1571
|
+
`desc` direction.
|
|
1572
|
+
|
|
1573
|
+
Returns:
|
|
1574
|
+
Live stored rows in the requested order, with ascending ID as the
|
|
1575
|
+
final tie breaker.
|
|
1576
|
+
|
|
1577
|
+
Raises:
|
|
1578
|
+
ServerFaultError: If selection has a modeled entity, filter, or
|
|
1579
|
+
preset fault.
|
|
1580
|
+
MockgunError: If selection, linked state, event state, or ordering
|
|
1581
|
+
is unsupported or unsettled.
|
|
1582
|
+
"""
|
|
1583
|
+
if entity_type == 'EventLogEntry' and self._store.events_unknown is not None:
|
|
1584
|
+
raise MockgunError(f'EventLogEntry is unknown: {self._store.events_unknown}')
|
|
1585
|
+
adapted, presets = self._validate_selection(entity_type, tree, self._scope)
|
|
1586
|
+
clauses: list[tuple[dict[str, str], str]] = []
|
|
1587
|
+
for entry in order:
|
|
1588
|
+
path = entry['field_name']
|
|
1589
|
+
if entry['direction'] not in {'asc', 'desc'}:
|
|
1590
|
+
raise ValueError(f'unsupported ordering direction: {entry["direction"]}')
|
|
1591
|
+
if (
|
|
1592
|
+
'.' not in path
|
|
1593
|
+
and path not in {'id', 'type'}
|
|
1594
|
+
and not self._schema.declares(entity_type, path)
|
|
1595
|
+
):
|
|
1596
|
+
continue
|
|
1597
|
+
try:
|
|
1598
|
+
field_type = self._order_field(entity_type, path)[2]
|
|
1599
|
+
except (MockgunError, ServerFaultError) as exc:
|
|
1600
|
+
raise MockgunError(f'mockgun cannot order {entity_type}.{path}: {exc}') from exc
|
|
1601
|
+
clauses.append((entry, field_type))
|
|
1602
|
+
rows = self._preset_rows(entity_type, adapted, presets, self._scope)
|
|
1603
|
+
rows.sort(key=lambda row: cast('int', row['id']))
|
|
1604
|
+
groups = [rows]
|
|
1605
|
+
for entry, field_type in clauses:
|
|
1606
|
+
if field_type == 'url':
|
|
1607
|
+
for row in rows:
|
|
1608
|
+
self._guard_uploaded_file(row, entry['field_name'])
|
|
1609
|
+
if len(rows) > 1 and field_type not in _ORDER_TYPES:
|
|
1610
|
+
raise MockgunError(
|
|
1611
|
+
f'mockgun cannot order {entity_type}.{entry["field_name"]} {field_type}'
|
|
1612
|
+
)
|
|
1613
|
+
groups = [
|
|
1614
|
+
part for group in groups for part in self._order_group(group, entity_type, entry)
|
|
1615
|
+
]
|
|
1616
|
+
return [row for group in groups for row in group]
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _adapt_condition_tree(tree: dict[str, object]) -> dict[str, object]:
|
|
1620
|
+
"""Copy a source-form condition tree into the evaluator representation."""
|
|
1621
|
+
operator = tree.get('logical_operator')
|
|
1622
|
+
if not isinstance(operator, str) or operator not in {'and', 'or'}:
|
|
1623
|
+
raise ServerFaultError('condition-tree logical_operator must be "and" or "or"')
|
|
1624
|
+
source_conditions = tree.get('conditions')
|
|
1625
|
+
if not isinstance(source_conditions, Sequence) or isinstance(
|
|
1626
|
+
source_conditions,
|
|
1627
|
+
(str, bytes, bytearray),
|
|
1628
|
+
):
|
|
1629
|
+
raise ServerFaultError('condition-tree conditions must be a sequence')
|
|
1630
|
+
|
|
1631
|
+
conditions: list[dict[str, object]] = []
|
|
1632
|
+
for source_condition in cast('Sequence[object]', source_conditions):
|
|
1633
|
+
if not isinstance(source_condition, dict):
|
|
1634
|
+
raise ServerFaultError('condition-tree nodes must be dictionaries')
|
|
1635
|
+
condition = cast('dict[str, object]', source_condition)
|
|
1636
|
+
if 'logical_operator' in condition or 'conditions' in condition:
|
|
1637
|
+
conditions.append(_adapt_condition_tree(condition))
|
|
1638
|
+
continue
|
|
1639
|
+
path = condition.get('path')
|
|
1640
|
+
if not isinstance(path, str):
|
|
1641
|
+
raise ServerFaultError('condition-tree leaf path must be a string')
|
|
1642
|
+
relation = condition.get('relation')
|
|
1643
|
+
if not isinstance(relation, str):
|
|
1644
|
+
raise ServerFaultError('condition-tree leaf relation must be a string')
|
|
1645
|
+
values = condition.get('values')
|
|
1646
|
+
if not isinstance(values, list):
|
|
1647
|
+
raise ServerFaultError('condition-tree leaf values must be a list')
|
|
1648
|
+
values = cast('list[object]', values)
|
|
1649
|
+
if path.startswith('$FROM$'):
|
|
1650
|
+
continue
|
|
1651
|
+
conditions.append(
|
|
1652
|
+
{
|
|
1653
|
+
'path': path,
|
|
1654
|
+
'relation': relation,
|
|
1655
|
+
'values': copy.deepcopy(values),
|
|
1656
|
+
}
|
|
1657
|
+
)
|
|
1658
|
+
return {'logical_operator': operator, 'conditions': conditions}
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def _compare_entity_metadata(actual: _EntityLink | None, relation: str, expected: str) -> bool:
|
|
1662
|
+
"""Compare nullable link types or case-insensitive modeled display names."""
|
|
1663
|
+
if actual is None:
|
|
1664
|
+
return relation in {'name_not_contains', 'type_is_not'}
|
|
1665
|
+
if relation == 'type_is':
|
|
1666
|
+
return actual['type'] == expected
|
|
1667
|
+
if relation == 'type_is_not':
|
|
1668
|
+
return actual['type'] != expected
|
|
1669
|
+
name = cast('str', actual['name']).lower()
|
|
1670
|
+
expected = expected.lower()
|
|
1671
|
+
if relation == 'name_is':
|
|
1672
|
+
return name == expected
|
|
1673
|
+
contains = expected in name
|
|
1674
|
+
return not contains if relation == 'name_not_contains' else contains
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _compare_equality(actual: FieldValue, relation: str, values: list[FieldValue]) -> bool:
|
|
1678
|
+
"""Evaluate a validated checkbox or null-only scalar relation."""
|
|
1679
|
+
return actual == values[0] if relation == 'is' else actual != values[0]
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def _compare_numeric(
|
|
1683
|
+
actual: _ComparableFieldValue | None,
|
|
1684
|
+
relation: str,
|
|
1685
|
+
values: list[_ComparableFieldValue | None],
|
|
1686
|
+
) -> bool:
|
|
1687
|
+
"""Evaluate a validated numeric, date, or datetime relation."""
|
|
1688
|
+
first = values[0] if values else None
|
|
1689
|
+
if relation == 'is':
|
|
1690
|
+
result = actual == first
|
|
1691
|
+
elif relation == 'is_not':
|
|
1692
|
+
result = actual != first
|
|
1693
|
+
elif relation == 'in':
|
|
1694
|
+
result = actual in values
|
|
1695
|
+
elif relation == 'not_in':
|
|
1696
|
+
result = actual not in values
|
|
1697
|
+
elif actual is None or first is None:
|
|
1698
|
+
result = False
|
|
1699
|
+
elif relation == 'less_than':
|
|
1700
|
+
result = actual < first
|
|
1701
|
+
elif relation == 'greater_than':
|
|
1702
|
+
result = actual > first
|
|
1703
|
+
else:
|
|
1704
|
+
second = values[1]
|
|
1705
|
+
if second is None:
|
|
1706
|
+
return False
|
|
1707
|
+
lower, upper = (second, first) if second < first else (first, second)
|
|
1708
|
+
result = lower <= actual <= upper
|
|
1709
|
+
return result
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def _compare_selection(actual: str | None, relation: str, values: list[str | None]) -> bool:
|
|
1713
|
+
"""Evaluate a validated list or status-list relation."""
|
|
1714
|
+
actual = actual.lower() if isinstance(actual, str) else actual
|
|
1715
|
+
values = [value.lower() if isinstance(value, str) else value for value in values]
|
|
1716
|
+
first = values[0] if values else None
|
|
1717
|
+
if relation == 'is':
|
|
1718
|
+
result = actual == first
|
|
1719
|
+
elif relation == 'is_not':
|
|
1720
|
+
result = actual != first
|
|
1721
|
+
elif relation == 'in':
|
|
1722
|
+
result = actual in values
|
|
1723
|
+
else:
|
|
1724
|
+
result = actual not in values
|
|
1725
|
+
return result
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _compare_text_fragment(actual: str | None, relation: str, expected: str | None) -> bool:
|
|
1729
|
+
"""Evaluate a validated case-insensitive text fragment relation."""
|
|
1730
|
+
normalized_actual = (actual or '').lower()
|
|
1731
|
+
normalized_expected = (expected or '').lower()
|
|
1732
|
+
contains = normalized_expected in normalized_actual and (
|
|
1733
|
+
actual is not None or not isinstance(expected, str)
|
|
1734
|
+
)
|
|
1735
|
+
if relation == 'contains':
|
|
1736
|
+
result = contains
|
|
1737
|
+
elif relation == 'not_contains':
|
|
1738
|
+
result = not contains
|
|
1739
|
+
elif relation == 'starts_with':
|
|
1740
|
+
result = normalized_actual.startswith(normalized_expected)
|
|
1741
|
+
else:
|
|
1742
|
+
result = normalized_actual.endswith(normalized_expected)
|
|
1743
|
+
return result
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def _compare_text_membership(
|
|
1747
|
+
actual: str | None,
|
|
1748
|
+
relation: str,
|
|
1749
|
+
values: list[str | list[str | None] | None],
|
|
1750
|
+
) -> bool:
|
|
1751
|
+
"""Evaluate a validated case-insensitive text membership relation."""
|
|
1752
|
+
normalized_actual = actual.lower() if actual is not None else None
|
|
1753
|
+
normalized_values: list[str | list[str | None] | None] = []
|
|
1754
|
+
for value in values:
|
|
1755
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
1756
|
+
items = cast('Sequence[str | None]', value)
|
|
1757
|
+
normalized_values.append(
|
|
1758
|
+
[item.lower() if item is not None else None for item in items]
|
|
1759
|
+
)
|
|
1760
|
+
else:
|
|
1761
|
+
normalized_values.append(value.lower() if value is not None else None)
|
|
1762
|
+
first = normalized_values[0] if normalized_values else None
|
|
1763
|
+
if first == '' and relation in {'is', 'is_not'}:
|
|
1764
|
+
first = None
|
|
1765
|
+
if relation == 'is':
|
|
1766
|
+
result = normalized_actual == first
|
|
1767
|
+
elif relation == 'is_not':
|
|
1768
|
+
result = normalized_actual != first
|
|
1769
|
+
elif relation == 'in':
|
|
1770
|
+
result = normalized_actual in normalized_values
|
|
1771
|
+
else:
|
|
1772
|
+
result = normalized_actual not in normalized_values
|
|
1773
|
+
return result
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def _entity_identity(value: Mapping[str, FieldValue]) -> tuple[FieldValue, FieldValue]:
|
|
1777
|
+
"""Return the source-observed identity pair for an entity value."""
|
|
1778
|
+
return value['type'], value['id']
|
|
1779
|
+
|
|
1780
|
+
|
|
1781
|
+
def _compare_entity_identity(
|
|
1782
|
+
actual: _EntityLink | None,
|
|
1783
|
+
relation: str,
|
|
1784
|
+
values: list[_EntityLink | None],
|
|
1785
|
+
) -> bool:
|
|
1786
|
+
"""Evaluate validated entity identity relations."""
|
|
1787
|
+
first = values[0] if values else None
|
|
1788
|
+
if relation == 'is':
|
|
1789
|
+
if actual is None or first is None:
|
|
1790
|
+
result = actual == first
|
|
1791
|
+
else:
|
|
1792
|
+
result = _entity_identity(actual) == _entity_identity(first)
|
|
1793
|
+
elif relation == 'is_not':
|
|
1794
|
+
if actual is None or first is None:
|
|
1795
|
+
result = actual != first
|
|
1796
|
+
else:
|
|
1797
|
+
result = _entity_identity(actual) != _entity_identity(first)
|
|
1798
|
+
else:
|
|
1799
|
+
actual_identity = _entity_identity(actual) if actual is not None else None
|
|
1800
|
+
contains = any(
|
|
1801
|
+
actual_identity == (_entity_identity(value) if value is not None else None)
|
|
1802
|
+
for value in values
|
|
1803
|
+
)
|
|
1804
|
+
result = not contains if relation == 'not_in' else contains
|
|
1805
|
+
return result
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def _compare_multi_entity(
|
|
1809
|
+
actual: list[_EntityLink] | None,
|
|
1810
|
+
relation: str,
|
|
1811
|
+
values: list[FieldValue],
|
|
1812
|
+
) -> bool:
|
|
1813
|
+
"""Match multi-entity members by identity or metadata, including complements."""
|
|
1814
|
+
if relation in {'is', 'is_not'} and values[0] is None:
|
|
1815
|
+
return not actual if relation == 'is' else bool(actual)
|
|
1816
|
+
positive = {
|
|
1817
|
+
'is_not': 'is',
|
|
1818
|
+
'not_in': 'in',
|
|
1819
|
+
'name_not_contains': 'name_contains',
|
|
1820
|
+
'type_is_not': 'type_is',
|
|
1821
|
+
}.get(relation, relation)
|
|
1822
|
+
if positive in {'is', 'in'}:
|
|
1823
|
+
matches = any(
|
|
1824
|
+
_compare_entity_identity(member, positive, cast('list[_EntityLink | None]', values))
|
|
1825
|
+
for member in actual or []
|
|
1826
|
+
)
|
|
1827
|
+
else:
|
|
1828
|
+
matches = any(
|
|
1829
|
+
_compare_entity_metadata(member, positive, cast('str', values[0]))
|
|
1830
|
+
for member in actual or []
|
|
1831
|
+
)
|
|
1832
|
+
return not matches if relation != positive else matches
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
def _compare_scalar(
|
|
1836
|
+
field_type: str,
|
|
1837
|
+
actual: FieldValue,
|
|
1838
|
+
relation: str,
|
|
1839
|
+
values: list[FieldValue],
|
|
1840
|
+
) -> bool:
|
|
1841
|
+
"""Dispatch one non-list field value to its type-specific comparator."""
|
|
1842
|
+
if field_type in {'checkbox', 'color', 'currency', 'image', 'timecode'}:
|
|
1843
|
+
result = _compare_equality(actual, relation, values)
|
|
1844
|
+
elif field_type in _NUMERIC_TYPES:
|
|
1845
|
+
result = _compare_numeric(
|
|
1846
|
+
cast('_ComparableFieldValue | None', actual),
|
|
1847
|
+
relation,
|
|
1848
|
+
cast('list[_ComparableFieldValue | None]', values),
|
|
1849
|
+
)
|
|
1850
|
+
elif field_type in {'list', 'status_list'}:
|
|
1851
|
+
result = _compare_selection(
|
|
1852
|
+
cast('str | None', actual),
|
|
1853
|
+
relation,
|
|
1854
|
+
cast('list[str | None]', values),
|
|
1855
|
+
)
|
|
1856
|
+
elif field_type == 'entity_type':
|
|
1857
|
+
result = actual == values[0]
|
|
1858
|
+
elif field_type == 'text':
|
|
1859
|
+
if relation in {'contains', 'ends_with', 'not_contains', 'starts_with'}:
|
|
1860
|
+
result = _compare_text_fragment(
|
|
1861
|
+
cast('str | None', actual),
|
|
1862
|
+
relation,
|
|
1863
|
+
cast('str | None', values[0]),
|
|
1864
|
+
)
|
|
1865
|
+
else:
|
|
1866
|
+
result = _compare_text_membership(
|
|
1867
|
+
cast('str | None', actual),
|
|
1868
|
+
relation,
|
|
1869
|
+
cast('list[str | list[str | None] | None]', values),
|
|
1870
|
+
)
|
|
1871
|
+
elif field_type == 'entity':
|
|
1872
|
+
if relation in {'in', 'is', 'is_not', 'not_in'}:
|
|
1873
|
+
result = _compare_entity_identity(
|
|
1874
|
+
cast('_EntityLink | None', actual),
|
|
1875
|
+
relation,
|
|
1876
|
+
cast('list[_EntityLink | None]', values),
|
|
1877
|
+
)
|
|
1878
|
+
else:
|
|
1879
|
+
result = _compare_entity_metadata(
|
|
1880
|
+
cast('_EntityLink', actual),
|
|
1881
|
+
relation,
|
|
1882
|
+
cast('str', values[0]),
|
|
1883
|
+
)
|
|
1884
|
+
elif field_type == 'multi_entity':
|
|
1885
|
+
result = _compare_multi_entity(
|
|
1886
|
+
cast('list[_EntityLink] | None', actual),
|
|
1887
|
+
relation,
|
|
1888
|
+
values,
|
|
1889
|
+
)
|
|
1890
|
+
else:
|
|
1891
|
+
raise ServerFaultError(f'relation {relation} is unsupported for field type {field_type}')
|
|
1892
|
+
return result
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def _compare(
|
|
1896
|
+
field_type: str,
|
|
1897
|
+
actual: FieldValue,
|
|
1898
|
+
relation: str,
|
|
1899
|
+
values: list[FieldValue],
|
|
1900
|
+
) -> bool:
|
|
1901
|
+
"""Evaluate one source-supported field relation."""
|
|
1902
|
+
if isinstance(actual, list) and field_type != 'multi_entity':
|
|
1903
|
+
items = cast('list[FieldValue]', actual)
|
|
1904
|
+
return any(_compare(field_type, item, relation, values) for item in items)
|
|
1905
|
+
return _compare_scalar(field_type, actual, relation, values)
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
def _entity_operand_fault(value: FieldValue) -> str | None:
|
|
1909
|
+
"""Describe the observed string-ID entity hash fault."""
|
|
1910
|
+
if isinstance(value, dict) and isinstance(value.get('id'), str):
|
|
1911
|
+
return (
|
|
1912
|
+
"API read() invalid/missing entity hash integer 'id':\n"
|
|
1913
|
+
f'{{"type" => "{value.get("type")}", "id" => "{value["id"]}"}}'
|
|
1914
|
+
)
|
|
1915
|
+
return None
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def _format_datetimes(value: FieldValue) -> FieldValue:
|
|
1919
|
+
"""Format datetime leaves while preserving nested link lists and null values."""
|
|
1920
|
+
if isinstance(value, datetime.datetime):
|
|
1921
|
+
return format_wire_datetime(value)
|
|
1922
|
+
if isinstance(value, list):
|
|
1923
|
+
return [_format_datetimes(member) for member in value]
|
|
1924
|
+
return value
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
def _operand_fault(field_type: str, relation: str, value: object) -> str | None:
|
|
1928
|
+
"""Describe only operand errors settled by the scalar characterizations."""
|
|
1929
|
+
wrong_type = (
|
|
1930
|
+
(field_type == 'number' and type(value) in {float, str})
|
|
1931
|
+
or (field_type == 'float' and type(value) is int)
|
|
1932
|
+
or (field_type == 'text' and type(value) in {int, float})
|
|
1933
|
+
or (
|
|
1934
|
+
field_type == 'date'
|
|
1935
|
+
and isinstance(value, str)
|
|
1936
|
+
and bool(re.search(r'\d{2}:\d{2}', value))
|
|
1937
|
+
)
|
|
1938
|
+
or (
|
|
1939
|
+
field_type == 'date_time'
|
|
1940
|
+
and isinstance(value, str)
|
|
1941
|
+
and bool(re.fullmatch(r'\d{4}-\d{2}-\d{2}', value))
|
|
1942
|
+
)
|
|
1943
|
+
)
|
|
1944
|
+
if wrong_type:
|
|
1945
|
+
return f'{field_type} operand has the wrong type: {value!r}'
|
|
1946
|
+
if field_type == 'checkbox':
|
|
1947
|
+
prefix = 'expected [FalseClass, TrueClass] data type(s) but got '
|
|
1948
|
+
if isinstance(value, str):
|
|
1949
|
+
return f'{prefix}String:\n"{value}"'
|
|
1950
|
+
if value is None and relation == 'is':
|
|
1951
|
+
return f'{prefix}NilClass:\nnil'
|
|
1952
|
+
return None
|
|
1953
|
+
|
|
1954
|
+
|
|
1955
|
+
def _ordered_note_links(
|
|
1956
|
+
row: Row, field: str, stored: FieldValue, visible: FieldValue, context: RequestContext | None
|
|
1957
|
+
) -> FieldValue:
|
|
1958
|
+
"""Return Asset before Shot for script reads of a script-authored Note's two links."""
|
|
1959
|
+
creator = row.get('created_by')
|
|
1960
|
+
if (
|
|
1961
|
+
row['type'] == 'Note'
|
|
1962
|
+
and context is not None
|
|
1963
|
+
and context.session_kind == 'script'
|
|
1964
|
+
and isinstance(creator, dict)
|
|
1965
|
+
and creator.get('type') == 'ApiUser'
|
|
1966
|
+
and field == 'note_links'
|
|
1967
|
+
and isinstance(stored, list)
|
|
1968
|
+
and len(stored) == len(_NOTE_LINK_PAIR)
|
|
1969
|
+
and {cast('str', cast('_EntityLink', member)['type']) for member in stored}
|
|
1970
|
+
== _NOTE_LINK_PAIR
|
|
1971
|
+
and isinstance(visible, list)
|
|
1972
|
+
):
|
|
1973
|
+
return sorted(visible, key=lambda member: cast('str', cast('_EntityLink', member)['type']))
|
|
1974
|
+
return visible
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
def _validate_operands(
|
|
1978
|
+
entity_type: str,
|
|
1979
|
+
field_name: str,
|
|
1980
|
+
field_type: str,
|
|
1981
|
+
relation: str,
|
|
1982
|
+
values: list[FieldValue],
|
|
1983
|
+
) -> None:
|
|
1984
|
+
"""Validate settled scalar and entity operands independently of candidate rows."""
|
|
1985
|
+
qualified = f'{entity_type}.{field_name}'
|
|
1986
|
+
faults = [_operand_fault(field_type, relation, value) for value in values]
|
|
1987
|
+
entity_faults = (
|
|
1988
|
+
[_entity_operand_fault(value) for value in values]
|
|
1989
|
+
if (
|
|
1990
|
+
field_type in {'entity', 'multi_entity'}
|
|
1991
|
+
and relation in {'is', 'is_not', 'in', 'not_in'}
|
|
1992
|
+
)
|
|
1993
|
+
else []
|
|
1994
|
+
)
|
|
1995
|
+
unsettled = (
|
|
1996
|
+
field_type in {'duration', 'percent'}
|
|
1997
|
+
and any(value is not None and type(value) is not int for value in values)
|
|
1998
|
+
) or (
|
|
1999
|
+
field_type in {'currency', 'image', 'timecode'}
|
|
2000
|
+
and any(value is not None for value in values)
|
|
2001
|
+
)
|
|
2002
|
+
has_null = (
|
|
2003
|
+
relation in {'in', 'not_in'}
|
|
2004
|
+
and relation in _VALID_RELATIONS.get(field_type, ())
|
|
2005
|
+
and None in values
|
|
2006
|
+
)
|
|
2007
|
+
if unsettled or (has_null and (any(faults) or any(entity_faults))):
|
|
2008
|
+
raise MockgunError(
|
|
2009
|
+
f'mockgun cannot evaluate {qualified} {field_type} {relation} {values!r}'
|
|
2010
|
+
)
|
|
2011
|
+
if has_null:
|
|
2012
|
+
raise ServerFaultError(
|
|
2013
|
+
f'API read() Read failed for entity type [{entity_type}]', error_code=104
|
|
2014
|
+
)
|
|
2015
|
+
for fault in entity_faults:
|
|
2016
|
+
if fault is not None:
|
|
2017
|
+
raise ServerFaultError(fault, error_code=103)
|
|
2018
|
+
for fault in faults:
|
|
2019
|
+
if fault is not None:
|
|
2020
|
+
raise ServerFaultError(f'API read() {qualified} {fault}', error_code=103)
|
|
2021
|
+
if not values and relation in {'in', 'not_in'}:
|
|
2022
|
+
if field_type == 'number':
|
|
2023
|
+
raise ServerFaultError(
|
|
2024
|
+
f'API read() {qualified} {relation} requires at least one value', error_code=103
|
|
2025
|
+
)
|
|
2026
|
+
if field_type in {'duration', 'entity', 'multi_entity', 'percent'} or (
|
|
2027
|
+
field_type in {'date', 'date_time', 'float'} and relation == 'not_in'
|
|
2028
|
+
):
|
|
2029
|
+
raise MockgunError(
|
|
2030
|
+
f'mockgun cannot evaluate {qualified} {field_type} {relation} {values!r}'
|
|
2031
|
+
)
|
|
2032
|
+
|
|
2033
|
+
|
|
2034
|
+
def collation_key(value: str) -> str:
|
|
2035
|
+
"""Return the ASCII letter-and-digit key used by modeled text order."""
|
|
2036
|
+
return ''.join(
|
|
2037
|
+
character
|
|
2038
|
+
for character in value.translate(_ASCII_LOWER)
|
|
2039
|
+
if character in _COLLATION_CHARACTERS
|
|
2040
|
+
)
|
|
2041
|
+
|
|
2042
|
+
|
|
2043
|
+
def check_collation(values: Collection[str], *, entity_type: str, field_name: str) -> None:
|
|
2044
|
+
"""Reject unsettled comparisons within one preceding-key tie group.
|
|
2045
|
+
|
|
2046
|
+
Args:
|
|
2047
|
+
values: Distinct non-null text values or names from one tie group only.
|
|
2048
|
+
entity_type: Entity owning the ordered field.
|
|
2049
|
+
field_name: Ordered field or path, used in the error message.
|
|
2050
|
+
|
|
2051
|
+
Raises:
|
|
2052
|
+
MockgunError: If distinct ASCII-lowered values have equal keys, or an
|
|
2053
|
+
unobserved character decides their comparison. Equal lowered values
|
|
2054
|
+
tie.
|
|
2055
|
+
"""
|
|
2056
|
+
lowered = {value.translate(_ASCII_LOWER) for value in values}
|
|
2057
|
+
if len({collation_key(value) for value in lowered}) != len(lowered):
|
|
2058
|
+
raise MockgunError(
|
|
2059
|
+
f'mockgun cannot order {entity_type}.{field_name}: equal collation keys'
|
|
2060
|
+
)
|
|
2061
|
+
stripped = [value.translate(_COLLATION_IGNORED) for value in lowered]
|
|
2062
|
+
if all(set(value) <= _COLLATION_CHARACTERS for value in stripped):
|
|
2063
|
+
return
|
|
2064
|
+
for left, right in combinations(stripped, 2):
|
|
2065
|
+
for first, second in zip(left, right, strict=False):
|
|
2066
|
+
if first != second:
|
|
2067
|
+
if first not in _COLLATION_CHARACTERS or second not in _COLLATION_CHARACTERS:
|
|
2068
|
+
raise MockgunError(
|
|
2069
|
+
f'mockgun cannot order {entity_type}.{field_name}: unsettled collation'
|
|
2070
|
+
)
|
|
2071
|
+
break
|
|
2072
|
+
|
|
2073
|
+
|
|
2074
|
+
def page(
|
|
2075
|
+
rows: list[Row], *, entities_per_page: int, current_page: int, max_page_size: int
|
|
2076
|
+
) -> tuple[list[Row], dict[str, int]]:
|
|
2077
|
+
"""Return the requested capped page and counts for all matching rows.
|
|
2078
|
+
|
|
2079
|
+
Args:
|
|
2080
|
+
rows: All matching rows in query order.
|
|
2081
|
+
entities_per_page: Requested positive page width.
|
|
2082
|
+
current_page: One-based page number.
|
|
2083
|
+
max_page_size: Site's positive maximum page width.
|
|
2084
|
+
|
|
2085
|
+
Returns:
|
|
2086
|
+
A new list retaining the selected live row references, followed by a
|
|
2087
|
+
paging dictionary containing `entity_count`, `current_page`,
|
|
2088
|
+
`entities_per_page`, and `page_count`. The effective width is the
|
|
2089
|
+
smaller of the requested width and site maximum.
|
|
2090
|
+
"""
|
|
2091
|
+
width = min(entities_per_page, max_page_size)
|
|
2092
|
+
offset = (current_page - 1) * width
|
|
2093
|
+
count = len(rows)
|
|
2094
|
+
return rows[offset : offset + width], {
|
|
2095
|
+
'entity_count': count,
|
|
2096
|
+
'current_page': current_page,
|
|
2097
|
+
'entities_per_page': width,
|
|
2098
|
+
'page_count': (count + width - 1) // width,
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
def project_rows(
|
|
2103
|
+
store: Store,
|
|
2104
|
+
schema: SiteSchema,
|
|
2105
|
+
rows: list[Row],
|
|
2106
|
+
return_fields: list[str],
|
|
2107
|
+
*,
|
|
2108
|
+
context: RequestContext | None = None,
|
|
2109
|
+
) -> list[dict[str, object]]:
|
|
2110
|
+
"""Return detached selected fields plus identity for each row in order.
|
|
2111
|
+
|
|
2112
|
+
Args:
|
|
2113
|
+
store: Site records used to resolve projected links.
|
|
2114
|
+
schema: Field declarations for the requested projection.
|
|
2115
|
+
rows: Selected stored rows, in return order.
|
|
2116
|
+
return_fields: Fields to select alongside type and identity.
|
|
2117
|
+
context: Read actor for evidence bounded by session kind. When absent,
|
|
2118
|
+
preserve projection behavior outside observed read forms.
|
|
2119
|
+
|
|
2120
|
+
Raises:
|
|
2121
|
+
ServerFaultError: If resolving a declared field or linked entity
|
|
2122
|
+
encounters a modeled schema fault.
|
|
2123
|
+
MockgunError: If projected values or linked identity state are unknown
|
|
2124
|
+
or unsupported.
|
|
2125
|
+
OverflowError: If converting a projected datetime to UTC exceeds the
|
|
2126
|
+
supported datetime range.
|
|
2127
|
+
ValueError: If the platform cannot convert a naive datetime.
|
|
2128
|
+
OSError: If local timezone conversion fails.
|
|
2129
|
+
"""
|
|
2130
|
+
return _Projection(store, schema, context).project(rows, return_fields)
|
|
2131
|
+
|
|
2132
|
+
|
|
2133
|
+
def select(
|
|
2134
|
+
store: Store,
|
|
2135
|
+
entity_type: str,
|
|
2136
|
+
tree: dict[str, object],
|
|
2137
|
+
order: list[dict[str, str]],
|
|
2138
|
+
*,
|
|
2139
|
+
scope: ReadScope,
|
|
2140
|
+
) -> list[Row]:
|
|
2141
|
+
"""Select matching rows with the current filter and stable ordering semantics.
|
|
2142
|
+
|
|
2143
|
+
Args:
|
|
2144
|
+
store: Site records and schema to query.
|
|
2145
|
+
entity_type: Entity type to query.
|
|
2146
|
+
tree: Wire filter tree with `logical_operator` and `conditions`;
|
|
2147
|
+
leaf conditions contain `path`, `relation`, and `values`.
|
|
2148
|
+
order: Ordered mappings containing `field_name` and an `asc` or `desc`
|
|
2149
|
+
direction.
|
|
2150
|
+
scope: Retirement, archive, preset, and request-context selection
|
|
2151
|
+
scope.
|
|
2152
|
+
|
|
2153
|
+
Returns:
|
|
2154
|
+
Live stored rows in the requested order, with ascending ID as the final
|
|
2155
|
+
tie breaker.
|
|
2156
|
+
|
|
2157
|
+
Raises:
|
|
2158
|
+
ServerFaultError: If selection has a modeled entity, filter, or preset
|
|
2159
|
+
fault.
|
|
2160
|
+
MockgunError: If selection, linked state, event state, or ordering is
|
|
2161
|
+
unsupported or unsettled.
|
|
2162
|
+
"""
|
|
2163
|
+
return _Query(store, store.schema, scope).select(entity_type, tree, order)
|