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.
Files changed (92) hide show
  1. affinity/__init__.py +139 -0
  2. affinity/cli/__init__.py +7 -0
  3. affinity/cli/click_compat.py +27 -0
  4. affinity/cli/commands/__init__.py +1 -0
  5. affinity/cli/commands/_entity_files_dump.py +219 -0
  6. affinity/cli/commands/_list_entry_fields.py +41 -0
  7. affinity/cli/commands/_v1_parsing.py +77 -0
  8. affinity/cli/commands/company_cmds.py +2139 -0
  9. affinity/cli/commands/completion_cmd.py +33 -0
  10. affinity/cli/commands/config_cmds.py +540 -0
  11. affinity/cli/commands/entry_cmds.py +33 -0
  12. affinity/cli/commands/field_cmds.py +413 -0
  13. affinity/cli/commands/interaction_cmds.py +875 -0
  14. affinity/cli/commands/list_cmds.py +3152 -0
  15. affinity/cli/commands/note_cmds.py +433 -0
  16. affinity/cli/commands/opportunity_cmds.py +1174 -0
  17. affinity/cli/commands/person_cmds.py +1980 -0
  18. affinity/cli/commands/query_cmd.py +444 -0
  19. affinity/cli/commands/relationship_strength_cmds.py +62 -0
  20. affinity/cli/commands/reminder_cmds.py +595 -0
  21. affinity/cli/commands/resolve_url_cmd.py +127 -0
  22. affinity/cli/commands/session_cmds.py +84 -0
  23. affinity/cli/commands/task_cmds.py +110 -0
  24. affinity/cli/commands/version_cmd.py +29 -0
  25. affinity/cli/commands/whoami_cmd.py +36 -0
  26. affinity/cli/config.py +108 -0
  27. affinity/cli/context.py +749 -0
  28. affinity/cli/csv_utils.py +195 -0
  29. affinity/cli/date_utils.py +42 -0
  30. affinity/cli/decorators.py +77 -0
  31. affinity/cli/errors.py +28 -0
  32. affinity/cli/field_utils.py +355 -0
  33. affinity/cli/formatters.py +551 -0
  34. affinity/cli/help_json.py +283 -0
  35. affinity/cli/logging.py +100 -0
  36. affinity/cli/main.py +261 -0
  37. affinity/cli/options.py +53 -0
  38. affinity/cli/paths.py +32 -0
  39. affinity/cli/progress.py +183 -0
  40. affinity/cli/query/__init__.py +163 -0
  41. affinity/cli/query/aggregates.py +357 -0
  42. affinity/cli/query/dates.py +194 -0
  43. affinity/cli/query/exceptions.py +147 -0
  44. affinity/cli/query/executor.py +1236 -0
  45. affinity/cli/query/filters.py +248 -0
  46. affinity/cli/query/models.py +333 -0
  47. affinity/cli/query/output.py +331 -0
  48. affinity/cli/query/parser.py +619 -0
  49. affinity/cli/query/planner.py +430 -0
  50. affinity/cli/query/progress.py +270 -0
  51. affinity/cli/query/schema.py +439 -0
  52. affinity/cli/render.py +1589 -0
  53. affinity/cli/resolve.py +222 -0
  54. affinity/cli/resolvers.py +249 -0
  55. affinity/cli/results.py +308 -0
  56. affinity/cli/runner.py +218 -0
  57. affinity/cli/serialization.py +65 -0
  58. affinity/cli/session_cache.py +276 -0
  59. affinity/cli/types.py +70 -0
  60. affinity/client.py +771 -0
  61. affinity/clients/__init__.py +19 -0
  62. affinity/clients/http.py +3664 -0
  63. affinity/clients/pipeline.py +165 -0
  64. affinity/compare.py +501 -0
  65. affinity/downloads.py +114 -0
  66. affinity/exceptions.py +615 -0
  67. affinity/filters.py +1128 -0
  68. affinity/hooks.py +198 -0
  69. affinity/inbound_webhooks.py +302 -0
  70. affinity/models/__init__.py +163 -0
  71. affinity/models/entities.py +798 -0
  72. affinity/models/pagination.py +513 -0
  73. affinity/models/rate_limit_snapshot.py +48 -0
  74. affinity/models/secondary.py +413 -0
  75. affinity/models/types.py +663 -0
  76. affinity/policies.py +40 -0
  77. affinity/progress.py +22 -0
  78. affinity/py.typed +0 -0
  79. affinity/services/__init__.py +42 -0
  80. affinity/services/companies.py +1286 -0
  81. affinity/services/lists.py +1892 -0
  82. affinity/services/opportunities.py +1330 -0
  83. affinity/services/persons.py +1348 -0
  84. affinity/services/rate_limits.py +173 -0
  85. affinity/services/tasks.py +193 -0
  86. affinity/services/v1_only.py +2445 -0
  87. affinity/types.py +83 -0
  88. affinity_sdk-0.9.5.dist-info/METADATA +622 -0
  89. affinity_sdk-0.9.5.dist-info/RECORD +92 -0
  90. affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
  91. affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
  92. 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)