affinity-sdk 0.9.5__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.
- affinity/__init__.py +139 -0
- affinity/cli/__init__.py +7 -0
- affinity/cli/click_compat.py +27 -0
- affinity/cli/commands/__init__.py +1 -0
- affinity/cli/commands/_entity_files_dump.py +219 -0
- affinity/cli/commands/_list_entry_fields.py +41 -0
- affinity/cli/commands/_v1_parsing.py +77 -0
- affinity/cli/commands/company_cmds.py +2139 -0
- affinity/cli/commands/completion_cmd.py +33 -0
- affinity/cli/commands/config_cmds.py +540 -0
- affinity/cli/commands/entry_cmds.py +33 -0
- affinity/cli/commands/field_cmds.py +413 -0
- affinity/cli/commands/interaction_cmds.py +875 -0
- affinity/cli/commands/list_cmds.py +3152 -0
- affinity/cli/commands/note_cmds.py +433 -0
- affinity/cli/commands/opportunity_cmds.py +1174 -0
- affinity/cli/commands/person_cmds.py +1980 -0
- affinity/cli/commands/query_cmd.py +444 -0
- affinity/cli/commands/relationship_strength_cmds.py +62 -0
- affinity/cli/commands/reminder_cmds.py +595 -0
- affinity/cli/commands/resolve_url_cmd.py +127 -0
- affinity/cli/commands/session_cmds.py +84 -0
- affinity/cli/commands/task_cmds.py +110 -0
- affinity/cli/commands/version_cmd.py +29 -0
- affinity/cli/commands/whoami_cmd.py +36 -0
- affinity/cli/config.py +108 -0
- affinity/cli/context.py +749 -0
- affinity/cli/csv_utils.py +195 -0
- affinity/cli/date_utils.py +42 -0
- affinity/cli/decorators.py +77 -0
- affinity/cli/errors.py +28 -0
- affinity/cli/field_utils.py +355 -0
- affinity/cli/formatters.py +551 -0
- affinity/cli/help_json.py +283 -0
- affinity/cli/logging.py +100 -0
- affinity/cli/main.py +261 -0
- affinity/cli/options.py +53 -0
- affinity/cli/paths.py +32 -0
- affinity/cli/progress.py +183 -0
- affinity/cli/query/__init__.py +163 -0
- affinity/cli/query/aggregates.py +357 -0
- affinity/cli/query/dates.py +194 -0
- affinity/cli/query/exceptions.py +147 -0
- affinity/cli/query/executor.py +1236 -0
- affinity/cli/query/filters.py +248 -0
- affinity/cli/query/models.py +333 -0
- affinity/cli/query/output.py +331 -0
- affinity/cli/query/parser.py +619 -0
- affinity/cli/query/planner.py +430 -0
- affinity/cli/query/progress.py +270 -0
- affinity/cli/query/schema.py +439 -0
- affinity/cli/render.py +1589 -0
- affinity/cli/resolve.py +222 -0
- affinity/cli/resolvers.py +249 -0
- affinity/cli/results.py +308 -0
- affinity/cli/runner.py +218 -0
- affinity/cli/serialization.py +65 -0
- affinity/cli/session_cache.py +276 -0
- affinity/cli/types.py +70 -0
- affinity/client.py +771 -0
- affinity/clients/__init__.py +19 -0
- affinity/clients/http.py +3664 -0
- affinity/clients/pipeline.py +165 -0
- affinity/compare.py +501 -0
- affinity/downloads.py +114 -0
- affinity/exceptions.py +615 -0
- affinity/filters.py +1128 -0
- affinity/hooks.py +198 -0
- affinity/inbound_webhooks.py +302 -0
- affinity/models/__init__.py +163 -0
- affinity/models/entities.py +798 -0
- affinity/models/pagination.py +513 -0
- affinity/models/rate_limit_snapshot.py +48 -0
- affinity/models/secondary.py +413 -0
- affinity/models/types.py +663 -0
- affinity/policies.py +40 -0
- affinity/progress.py +22 -0
- affinity/py.typed +0 -0
- affinity/services/__init__.py +42 -0
- affinity/services/companies.py +1286 -0
- affinity/services/lists.py +1892 -0
- affinity/services/opportunities.py +1330 -0
- affinity/services/persons.py +1348 -0
- affinity/services/rate_limits.py +173 -0
- affinity/services/tasks.py +193 -0
- affinity/services/v1_only.py +2445 -0
- affinity/types.py +83 -0
- affinity_sdk-0.9.5.dist-info/METADATA +622 -0
- affinity_sdk-0.9.5.dist-info/RECORD +92 -0
- affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
- affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
- affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""Query parser and validator.
|
|
2
|
+
|
|
3
|
+
Parses JSON queries into validated Query objects.
|
|
4
|
+
This module is CLI-only and NOT part of the public SDK API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from .exceptions import QueryParseError, QueryValidationError
|
|
16
|
+
from .models import Query, WhereClause
|
|
17
|
+
from .schema import SCHEMA_REGISTRY, FetchStrategy
|
|
18
|
+
|
|
19
|
+
# =============================================================================
|
|
20
|
+
# Version Configuration
|
|
21
|
+
# =============================================================================
|
|
22
|
+
|
|
23
|
+
CURRENT_VERSION = "1.0"
|
|
24
|
+
SUPPORTED_VERSIONS = frozenset(["1.0"])
|
|
25
|
+
DEPRECATED_VERSIONS: frozenset[str] = frozenset()
|
|
26
|
+
|
|
27
|
+
# Supported operators per version
|
|
28
|
+
SUPPORTED_OPERATORS_V1 = frozenset(
|
|
29
|
+
[
|
|
30
|
+
"eq",
|
|
31
|
+
"neq",
|
|
32
|
+
"gt",
|
|
33
|
+
"gte",
|
|
34
|
+
"lt",
|
|
35
|
+
"lte",
|
|
36
|
+
"contains",
|
|
37
|
+
"starts_with",
|
|
38
|
+
"in",
|
|
39
|
+
"between",
|
|
40
|
+
"is_null",
|
|
41
|
+
"is_not_null",
|
|
42
|
+
"contains_any",
|
|
43
|
+
"contains_all",
|
|
44
|
+
"has_any",
|
|
45
|
+
"has_all",
|
|
46
|
+
]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Supported entities
|
|
50
|
+
SUPPORTED_ENTITIES = frozenset(
|
|
51
|
+
[
|
|
52
|
+
"persons",
|
|
53
|
+
"companies",
|
|
54
|
+
"opportunities",
|
|
55
|
+
"lists",
|
|
56
|
+
"listEntries",
|
|
57
|
+
"interactions",
|
|
58
|
+
"notes",
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# =============================================================================
|
|
64
|
+
# Parse Result
|
|
65
|
+
# =============================================================================
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ParseResult:
|
|
69
|
+
"""Result of parsing a query."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, query: Query, warnings: list[str]) -> None:
|
|
72
|
+
self.query = query
|
|
73
|
+
self.warnings = warnings
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def version(self) -> str:
|
|
77
|
+
return self.query.version or CURRENT_VERSION
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# =============================================================================
|
|
81
|
+
# Validation Functions
|
|
82
|
+
# =============================================================================
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_version(version: str | None) -> tuple[str, list[str]]:
|
|
86
|
+
"""Validate and normalize query version.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Tuple of (resolved_version, warnings)
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
QueryParseError: If version is not supported
|
|
93
|
+
"""
|
|
94
|
+
warnings: list[str] = []
|
|
95
|
+
|
|
96
|
+
if version is None:
|
|
97
|
+
warnings.append(
|
|
98
|
+
"Query missing '$version' field. Assuming version 1.0. "
|
|
99
|
+
'Add \'"$version": "1.0"\' for forward compatibility.'
|
|
100
|
+
)
|
|
101
|
+
return CURRENT_VERSION, warnings
|
|
102
|
+
|
|
103
|
+
if version not in SUPPORTED_VERSIONS and version not in DEPRECATED_VERSIONS:
|
|
104
|
+
raise QueryParseError(
|
|
105
|
+
f"Unsupported query version '{version}'. "
|
|
106
|
+
f"Supported versions: {', '.join(sorted(SUPPORTED_VERSIONS))}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if version in DEPRECATED_VERSIONS:
|
|
110
|
+
warnings.append(
|
|
111
|
+
f"Query version '{version}' is deprecated. "
|
|
112
|
+
"Run 'xaffinity query migrate --file query.json' to upgrade."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return version, warnings
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_entity(entity: str) -> None:
|
|
119
|
+
"""Validate that entity type is supported."""
|
|
120
|
+
if entity not in SUPPORTED_ENTITIES:
|
|
121
|
+
raise QueryValidationError(
|
|
122
|
+
f"Unknown entity type '{entity}'. "
|
|
123
|
+
f"Supported entities: {', '.join(sorted(SUPPORTED_ENTITIES))}",
|
|
124
|
+
field="from",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def validate_operator(op: str, _version: str = CURRENT_VERSION) -> None:
|
|
129
|
+
"""Validate that operator is supported for the given version."""
|
|
130
|
+
supported = SUPPORTED_OPERATORS_V1 # Currently only v1
|
|
131
|
+
if op not in supported:
|
|
132
|
+
raise QueryParseError(
|
|
133
|
+
f"Unknown operator '{op}'. Supported operators: {', '.join(sorted(supported))}",
|
|
134
|
+
field="op",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def validate_where_clause(where: WhereClause, version: str = CURRENT_VERSION) -> None:
|
|
139
|
+
"""Recursively validate a WHERE clause."""
|
|
140
|
+
# Check for single condition
|
|
141
|
+
if where.op is not None:
|
|
142
|
+
validate_operator(where.op, version)
|
|
143
|
+
|
|
144
|
+
# Validate that path or expr is provided
|
|
145
|
+
if where.path is None and where.expr is None:
|
|
146
|
+
raise QueryValidationError(
|
|
147
|
+
"Condition must have 'path' or 'expr' field",
|
|
148
|
+
field="where",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Validate value for operators that require it
|
|
152
|
+
if where.op not in ("is_null", "is_not_null") and where.value is None:
|
|
153
|
+
raise QueryValidationError(
|
|
154
|
+
f"Operator '{where.op}' requires a 'value' field",
|
|
155
|
+
field="where",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Validate 'between' has two-element list
|
|
159
|
+
if where.op == "between" and (not isinstance(where.value, list) or len(where.value) != 2):
|
|
160
|
+
raise QueryValidationError(
|
|
161
|
+
"'between' operator requires a two-element array [min, max]",
|
|
162
|
+
field="where.value",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Validate 'in' has a list
|
|
166
|
+
if where.op == "in" and not isinstance(where.value, list):
|
|
167
|
+
raise QueryValidationError(
|
|
168
|
+
"'in' operator requires an array value",
|
|
169
|
+
field="where.value",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Validate compound conditions
|
|
173
|
+
if where.and_ is not None:
|
|
174
|
+
for clause in where.and_:
|
|
175
|
+
validate_where_clause(clause, version)
|
|
176
|
+
|
|
177
|
+
if where.or_ is not None:
|
|
178
|
+
for clause in where.or_:
|
|
179
|
+
validate_where_clause(clause, version)
|
|
180
|
+
|
|
181
|
+
if where.not_ is not None:
|
|
182
|
+
validate_where_clause(where.not_, version)
|
|
183
|
+
|
|
184
|
+
# Validate quantifiers
|
|
185
|
+
if where.all_ is not None:
|
|
186
|
+
validate_where_clause(where.all_.where, version)
|
|
187
|
+
|
|
188
|
+
if where.none_ is not None:
|
|
189
|
+
validate_where_clause(where.none_.where, version)
|
|
190
|
+
|
|
191
|
+
# Validate exists
|
|
192
|
+
if where.exists_ is not None:
|
|
193
|
+
if where.exists_.from_ not in SUPPORTED_ENTITIES:
|
|
194
|
+
raise QueryValidationError(
|
|
195
|
+
f"Unknown entity type '{where.exists_.from_}' in EXISTS clause",
|
|
196
|
+
field="where.exists.from",
|
|
197
|
+
)
|
|
198
|
+
if where.exists_.where is not None:
|
|
199
|
+
validate_where_clause(where.exists_.where, version)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# =============================================================================
|
|
203
|
+
# Entity Queryability Validation
|
|
204
|
+
# =============================================================================
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def extract_filter_fields(where: WhereClause | None, *, inside_not: bool = False) -> set[str]:
|
|
208
|
+
"""Recursively extract all field paths from a where clause.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
where: The where clause to extract from
|
|
212
|
+
inside_not: True if we're inside a NOT clause (used to track negated filters)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Set of field paths found in the where clause
|
|
216
|
+
"""
|
|
217
|
+
if where is None:
|
|
218
|
+
return set()
|
|
219
|
+
|
|
220
|
+
fields: set[str] = set()
|
|
221
|
+
|
|
222
|
+
# Direct condition
|
|
223
|
+
if where.path and not inside_not:
|
|
224
|
+
fields.add(where.path)
|
|
225
|
+
|
|
226
|
+
# Compound conditions
|
|
227
|
+
if where.and_:
|
|
228
|
+
for clause in where.and_:
|
|
229
|
+
fields.update(extract_filter_fields(clause, inside_not=inside_not))
|
|
230
|
+
if where.or_:
|
|
231
|
+
for clause in where.or_:
|
|
232
|
+
fields.update(extract_filter_fields(clause, inside_not=inside_not))
|
|
233
|
+
if where.not_:
|
|
234
|
+
# Don't extract fields from inside NOT - negated filters don't satisfy requirements
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
return fields
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def extract_filter_operators(where: WhereClause | None, field: str) -> set[str]:
|
|
241
|
+
"""Extract all operators used for a specific field in where clause.
|
|
242
|
+
|
|
243
|
+
NOTE: Does NOT traverse into NOT clauses - negated filters should be
|
|
244
|
+
rejected by validate_entity_queryable, not validated for operators.
|
|
245
|
+
"""
|
|
246
|
+
if where is None:
|
|
247
|
+
return set()
|
|
248
|
+
|
|
249
|
+
ops: set[str] = set()
|
|
250
|
+
|
|
251
|
+
# Direct condition
|
|
252
|
+
if where.path == field and where.op:
|
|
253
|
+
ops.add(where.op)
|
|
254
|
+
|
|
255
|
+
# Compound conditions
|
|
256
|
+
if where.and_:
|
|
257
|
+
for clause in where.and_:
|
|
258
|
+
ops.update(extract_filter_operators(clause, field))
|
|
259
|
+
if where.or_:
|
|
260
|
+
for clause in where.or_:
|
|
261
|
+
ops.update(extract_filter_operators(clause, field))
|
|
262
|
+
# NOTE: "not" clauses are not traversed - negated required filters are invalid
|
|
263
|
+
|
|
264
|
+
return ops
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _check_required_filter_in_not(
|
|
268
|
+
where: WhereClause | None, required_fields: frozenset[str]
|
|
269
|
+
) -> bool:
|
|
270
|
+
"""Check if any required filter field appears inside a NOT clause.
|
|
271
|
+
|
|
272
|
+
Returns True if a required filter is negated (which is invalid).
|
|
273
|
+
"""
|
|
274
|
+
if where is None:
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
# Check NOT clause for required fields
|
|
278
|
+
if where.not_ and _where_contains_field(where.not_, required_fields):
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
# Recurse into compound conditions
|
|
282
|
+
if where.and_:
|
|
283
|
+
for clause in where.and_:
|
|
284
|
+
if _check_required_filter_in_not(clause, required_fields):
|
|
285
|
+
return True
|
|
286
|
+
if where.or_:
|
|
287
|
+
for clause in where.or_:
|
|
288
|
+
if _check_required_filter_in_not(clause, required_fields):
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _where_contains_field(where: WhereClause | None, fields: frozenset[str]) -> bool:
|
|
295
|
+
"""Check if a where clause contains any of the specified fields."""
|
|
296
|
+
if where is None:
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
if where.path and where.path in fields:
|
|
300
|
+
return True
|
|
301
|
+
|
|
302
|
+
if where.and_:
|
|
303
|
+
for clause in where.and_:
|
|
304
|
+
if _where_contains_field(clause, fields):
|
|
305
|
+
return True
|
|
306
|
+
if where.or_:
|
|
307
|
+
for clause in where.or_:
|
|
308
|
+
if _where_contains_field(clause, fields):
|
|
309
|
+
return True
|
|
310
|
+
return bool(where.not_ and _where_contains_field(where.not_, fields))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _validate_or_branches_have_required_filter(
|
|
314
|
+
where: WhereClause | None,
|
|
315
|
+
required_fields: frozenset[str],
|
|
316
|
+
parent_has_required: bool = False,
|
|
317
|
+
) -> bool:
|
|
318
|
+
"""Validate that all OR branches contain at least one required filter.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
where: Where clause to validate
|
|
322
|
+
required_fields: Set of required field names (e.g., {"listId", "listName"})
|
|
323
|
+
parent_has_required: True if an ancestor AND clause already has the required filter
|
|
324
|
+
|
|
325
|
+
Returns True if valid, False if any OR branch is missing required filter.
|
|
326
|
+
|
|
327
|
+
Example valid structures:
|
|
328
|
+
- AND [listName=X, OR[A, B]] # OR branches covered by sibling listName in AND
|
|
329
|
+
- OR [AND[listName=X, A], AND[listName=Y, B]] # Each OR branch has its own
|
|
330
|
+
"""
|
|
331
|
+
if where is None:
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
# Check OR branches - each must have required filter UNLESS parent AND has it
|
|
335
|
+
if where.or_ and not parent_has_required:
|
|
336
|
+
for branch in where.or_:
|
|
337
|
+
branch_fields = extract_filter_fields(branch)
|
|
338
|
+
if not (branch_fields & required_fields):
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
# For AND clauses: check if any sibling clause provides the required filter
|
|
342
|
+
# If so, nested OR clauses within this AND don't need their own
|
|
343
|
+
if where.and_:
|
|
344
|
+
# Check if this AND has the required filter at its level
|
|
345
|
+
and_has_required = parent_has_required
|
|
346
|
+
for clause in where.and_:
|
|
347
|
+
# Only check direct (non-compound) conditions at this level
|
|
348
|
+
if clause.path and clause.path in required_fields:
|
|
349
|
+
and_has_required = True
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
# Recurse into AND clauses with updated context
|
|
353
|
+
for clause in where.and_:
|
|
354
|
+
if not _validate_or_branches_have_required_filter(
|
|
355
|
+
clause, required_fields, and_has_required
|
|
356
|
+
):
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
# Recurse into OR clauses - each branch is independent
|
|
360
|
+
if where.or_:
|
|
361
|
+
for clause in where.or_:
|
|
362
|
+
# Each OR branch provides its own context
|
|
363
|
+
clause_fields = extract_filter_fields(clause)
|
|
364
|
+
branch_has_required = parent_has_required or bool(clause_fields & required_fields)
|
|
365
|
+
if not _validate_or_branches_have_required_filter(
|
|
366
|
+
clause, required_fields, branch_has_required
|
|
367
|
+
):
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
return True
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def validate_entity_queryable(query: Query) -> None:
|
|
374
|
+
"""Validate that the entity can be queried directly.
|
|
375
|
+
|
|
376
|
+
Checks:
|
|
377
|
+
1. RELATIONSHIP_ONLY entities cannot be queried directly
|
|
378
|
+
2. REQUIRES_PARENT entities must have required filter with valid operator
|
|
379
|
+
3. Required filters cannot be negated (inside NOT clause)
|
|
380
|
+
4. All OR branches must have the required filter
|
|
381
|
+
|
|
382
|
+
Raises:
|
|
383
|
+
QueryParseError: If entity cannot be queried as specified
|
|
384
|
+
"""
|
|
385
|
+
schema = SCHEMA_REGISTRY.get(query.from_)
|
|
386
|
+
if schema is None:
|
|
387
|
+
raise QueryParseError(f"Unknown entity: '{query.from_}'")
|
|
388
|
+
|
|
389
|
+
# Check if entity can be queried directly
|
|
390
|
+
if schema.fetch_strategy == FetchStrategy.RELATIONSHIP_ONLY:
|
|
391
|
+
raise QueryParseError(
|
|
392
|
+
f"'{query.from_}' cannot be queried directly. "
|
|
393
|
+
f"Use it as an 'include' on a parent entity instead. "
|
|
394
|
+
f'Example: {{"from": "persons", "include": ["{query.from_}"]}}'
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Check required filters for REQUIRES_PARENT entities
|
|
398
|
+
if schema.fetch_strategy == FetchStrategy.REQUIRES_PARENT:
|
|
399
|
+
present = extract_filter_fields(query.where)
|
|
400
|
+
# For listEntries, either listId OR listName is acceptable
|
|
401
|
+
if not (present & schema.required_filters):
|
|
402
|
+
# Show all alternatives in error message
|
|
403
|
+
filter_options = sorted(schema.required_filters)
|
|
404
|
+
if len(filter_options) > 1:
|
|
405
|
+
filter_desc = " or ".join(f"'{f}'" for f in filter_options)
|
|
406
|
+
# For listEntries, show both ID and name examples
|
|
407
|
+
raise QueryParseError(
|
|
408
|
+
f"Query for '{query.from_}' requires a {filter_desc} filter. "
|
|
409
|
+
f"Examples:\n"
|
|
410
|
+
f' By ID: {{"from": "{query.from_}", "where": '
|
|
411
|
+
f'{{"path": "listId", "op": "eq", "value": 12345}}}}\n'
|
|
412
|
+
f' By name: {{"from": "{query.from_}", "where": '
|
|
413
|
+
f'{{"path": "listName", "op": "eq", "value": "My List"}}}}'
|
|
414
|
+
)
|
|
415
|
+
else:
|
|
416
|
+
example_filter = filter_options[0]
|
|
417
|
+
raise QueryParseError(
|
|
418
|
+
f"Query for '{query.from_}' requires a '{example_filter}' filter. "
|
|
419
|
+
f'Example: {{"from": "{query.from_}", "where": '
|
|
420
|
+
f'{{"path": "{example_filter}", "op": "eq", "value": 12345}}}}'
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# Check for negated required filters
|
|
424
|
+
if _check_required_filter_in_not(query.where, schema.required_filters):
|
|
425
|
+
raise QueryParseError(
|
|
426
|
+
f"Cannot negate required filter for '{query.from_}'. "
|
|
427
|
+
f"A negated filter like NOT(listId=X) would match all other lists, "
|
|
428
|
+
f"which is unbounded. Use a positive filter instead."
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Validate operators for required filters (must be eq or in)
|
|
432
|
+
valid_ops = {"eq", "in"}
|
|
433
|
+
for required_field in schema.required_filters:
|
|
434
|
+
ops = extract_filter_operators(query.where, required_field)
|
|
435
|
+
invalid_ops = ops - valid_ops
|
|
436
|
+
if invalid_ops:
|
|
437
|
+
raise QueryParseError(
|
|
438
|
+
f"Invalid operator '{next(iter(invalid_ops))}' for required filter "
|
|
439
|
+
f"'{required_field}'. Only 'eq' and 'in' operators are supported. "
|
|
440
|
+
f'Example: {{"path": "{required_field}", "op": "eq", "value": 12345}}'
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# Validate all OR branches have required filter
|
|
444
|
+
if not _validate_or_branches_have_required_filter(query.where, schema.required_filters):
|
|
445
|
+
filter_options = sorted(schema.required_filters)
|
|
446
|
+
filter_desc = " or ".join(f"'{f}'" for f in filter_options)
|
|
447
|
+
raise QueryParseError(
|
|
448
|
+
f"All OR branches must include a {filter_desc} filter. "
|
|
449
|
+
f"Each branch of an OR condition must specify which parent to fetch from."
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def validate_query_semantics(query: Query) -> list[str]:
|
|
454
|
+
"""Validate semantic constraints on the query.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
List of warnings (non-fatal issues)
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
QueryValidationError: For fatal semantic errors
|
|
461
|
+
"""
|
|
462
|
+
warnings: list[str] = []
|
|
463
|
+
|
|
464
|
+
# Aggregate with include is not allowed
|
|
465
|
+
if query.aggregate is not None and query.include is not None:
|
|
466
|
+
raise QueryValidationError(
|
|
467
|
+
"Cannot use 'aggregate' with 'include'. "
|
|
468
|
+
"Aggregates collapse records, making includes meaningless.",
|
|
469
|
+
field="aggregate",
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# groupBy requires aggregate
|
|
473
|
+
if query.group_by is not None and query.aggregate is None:
|
|
474
|
+
raise QueryValidationError(
|
|
475
|
+
"'groupBy' requires 'aggregate' to be specified.",
|
|
476
|
+
field="groupBy",
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# having requires aggregate
|
|
480
|
+
if query.having is not None and query.aggregate is None:
|
|
481
|
+
raise QueryValidationError(
|
|
482
|
+
"'having' requires 'aggregate' to be specified.",
|
|
483
|
+
field="having",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Validate include paths
|
|
487
|
+
if query.include is not None:
|
|
488
|
+
for include_path in query.include:
|
|
489
|
+
# Basic validation - detailed validation happens in schema.py
|
|
490
|
+
if not include_path or not isinstance(include_path, str):
|
|
491
|
+
raise QueryValidationError(
|
|
492
|
+
f"Invalid include path: {include_path!r}",
|
|
493
|
+
field="include",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Validate select paths
|
|
497
|
+
if query.select is not None:
|
|
498
|
+
for select_path in query.select:
|
|
499
|
+
if not select_path or not isinstance(select_path, str):
|
|
500
|
+
raise QueryValidationError(
|
|
501
|
+
f"Invalid select path: {select_path!r}",
|
|
502
|
+
field="select",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Validate limit
|
|
506
|
+
if query.limit is not None:
|
|
507
|
+
if query.limit < 0:
|
|
508
|
+
raise QueryValidationError(
|
|
509
|
+
"limit must be non-negative",
|
|
510
|
+
field="limit",
|
|
511
|
+
)
|
|
512
|
+
if query.limit == 0:
|
|
513
|
+
warnings.append("Query has limit=0, which will return no results.")
|
|
514
|
+
|
|
515
|
+
return warnings
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# =============================================================================
|
|
519
|
+
# Main Parse Function
|
|
520
|
+
# =============================================================================
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def parse_query(
|
|
524
|
+
query_input: dict[str, Any] | str,
|
|
525
|
+
*,
|
|
526
|
+
version_override: str | None = None,
|
|
527
|
+
) -> ParseResult:
|
|
528
|
+
"""Parse and validate a query.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
query_input: Either a dict (already parsed JSON) or a JSON string
|
|
532
|
+
version_override: If provided, overrides $version in query
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
ParseResult with validated Query and warnings
|
|
536
|
+
|
|
537
|
+
Raises:
|
|
538
|
+
QueryParseError: For syntax/parsing errors
|
|
539
|
+
QueryValidationError: For semantic validation errors
|
|
540
|
+
"""
|
|
541
|
+
warnings: list[str] = []
|
|
542
|
+
|
|
543
|
+
# Parse JSON if string
|
|
544
|
+
if isinstance(query_input, str):
|
|
545
|
+
try:
|
|
546
|
+
query_dict = json.loads(query_input)
|
|
547
|
+
except json.JSONDecodeError as e:
|
|
548
|
+
raise QueryParseError(f"Invalid JSON: {e}") from None
|
|
549
|
+
else:
|
|
550
|
+
query_dict = query_input
|
|
551
|
+
|
|
552
|
+
if not isinstance(query_dict, dict):
|
|
553
|
+
raise QueryParseError("Query must be a JSON object")
|
|
554
|
+
|
|
555
|
+
# Handle version
|
|
556
|
+
version = version_override or query_dict.get("$version")
|
|
557
|
+
resolved_version, version_warnings = validate_version(version)
|
|
558
|
+
warnings.extend(version_warnings)
|
|
559
|
+
|
|
560
|
+
# Set version in query dict for Pydantic
|
|
561
|
+
query_dict["$version"] = resolved_version
|
|
562
|
+
|
|
563
|
+
# Validate entity type before Pydantic parsing
|
|
564
|
+
if "from" not in query_dict:
|
|
565
|
+
raise QueryParseError("Query must have a 'from' field specifying the entity type")
|
|
566
|
+
validate_entity(query_dict["from"])
|
|
567
|
+
|
|
568
|
+
# Parse with Pydantic
|
|
569
|
+
try:
|
|
570
|
+
query = Query.model_validate(query_dict)
|
|
571
|
+
except ValidationError as e:
|
|
572
|
+
# Convert Pydantic errors to QueryParseError
|
|
573
|
+
errors = e.errors()
|
|
574
|
+
if len(errors) == 1:
|
|
575
|
+
err = errors[0]
|
|
576
|
+
field_path = ".".join(str(loc) for loc in err["loc"])
|
|
577
|
+
raise QueryParseError(err["msg"], field=field_path) from None
|
|
578
|
+
else:
|
|
579
|
+
error_msgs = [
|
|
580
|
+
f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}" for err in errors
|
|
581
|
+
]
|
|
582
|
+
raise QueryParseError("Multiple validation errors:\n" + "\n".join(error_msgs)) from None
|
|
583
|
+
|
|
584
|
+
# Validate WHERE clause operators
|
|
585
|
+
if query.where is not None:
|
|
586
|
+
validate_where_clause(query.where, resolved_version)
|
|
587
|
+
|
|
588
|
+
# Validate entity queryability (RELATIONSHIP_ONLY, REQUIRES_PARENT checks)
|
|
589
|
+
validate_entity_queryable(query)
|
|
590
|
+
|
|
591
|
+
# Validate semantic constraints
|
|
592
|
+
semantic_warnings = validate_query_semantics(query)
|
|
593
|
+
warnings.extend(semantic_warnings)
|
|
594
|
+
|
|
595
|
+
return ParseResult(query, warnings)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def parse_query_from_file(
|
|
599
|
+
filepath: str | Path, *, version_override: str | None = None
|
|
600
|
+
) -> ParseResult:
|
|
601
|
+
"""Parse a query from a file.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
filepath: Path to JSON file
|
|
605
|
+
version_override: If provided, overrides $version in query
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
ParseResult with validated Query and warnings
|
|
609
|
+
|
|
610
|
+
Raises:
|
|
611
|
+
QueryParseError: For file read or parsing errors
|
|
612
|
+
"""
|
|
613
|
+
path = Path(filepath) if isinstance(filepath, str) else filepath
|
|
614
|
+
try:
|
|
615
|
+
content = path.read_text()
|
|
616
|
+
except OSError as e:
|
|
617
|
+
raise QueryParseError(f"Failed to read query file: {e}") from None
|
|
618
|
+
|
|
619
|
+
return parse_query(content, version_override=version_override)
|