lorebase-core 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. lorebase_core-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,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