lorebase-core 0.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lorebase_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- lorebase_core-0.2.3.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
"""Validation of DSL queries against a Schema, producing a resolved plan keyed by ids.
|
|
2
|
+
|
|
3
|
+
`resolve_query` maps api names to Kind, Attribute and Relation ids, checks every operator
|
|
4
|
+
against the Attribute type, encodes operands into canonical values (the form stored in
|
|
5
|
+
`values`) and enforces the limits given by the caller. All problems of a query are collected
|
|
6
|
+
and raised together as `QueryError`, each with a JSON pointer into the query document.
|
|
7
|
+
|
|
8
|
+
Operators per Attribute type:
|
|
9
|
+
|
|
10
|
+
- string, text: eq, ne, lt, lte, gt, gte, in, between, prefix, match, exists
|
|
11
|
+
- uri, ref: eq, ne, lt, lte, gt, gte, in, between, prefix, exists
|
|
12
|
+
- enum: eq, ne, in, exists
|
|
13
|
+
- integer, decimal, float, date, timestamp, duration: eq, ne, lt, lte, gt, gte, in, between,
|
|
14
|
+
exists
|
|
15
|
+
- boolean: eq, ne, exists
|
|
16
|
+
- array: contains, exists
|
|
17
|
+
- geo_point: near, exists
|
|
18
|
+
- json: exists
|
|
19
|
+
|
|
20
|
+
`ne` matches Records that hold a value different from the operand (absent values never
|
|
21
|
+
match a comparison); `exists` takes `true` (default) or `false`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from enum import StrEnum
|
|
29
|
+
from types import MappingProxyType
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from lorebase_core.model import (
|
|
33
|
+
Attribute,
|
|
34
|
+
Kind,
|
|
35
|
+
LinkDirection,
|
|
36
|
+
PathRelation,
|
|
37
|
+
Relation,
|
|
38
|
+
Schema,
|
|
39
|
+
Trait,
|
|
40
|
+
)
|
|
41
|
+
from lorebase_core.model.relation import RelationBacking
|
|
42
|
+
from lorebase_core.query.dsl import (
|
|
43
|
+
AllOf,
|
|
44
|
+
AnyOf,
|
|
45
|
+
AttributeCondition,
|
|
46
|
+
CountCondition,
|
|
47
|
+
DerivedCondition,
|
|
48
|
+
MatchCondition,
|
|
49
|
+
NotCondition,
|
|
50
|
+
Operator,
|
|
51
|
+
Predicate,
|
|
52
|
+
Query,
|
|
53
|
+
ViaCondition,
|
|
54
|
+
)
|
|
55
|
+
from lorebase_core.types import AttributeType, EncodingError, TypeSpec, encode_value
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"KEY_FIELD",
|
|
59
|
+
"OPERATORS",
|
|
60
|
+
"TITLE_FIELD",
|
|
61
|
+
"AggregatePlan",
|
|
62
|
+
"Comparison",
|
|
63
|
+
"Condition",
|
|
64
|
+
"Conjunction",
|
|
65
|
+
"Disjunction",
|
|
66
|
+
"FieldRef",
|
|
67
|
+
"FieldSource",
|
|
68
|
+
"FullText",
|
|
69
|
+
"Hop",
|
|
70
|
+
"IncludePlan",
|
|
71
|
+
"LinkCount",
|
|
72
|
+
"LinkExists",
|
|
73
|
+
"Negation",
|
|
74
|
+
"Ordering",
|
|
75
|
+
"QueryError",
|
|
76
|
+
"QueryIssue",
|
|
77
|
+
"QueryLimits",
|
|
78
|
+
"QueryPlan",
|
|
79
|
+
"Target",
|
|
80
|
+
"resolve_field",
|
|
81
|
+
"resolve_hop",
|
|
82
|
+
"resolve_query",
|
|
83
|
+
"resolve_where",
|
|
84
|
+
"target_of_kind",
|
|
85
|
+
"target_of_trait",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
KEY_FIELD = "key"
|
|
89
|
+
"""Pseudo field: the Record key (a reserved Attribute name)."""
|
|
90
|
+
TITLE_FIELD = "title"
|
|
91
|
+
"""Pseudo field: the Record title, unless the Kind has an Attribute named `title`."""
|
|
92
|
+
|
|
93
|
+
_TEXT = TypeSpec.of(AttributeType.STRING)
|
|
94
|
+
_ORDERED = frozenset(
|
|
95
|
+
{Operator.EQ, Operator.NE, Operator.LT, Operator.LTE, Operator.GT, Operator.GTE}
|
|
96
|
+
| {Operator.IN, Operator.BETWEEN, Operator.EXISTS}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
OPERATORS: Mapping[AttributeType, frozenset[Operator]] = MappingProxyType(
|
|
100
|
+
{
|
|
101
|
+
AttributeType.STRING: _ORDERED | {Operator.PREFIX, Operator.MATCH},
|
|
102
|
+
AttributeType.TEXT: _ORDERED | {Operator.PREFIX, Operator.MATCH},
|
|
103
|
+
AttributeType.URI: _ORDERED | {Operator.PREFIX},
|
|
104
|
+
AttributeType.REF: _ORDERED | {Operator.PREFIX},
|
|
105
|
+
AttributeType.ENUM: frozenset({Operator.EQ, Operator.NE, Operator.IN, Operator.EXISTS}),
|
|
106
|
+
AttributeType.INTEGER: _ORDERED,
|
|
107
|
+
AttributeType.DECIMAL: _ORDERED,
|
|
108
|
+
AttributeType.FLOAT: _ORDERED,
|
|
109
|
+
AttributeType.DATE: _ORDERED,
|
|
110
|
+
AttributeType.TIMESTAMP: _ORDERED,
|
|
111
|
+
AttributeType.DURATION: _ORDERED,
|
|
112
|
+
AttributeType.BOOLEAN: frozenset({Operator.EQ, Operator.NE, Operator.EXISTS}),
|
|
113
|
+
AttributeType.ARRAY: frozenset({Operator.CONTAINS, Operator.EXISTS}),
|
|
114
|
+
AttributeType.GEO_POINT: frozenset({Operator.NEAR, Operator.EXISTS}),
|
|
115
|
+
AttributeType.JSON: frozenset({Operator.EXISTS}),
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
"""Operators allowed per Attribute type."""
|
|
119
|
+
|
|
120
|
+
ORDERABLE_TYPES = frozenset(
|
|
121
|
+
t for t, ops in OPERATORS.items() if Operator.LT in ops or t is AttributeType.BOOLEAN
|
|
122
|
+
) | {AttributeType.ENUM}
|
|
123
|
+
"""Types usable in `order_by`."""
|
|
124
|
+
|
|
125
|
+
COUNT_OPERATORS = frozenset(
|
|
126
|
+
{Operator.EQ, Operator.NE, Operator.LT, Operator.LTE, Operator.GT, Operator.GTE}
|
|
127
|
+
| {Operator.BETWEEN}
|
|
128
|
+
)
|
|
129
|
+
"""Operators of `count` predicates."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Issues -------------------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class IssueCode(StrEnum):
|
|
136
|
+
UNKNOWN_KIND = "unknown_kind"
|
|
137
|
+
UNKNOWN_TRAIT = "unknown_trait"
|
|
138
|
+
UNKNOWN_FIELD = "unknown_field"
|
|
139
|
+
UNKNOWN_RELATION = "unknown_relation"
|
|
140
|
+
RELATION_NOT_ON_KIND = "relation_not_on_kind"
|
|
141
|
+
OPERATOR_NOT_ALLOWED = "operator_not_allowed"
|
|
142
|
+
INVALID_VALUE = "invalid_value"
|
|
143
|
+
NOT_DERIVED = "not_derived"
|
|
144
|
+
NOT_ORDERABLE = "not_orderable"
|
|
145
|
+
LIMIT_EXCEEDED = "limit_exceeded"
|
|
146
|
+
DEPTH_EXCEEDED = "depth_exceeded"
|
|
147
|
+
TOO_COMPLEX = "too_complex"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class QueryIssue:
|
|
152
|
+
"""One problem: JSON pointer into the query document, stable code, message."""
|
|
153
|
+
|
|
154
|
+
pointer: str
|
|
155
|
+
code: str
|
|
156
|
+
message: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class QueryError(ValueError):
|
|
160
|
+
"""A query does not fit the Schema or the limits; `issues` lists every problem."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, issues: Sequence[QueryIssue]) -> None:
|
|
163
|
+
super().__init__("; ".join(f"{i.pointer}: {i.message}" for i in issues))
|
|
164
|
+
self.issues = tuple(issues)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True, slots=True)
|
|
168
|
+
class QueryLimits:
|
|
169
|
+
"""Bounds enforced while resolving a query (from the caller's configuration)."""
|
|
170
|
+
|
|
171
|
+
default_limit: int = 50
|
|
172
|
+
max_limit: int = 500
|
|
173
|
+
max_via_depth: int = 3
|
|
174
|
+
max_predicates: int = 64
|
|
175
|
+
max_in_values: int = 1000
|
|
176
|
+
include_default_limit: int = 10
|
|
177
|
+
include_max_limit: int = 50
|
|
178
|
+
max_includes: int = 5
|
|
179
|
+
max_order_terms: int = 4
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Resolved plan ------------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class FieldSource(StrEnum):
|
|
186
|
+
VALUE = "value"
|
|
187
|
+
DERIVED = "derived"
|
|
188
|
+
KEY = "key"
|
|
189
|
+
TITLE = "title"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True, slots=True)
|
|
193
|
+
class FieldRef:
|
|
194
|
+
"""A field of the target Records. `attributes` maps Kind id to Attribute id."""
|
|
195
|
+
|
|
196
|
+
name: str
|
|
197
|
+
source: FieldSource
|
|
198
|
+
spec: TypeSpec
|
|
199
|
+
attributes: Mapping[str, str] = field(default_factory=dict)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True, slots=True)
|
|
203
|
+
class Target:
|
|
204
|
+
"""The Kinds a query reads; a Trait target expands to its implementing Kinds."""
|
|
205
|
+
|
|
206
|
+
name: str
|
|
207
|
+
kinds: tuple[Kind, ...]
|
|
208
|
+
trait: Trait | None = None
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def kind_ids(self) -> tuple[str, ...]:
|
|
212
|
+
return tuple(kind.id for kind in self.kinds)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True, slots=True)
|
|
216
|
+
class Hop:
|
|
217
|
+
"""One step along a Relation from `near_kind` to `far`.
|
|
218
|
+
|
|
219
|
+
Along a path Derivation, `relation` is its virtual Relation and `path` the resolved path
|
|
220
|
+
(`lorebase_core.model.paths`): readers compose its steps, or read its stored Links when it
|
|
221
|
+
is materialized.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
name: str
|
|
225
|
+
relation: Relation
|
|
226
|
+
direction: LinkDirection
|
|
227
|
+
near_kind: str
|
|
228
|
+
far: Target
|
|
229
|
+
path: PathRelation | None = None
|
|
230
|
+
inner: tuple[Kind, ...] = ()
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def foreign_key(self) -> bool:
|
|
234
|
+
return self.relation.backing is RelationBacking.FOREIGN_KEY
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def kinds_read(self) -> frozenset[str]:
|
|
238
|
+
"""Api names of the Kinds the hop reads: the far Kinds and, along a path, the Kinds
|
|
239
|
+
of its intermediate Records (a path tells which Records connect through them)."""
|
|
240
|
+
return frozenset(kind.api_name for kind in (*self.far.kinds, *self.inner))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass(frozen=True, slots=True)
|
|
244
|
+
class Comparison:
|
|
245
|
+
field: FieldRef
|
|
246
|
+
op: Operator
|
|
247
|
+
operand: Any = None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True, slots=True)
|
|
251
|
+
class LinkExists:
|
|
252
|
+
hop: Hop
|
|
253
|
+
where: Condition | None = None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True, slots=True)
|
|
257
|
+
class LinkCount:
|
|
258
|
+
hop: Hop
|
|
259
|
+
op: Operator
|
|
260
|
+
operand: Any
|
|
261
|
+
where: Condition | None = None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True, slots=True)
|
|
265
|
+
class FullText:
|
|
266
|
+
text: str
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass(frozen=True, slots=True)
|
|
270
|
+
class Conjunction:
|
|
271
|
+
items: tuple[Condition, ...]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True, slots=True)
|
|
275
|
+
class Disjunction:
|
|
276
|
+
items: tuple[Condition, ...]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True, slots=True)
|
|
280
|
+
class Negation:
|
|
281
|
+
item: Condition
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
Condition = Comparison | LinkExists | LinkCount | FullText | Conjunction | Disjunction | Negation
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass(frozen=True, slots=True)
|
|
288
|
+
class Ordering:
|
|
289
|
+
field: FieldRef
|
|
290
|
+
descending: bool = False
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass(frozen=True, slots=True)
|
|
294
|
+
class IncludePlan:
|
|
295
|
+
hop: Hop
|
|
296
|
+
select: tuple[FieldRef, ...] | None
|
|
297
|
+
limit: int
|
|
298
|
+
where: Condition | None = None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True, slots=True)
|
|
302
|
+
class AggregatePlan:
|
|
303
|
+
group_by: FieldRef | None = None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass(frozen=True, slots=True)
|
|
307
|
+
class QueryPlan:
|
|
308
|
+
"""A validated query: everything resolved to ids, operands in canonical form."""
|
|
309
|
+
|
|
310
|
+
target: Target
|
|
311
|
+
where: Condition | None
|
|
312
|
+
select: tuple[FieldRef, ...] | None
|
|
313
|
+
include: tuple[IncludePlan, ...]
|
|
314
|
+
order: tuple[Ordering, ...]
|
|
315
|
+
limit: int
|
|
316
|
+
cursor: str | None
|
|
317
|
+
aggregate: AggregatePlan | None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def condition_kinds(condition: Condition | None) -> frozenset[str]:
|
|
321
|
+
"""Api names of the Kinds a condition reaches through Relation predicates."""
|
|
322
|
+
match condition:
|
|
323
|
+
case LinkExists(hop=hop, where=where) | LinkCount(hop=hop, where=where):
|
|
324
|
+
return hop.kinds_read | condition_kinds(where)
|
|
325
|
+
case Conjunction(items=items) | Disjunction(items=items):
|
|
326
|
+
return frozenset().union(*(condition_kinds(item) for item in items))
|
|
327
|
+
case Negation(item=item):
|
|
328
|
+
return condition_kinds(item)
|
|
329
|
+
return frozenset()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def plan_kinds(plan: QueryPlan) -> frozenset[str]:
|
|
333
|
+
"""Api names of every Kind a query reads: its target, Relation predicates and includes."""
|
|
334
|
+
kinds = {k.api_name for k in plan.target.kinds} | condition_kinds(plan.where)
|
|
335
|
+
for include in plan.include:
|
|
336
|
+
kinds |= include.hop.kinds_read | condition_kinds(include.where)
|
|
337
|
+
return frozenset(kinds)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# Resolution -------------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def target_of_kind(schema: Schema, api_name: str) -> Target | None:
|
|
344
|
+
kind = schema.kind_by_api_name(api_name)
|
|
345
|
+
return None if kind is None else Target(api_name, (kind,))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def target_of_trait(schema: Schema, api_name: str) -> Target | None:
|
|
349
|
+
trait = schema.trait_by_api_name(api_name)
|
|
350
|
+
if trait is None:
|
|
351
|
+
return None
|
|
352
|
+
kinds = tuple(sorted(schema.kinds_implementing(trait.id), key=lambda k: k.id))
|
|
353
|
+
return Target(api_name, kinds, trait)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def resolve_field(schema: Schema, target: Target, name: str) -> FieldRef | None:
|
|
357
|
+
"""Resolve an Attribute, derived Attribute, `key` or `title` of the target Records."""
|
|
358
|
+
if name == KEY_FIELD:
|
|
359
|
+
return FieldRef(name, FieldSource.KEY, _TEXT)
|
|
360
|
+
if target.trait is not None:
|
|
361
|
+
found = _trait_field(target, name)
|
|
362
|
+
else:
|
|
363
|
+
found = _kind_field(target.kinds[0], name)
|
|
364
|
+
if found is None and name == TITLE_FIELD:
|
|
365
|
+
return FieldRef(name, FieldSource.TITLE, _TEXT)
|
|
366
|
+
return found
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _kind_field(kind: Kind, name: str) -> FieldRef | None:
|
|
370
|
+
attribute = kind.attribute_by_api_name(name)
|
|
371
|
+
if attribute is None:
|
|
372
|
+
return None
|
|
373
|
+
source = FieldSource.DERIVED if attribute.is_derived else FieldSource.VALUE
|
|
374
|
+
return FieldRef(name, source, attribute.type, MappingProxyType({kind.id: attribute.id}))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _trait_field(target: Target, name: str) -> FieldRef | None:
|
|
378
|
+
assert target.trait is not None
|
|
379
|
+
trait_attribute = next(
|
|
380
|
+
(a for a in target.trait.attributes.values() if a.api_name == name), None
|
|
381
|
+
)
|
|
382
|
+
if trait_attribute is None:
|
|
383
|
+
return None
|
|
384
|
+
mapped: dict[str, str] = {}
|
|
385
|
+
derived = False
|
|
386
|
+
for kind in target.kinds:
|
|
387
|
+
for implementation in kind.traits:
|
|
388
|
+
if implementation.trait_id != target.trait.id:
|
|
389
|
+
continue
|
|
390
|
+
attribute_id = implementation.attribute_map.get(trait_attribute.id)
|
|
391
|
+
attribute: Attribute | None = (
|
|
392
|
+
kind.attributes.get(attribute_id) if attribute_id is not None else None
|
|
393
|
+
)
|
|
394
|
+
if attribute is not None:
|
|
395
|
+
mapped[kind.id] = attribute.id
|
|
396
|
+
derived = derived or attribute.is_derived
|
|
397
|
+
source = FieldSource.DERIVED if derived else FieldSource.VALUE
|
|
398
|
+
return FieldRef(name, source, trait_attribute.type, MappingProxyType(mapped))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def resolve_hop(schema: Schema, target: Target, name: str) -> Hop | str:
|
|
402
|
+
"""Resolve a forward or inverse Relation name (or path Derivation name) from the target;
|
|
403
|
+
a message when invalid."""
|
|
404
|
+
found = schema.relation_by_api_name(name)
|
|
405
|
+
path: PathRelation | None = None
|
|
406
|
+
if found is None:
|
|
407
|
+
along = schema.path_by_api_name(name)
|
|
408
|
+
if along is None:
|
|
409
|
+
return f"unknown Relation {name!r}"
|
|
410
|
+
path, direction = along
|
|
411
|
+
found = (path.relation, direction)
|
|
412
|
+
relation, direction = found
|
|
413
|
+
near = relation.endpoint(direction)
|
|
414
|
+
if near not in target.kind_ids:
|
|
415
|
+
return f"Relation {name!r} does not start at {target.name}"
|
|
416
|
+
far_kind = schema.kinds.get(relation.far_endpoint(direction))
|
|
417
|
+
if far_kind is None:
|
|
418
|
+
return f"Relation {name!r} points to an unknown Kind"
|
|
419
|
+
inner: tuple[Kind, ...] = ()
|
|
420
|
+
if path is not None:
|
|
421
|
+
found_inner = [schema.kinds.get(kind_id) for kind_id in path.inner_kinds]
|
|
422
|
+
if any(kind is None for kind in found_inner):
|
|
423
|
+
return f"Relation {name!r} crosses an unknown Kind"
|
|
424
|
+
inner = tuple(kind for kind in found_inner if kind is not None)
|
|
425
|
+
return Hop(name, relation, direction, near, Target(far_kind.api_name, (far_kind,)), path, inner)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@dataclass
|
|
429
|
+
class _Resolver:
|
|
430
|
+
schema: Schema
|
|
431
|
+
limits: QueryLimits
|
|
432
|
+
issues: list[QueryIssue] = field(default_factory=list)
|
|
433
|
+
predicates: int = 0
|
|
434
|
+
|
|
435
|
+
def fail(self, pointer: str, code: IssueCode, message: str) -> None:
|
|
436
|
+
self.issues.append(QueryIssue(pointer or "/", code.value, message))
|
|
437
|
+
|
|
438
|
+
# Predicates -------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
def condition(
|
|
441
|
+
self, predicate: Predicate, target: Target, pointer: str, depth: int
|
|
442
|
+
) -> Condition | None:
|
|
443
|
+
self.predicates += 1
|
|
444
|
+
if self.predicates == self.limits.max_predicates + 1:
|
|
445
|
+
self.fail(
|
|
446
|
+
pointer,
|
|
447
|
+
IssueCode.TOO_COMPLEX,
|
|
448
|
+
f"a query holds at most {self.limits.max_predicates} predicates",
|
|
449
|
+
)
|
|
450
|
+
match predicate:
|
|
451
|
+
case AllOf(items=items):
|
|
452
|
+
parts = [
|
|
453
|
+
self.condition(p, target, f"{pointer}/and/{i}", depth)
|
|
454
|
+
for i, p in enumerate(items)
|
|
455
|
+
]
|
|
456
|
+
return _all(parts, Conjunction)
|
|
457
|
+
case AnyOf(items=items):
|
|
458
|
+
parts = [
|
|
459
|
+
self.condition(p, target, f"{pointer}/or/{i}", depth)
|
|
460
|
+
for i, p in enumerate(items)
|
|
461
|
+
]
|
|
462
|
+
return _all(parts, Disjunction)
|
|
463
|
+
case NotCondition(item=item):
|
|
464
|
+
inner = self.condition(item, target, f"{pointer}/not", depth)
|
|
465
|
+
return None if inner is None else Negation(inner)
|
|
466
|
+
case MatchCondition(match=text):
|
|
467
|
+
return FullText(text)
|
|
468
|
+
case ViaCondition(via=name, where=where):
|
|
469
|
+
hop = self.hop(target, name, f"{pointer}/via", depth)
|
|
470
|
+
if hop is None:
|
|
471
|
+
return None
|
|
472
|
+
inner = (
|
|
473
|
+
None
|
|
474
|
+
if where is None
|
|
475
|
+
else self.condition(where, hop.far, f"{pointer}/where", depth + 1)
|
|
476
|
+
)
|
|
477
|
+
if where is not None and inner is None:
|
|
478
|
+
return None
|
|
479
|
+
return LinkExists(hop, inner)
|
|
480
|
+
case CountCondition(count=name, op=op, value=value, where=where):
|
|
481
|
+
hop = self.hop(target, name, f"{pointer}/count", depth)
|
|
482
|
+
operand = self.count_operand(op, value, pointer)
|
|
483
|
+
inner = (
|
|
484
|
+
None
|
|
485
|
+
if where is None or hop is None
|
|
486
|
+
else self.condition(where, hop.far, f"{pointer}/where", depth + 1)
|
|
487
|
+
)
|
|
488
|
+
if hop is None or operand is None or (where is not None and inner is None):
|
|
489
|
+
return None
|
|
490
|
+
return LinkCount(hop, op, operand, inner)
|
|
491
|
+
case DerivedCondition(derived=name, op=op, value=value):
|
|
492
|
+
ref = resolve_field(self.schema, target, name)
|
|
493
|
+
if ref is None or ref.source is not FieldSource.DERIVED:
|
|
494
|
+
code = IssueCode.UNKNOWN_FIELD if ref is None else IssueCode.NOT_DERIVED
|
|
495
|
+
self.fail(f"{pointer}/derived", code, f"no derived Attribute {name!r}")
|
|
496
|
+
return None
|
|
497
|
+
return self.comparison(ref, op, value, pointer)
|
|
498
|
+
case AttributeCondition(attr=name, op=op, value=value):
|
|
499
|
+
ref = resolve_field(self.schema, target, name)
|
|
500
|
+
if ref is None:
|
|
501
|
+
self.fail(
|
|
502
|
+
f"{pointer}/attr",
|
|
503
|
+
IssueCode.UNKNOWN_FIELD,
|
|
504
|
+
f"{target.name} has no Attribute {name!r}",
|
|
505
|
+
)
|
|
506
|
+
return None
|
|
507
|
+
return self.comparison(ref, op, value, pointer)
|
|
508
|
+
raise AssertionError(f"unexpected predicate {predicate!r}") # pragma: no cover
|
|
509
|
+
|
|
510
|
+
def hop(self, target: Target, name: str, pointer: str, depth: int) -> Hop | None:
|
|
511
|
+
if depth >= self.limits.max_via_depth:
|
|
512
|
+
self.fail(
|
|
513
|
+
pointer,
|
|
514
|
+
IssueCode.DEPTH_EXCEEDED,
|
|
515
|
+
f"Relations nest at most {self.limits.max_via_depth} levels deep",
|
|
516
|
+
)
|
|
517
|
+
return None
|
|
518
|
+
hop = resolve_hop(self.schema, target, name)
|
|
519
|
+
if isinstance(hop, str):
|
|
520
|
+
code = (
|
|
521
|
+
IssueCode.UNKNOWN_RELATION
|
|
522
|
+
if self.schema.relation_by_api_name(name) is None
|
|
523
|
+
else IssueCode.RELATION_NOT_ON_KIND
|
|
524
|
+
)
|
|
525
|
+
self.fail(pointer, code, hop)
|
|
526
|
+
return None
|
|
527
|
+
return hop
|
|
528
|
+
|
|
529
|
+
def comparison(
|
|
530
|
+
self, ref: FieldRef, op: Operator, value: Any, pointer: str
|
|
531
|
+
) -> Comparison | None:
|
|
532
|
+
allowed = OPERATORS[ref.spec.name]
|
|
533
|
+
if op not in allowed:
|
|
534
|
+
self.fail(
|
|
535
|
+
f"{pointer}/op",
|
|
536
|
+
IssueCode.OPERATOR_NOT_ALLOWED,
|
|
537
|
+
f"{op.value} does not apply to {ref.name} ({ref.spec.describe()}); "
|
|
538
|
+
f"use one of {', '.join(sorted(o.value for o in allowed))}",
|
|
539
|
+
)
|
|
540
|
+
return None
|
|
541
|
+
operand = self.operand(ref, op, value, f"{pointer}/value")
|
|
542
|
+
return None if operand is _INVALID else Comparison(ref, op, operand)
|
|
543
|
+
|
|
544
|
+
def operand(self, ref: FieldRef, op: Operator, value: Any, pointer: str) -> Any:
|
|
545
|
+
spec = ref.spec
|
|
546
|
+
if op is Operator.EXISTS:
|
|
547
|
+
if value is None:
|
|
548
|
+
return True
|
|
549
|
+
if not isinstance(value, bool):
|
|
550
|
+
return self.invalid(pointer, "exists takes true or false")
|
|
551
|
+
return value
|
|
552
|
+
if op is Operator.IN:
|
|
553
|
+
if not isinstance(value, list) or not value:
|
|
554
|
+
return self.invalid(pointer, "in takes a non-empty list")
|
|
555
|
+
if len(value) > self.limits.max_in_values:
|
|
556
|
+
self.fail(
|
|
557
|
+
pointer,
|
|
558
|
+
IssueCode.LIMIT_EXCEEDED,
|
|
559
|
+
f"in takes at most {self.limits.max_in_values} values",
|
|
560
|
+
)
|
|
561
|
+
return _INVALID
|
|
562
|
+
encoded = [self.encode(spec, item, f"{pointer}/{i}") for i, item in enumerate(value)]
|
|
563
|
+
return _INVALID if _INVALID in encoded else tuple(dict.fromkeys(encoded))
|
|
564
|
+
if op is Operator.BETWEEN:
|
|
565
|
+
if not isinstance(value, list) or len(value) != 2:
|
|
566
|
+
return self.invalid(pointer, "between takes a list [low, high]")
|
|
567
|
+
low, high = (self.encode(spec, item, f"{pointer}/{i}") for i, item in enumerate(value))
|
|
568
|
+
return _INVALID if _INVALID in (low, high) else (low, high)
|
|
569
|
+
if op is Operator.CONTAINS:
|
|
570
|
+
element = spec.scalar
|
|
571
|
+
items = value if isinstance(value, list) else [value]
|
|
572
|
+
if not items:
|
|
573
|
+
return self.invalid(pointer, "contains takes a value or a non-empty list")
|
|
574
|
+
encoded = [self.encode(element, item, f"{pointer}/{i}") for i, item in enumerate(items)]
|
|
575
|
+
return _INVALID if _INVALID in encoded else tuple(encoded)
|
|
576
|
+
if op is Operator.PREFIX or op is Operator.MATCH:
|
|
577
|
+
if not isinstance(value, str) or not value:
|
|
578
|
+
return self.invalid(pointer, f"{op.value} takes a non-empty string")
|
|
579
|
+
return value
|
|
580
|
+
if op is Operator.NEAR:
|
|
581
|
+
return self.near(value, pointer)
|
|
582
|
+
return self.encode(spec, value, pointer)
|
|
583
|
+
|
|
584
|
+
def near(self, value: Any, pointer: str) -> Any:
|
|
585
|
+
if not isinstance(value, dict) or set(value) != {"lat", "lon", "radius_m"}:
|
|
586
|
+
return self.invalid(pointer, "near takes {lat, lon, radius_m}")
|
|
587
|
+
point = self.encode(
|
|
588
|
+
TypeSpec.of(AttributeType.GEO_POINT),
|
|
589
|
+
{"lat": value["lat"], "lon": value["lon"]},
|
|
590
|
+
pointer,
|
|
591
|
+
)
|
|
592
|
+
radius = value["radius_m"]
|
|
593
|
+
if (
|
|
594
|
+
isinstance(radius, bool)
|
|
595
|
+
or not isinstance(radius, int | float)
|
|
596
|
+
or not radius > 0
|
|
597
|
+
or point is _INVALID
|
|
598
|
+
):
|
|
599
|
+
return self.invalid(pointer, "near takes a point and a positive radius_m")
|
|
600
|
+
return (point["lat"], point["lon"], float(radius))
|
|
601
|
+
|
|
602
|
+
def count_operand(self, op: Operator, value: Any, pointer: str) -> Any:
|
|
603
|
+
if op not in COUNT_OPERATORS:
|
|
604
|
+
self.fail(
|
|
605
|
+
f"{pointer}/op",
|
|
606
|
+
IssueCode.OPERATOR_NOT_ALLOWED,
|
|
607
|
+
f"count takes one of {', '.join(sorted(o.value for o in COUNT_OPERATORS))}",
|
|
608
|
+
)
|
|
609
|
+
return None
|
|
610
|
+
if op is Operator.BETWEEN:
|
|
611
|
+
if isinstance(value, list) and len(value) == 2 and all(_count(v) for v in value):
|
|
612
|
+
return (value[0], value[1])
|
|
613
|
+
elif _count(value):
|
|
614
|
+
return value
|
|
615
|
+
self.invalid(f"{pointer}/value", "count compares with non-negative integers")
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
def encode(self, spec: TypeSpec, value: Any, pointer: str) -> Any:
|
|
619
|
+
if value is None:
|
|
620
|
+
return self.invalid(pointer, "a value is required")
|
|
621
|
+
try:
|
|
622
|
+
return encode_value(spec, value)
|
|
623
|
+
except EncodingError as error:
|
|
624
|
+
return self.invalid(pointer, f"not a valid {spec.describe()}: {error}")
|
|
625
|
+
|
|
626
|
+
def invalid(self, pointer: str, message: str) -> Any:
|
|
627
|
+
self.fail(pointer, IssueCode.INVALID_VALUE, message)
|
|
628
|
+
return _INVALID
|
|
629
|
+
|
|
630
|
+
# Query parts ------------------------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
def target(self, query: Query) -> Target | None:
|
|
633
|
+
if query.kind is not None:
|
|
634
|
+
found = target_of_kind(self.schema, query.kind)
|
|
635
|
+
if found is None:
|
|
636
|
+
self.fail("/kind", IssueCode.UNKNOWN_KIND, f"unknown Kind {query.kind!r}")
|
|
637
|
+
return found
|
|
638
|
+
assert query.trait is not None
|
|
639
|
+
found = target_of_trait(self.schema, query.trait)
|
|
640
|
+
if found is None:
|
|
641
|
+
self.fail("/trait", IssueCode.UNKNOWN_TRAIT, f"unknown Trait {query.trait!r}")
|
|
642
|
+
return found
|
|
643
|
+
|
|
644
|
+
def select(
|
|
645
|
+
self, target: Target, names: Sequence[str] | None, pointer: str
|
|
646
|
+
) -> tuple[FieldRef, ...] | None:
|
|
647
|
+
if names is None:
|
|
648
|
+
return None
|
|
649
|
+
refs: list[FieldRef] = []
|
|
650
|
+
for i, name in enumerate(dict.fromkeys(names)):
|
|
651
|
+
ref = resolve_field(self.schema, target, name)
|
|
652
|
+
if ref is None:
|
|
653
|
+
self.fail(
|
|
654
|
+
f"{pointer}/{i}", IssueCode.UNKNOWN_FIELD, f"{target.name} has no {name!r}"
|
|
655
|
+
)
|
|
656
|
+
else:
|
|
657
|
+
refs.append(ref)
|
|
658
|
+
return tuple(refs)
|
|
659
|
+
|
|
660
|
+
def order(self, target: Target, query: Query) -> tuple[Ordering, ...]:
|
|
661
|
+
if len(query.order_by) > self.limits.max_order_terms:
|
|
662
|
+
self.fail(
|
|
663
|
+
"/order_by",
|
|
664
|
+
IssueCode.LIMIT_EXCEEDED,
|
|
665
|
+
f"order_by takes at most {self.limits.max_order_terms} terms",
|
|
666
|
+
)
|
|
667
|
+
terms: list[Ordering] = []
|
|
668
|
+
for i, term in enumerate(query.order_by):
|
|
669
|
+
ref = resolve_field(self.schema, target, term.attr)
|
|
670
|
+
if ref is None:
|
|
671
|
+
self.fail(
|
|
672
|
+
f"/order_by/{i}/attr",
|
|
673
|
+
IssueCode.UNKNOWN_FIELD,
|
|
674
|
+
f"{target.name} has no {term.attr!r}",
|
|
675
|
+
)
|
|
676
|
+
elif ref.spec.name not in ORDERABLE_TYPES:
|
|
677
|
+
self.fail(
|
|
678
|
+
f"/order_by/{i}/attr",
|
|
679
|
+
IssueCode.NOT_ORDERABLE,
|
|
680
|
+
f"{ref.spec.describe()} values cannot be ordered",
|
|
681
|
+
)
|
|
682
|
+
else:
|
|
683
|
+
terms.append(Ordering(ref, term.dir == "desc"))
|
|
684
|
+
return tuple(terms)
|
|
685
|
+
|
|
686
|
+
def includes(self, target: Target, query: Query) -> tuple[IncludePlan, ...]:
|
|
687
|
+
if len(query.include) > self.limits.max_includes:
|
|
688
|
+
self.fail(
|
|
689
|
+
"/include",
|
|
690
|
+
IssueCode.LIMIT_EXCEEDED,
|
|
691
|
+
f"include takes at most {self.limits.max_includes} Relations",
|
|
692
|
+
)
|
|
693
|
+
plans: list[IncludePlan] = []
|
|
694
|
+
for name, spec in query.include.items():
|
|
695
|
+
pointer = f"/include/{_escape(name)}"
|
|
696
|
+
hop = self.hop(target, name, pointer, 0)
|
|
697
|
+
if hop is None:
|
|
698
|
+
continue
|
|
699
|
+
limit = spec.limit or self.limits.include_default_limit
|
|
700
|
+
if limit > self.limits.include_max_limit:
|
|
701
|
+
self.fail(
|
|
702
|
+
f"{pointer}/limit",
|
|
703
|
+
IssueCode.LIMIT_EXCEEDED,
|
|
704
|
+
f"must not exceed {self.limits.include_max_limit}",
|
|
705
|
+
)
|
|
706
|
+
where = (
|
|
707
|
+
None
|
|
708
|
+
if spec.where is None
|
|
709
|
+
else self.condition(spec.where, hop.far, f"{pointer}/where", 1)
|
|
710
|
+
)
|
|
711
|
+
select = self.select(hop.far, spec.select, f"{pointer}/select")
|
|
712
|
+
plans.append(IncludePlan(hop, select, limit, where))
|
|
713
|
+
return tuple(plans)
|
|
714
|
+
|
|
715
|
+
def limit(self, query: Query) -> int:
|
|
716
|
+
limit = self.limits.default_limit if query.limit is None else query.limit
|
|
717
|
+
if limit > self.limits.max_limit:
|
|
718
|
+
self.fail(
|
|
719
|
+
"/limit", IssueCode.LIMIT_EXCEEDED, f"must not exceed {self.limits.max_limit}"
|
|
720
|
+
)
|
|
721
|
+
return limit
|
|
722
|
+
|
|
723
|
+
def aggregate(self, target: Target, query: Query) -> AggregatePlan | None:
|
|
724
|
+
if query.aggregate is None:
|
|
725
|
+
return None
|
|
726
|
+
if query.aggregate.group_by is None:
|
|
727
|
+
return AggregatePlan()
|
|
728
|
+
ref = resolve_field(self.schema, target, query.aggregate.group_by)
|
|
729
|
+
if ref is None:
|
|
730
|
+
self.fail(
|
|
731
|
+
"/aggregate/group_by",
|
|
732
|
+
IssueCode.UNKNOWN_FIELD,
|
|
733
|
+
f"{target.name} has no {query.aggregate.group_by!r}",
|
|
734
|
+
)
|
|
735
|
+
return None
|
|
736
|
+
if ref.spec.name not in ORDERABLE_TYPES:
|
|
737
|
+
self.fail(
|
|
738
|
+
"/aggregate/group_by",
|
|
739
|
+
IssueCode.NOT_ORDERABLE,
|
|
740
|
+
f"{ref.spec.describe()} values cannot be grouped",
|
|
741
|
+
)
|
|
742
|
+
return None
|
|
743
|
+
return AggregatePlan(ref)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
_INVALID: Any = object()
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _count(value: Any) -> bool:
|
|
750
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _all[T: (Conjunction, Disjunction)](parts: list[Condition | None], kind: type[T]) -> T | None:
|
|
754
|
+
if any(part is None for part in parts):
|
|
755
|
+
return None
|
|
756
|
+
return kind(tuple(part for part in parts if part is not None))
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _escape(token: str) -> str:
|
|
760
|
+
"""JSON pointer escaping (RFC 6901)."""
|
|
761
|
+
return token.replace("~", "~0").replace("/", "~1")
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def resolve_query(query: Query, schema: Schema, limits: QueryLimits) -> QueryPlan:
|
|
765
|
+
"""Validate `query` against `schema`; raise `QueryError` listing every problem."""
|
|
766
|
+
resolver = _Resolver(schema, limits)
|
|
767
|
+
target = resolver.target(query)
|
|
768
|
+
if target is None:
|
|
769
|
+
raise QueryError(resolver.issues)
|
|
770
|
+
where = None if query.where is None else resolver.condition(query.where, target, "/where", 0)
|
|
771
|
+
plan = QueryPlan(
|
|
772
|
+
target=target,
|
|
773
|
+
where=where,
|
|
774
|
+
select=resolver.select(target, query.select, "/select"),
|
|
775
|
+
include=resolver.includes(target, query),
|
|
776
|
+
order=resolver.order(target, query),
|
|
777
|
+
limit=resolver.limit(query),
|
|
778
|
+
cursor=query.cursor,
|
|
779
|
+
aggregate=resolver.aggregate(target, query),
|
|
780
|
+
)
|
|
781
|
+
if resolver.issues:
|
|
782
|
+
raise QueryError(resolver.issues)
|
|
783
|
+
return plan
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def resolve_where(
|
|
787
|
+
predicate: Predicate, target: Target, schema: Schema, limits: QueryLimits, pointer: str = ""
|
|
788
|
+
) -> Condition:
|
|
789
|
+
"""Validate a standalone predicate over `target` (search filters, traversal hops)."""
|
|
790
|
+
resolver = _Resolver(schema, limits)
|
|
791
|
+
condition = resolver.condition(predicate, target, pointer, 0)
|
|
792
|
+
if resolver.issues or condition is None:
|
|
793
|
+
raise QueryError(resolver.issues)
|
|
794
|
+
return condition
|