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,248 @@
|
|
|
1
|
+
"""Filter operators for query WHERE clauses.
|
|
2
|
+
|
|
3
|
+
This module provides extended filter operators beyond what the SDK supports.
|
|
4
|
+
It is CLI-only and NOT part of the public SDK API.
|
|
5
|
+
|
|
6
|
+
Uses the shared compare module (affinity/compare.py) for comparison logic,
|
|
7
|
+
ensuring consistent behavior between SDK filter and Query tool.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ...compare import compare_values
|
|
16
|
+
from .dates import parse_date_value
|
|
17
|
+
from .exceptions import QueryValidationError
|
|
18
|
+
from .models import WhereClause
|
|
19
|
+
|
|
20
|
+
# =============================================================================
|
|
21
|
+
# Operator Definitions
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
# Type alias for operator functions
|
|
25
|
+
OperatorFunc = Callable[[Any, Any], bool]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_operator(op_name: str) -> OperatorFunc:
|
|
29
|
+
"""Create an operator function that delegates to compare_values().
|
|
30
|
+
|
|
31
|
+
This is a factory function that creates operator functions for the OPERATORS
|
|
32
|
+
registry. Each function wraps compare_values() with the appropriate operator name.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def op_func(field_value: Any, target: Any) -> bool:
|
|
36
|
+
return compare_values(field_value, target, op_name)
|
|
37
|
+
|
|
38
|
+
return op_func
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Operator registry - all operators delegate to compare_values() from the shared module
|
|
42
|
+
# This ensures consistent comparison behavior between SDK filter and Query tool
|
|
43
|
+
OPERATORS: dict[str, OperatorFunc] = {
|
|
44
|
+
"eq": _make_operator("eq"),
|
|
45
|
+
"neq": _make_operator("neq"),
|
|
46
|
+
"gt": _make_operator("gt"),
|
|
47
|
+
"gte": _make_operator("gte"),
|
|
48
|
+
"lt": _make_operator("lt"),
|
|
49
|
+
"lte": _make_operator("lte"),
|
|
50
|
+
"contains": _make_operator("contains"),
|
|
51
|
+
"starts_with": _make_operator("starts_with"),
|
|
52
|
+
"ends_with": _make_operator("ends_with"), # New: was missing in query tool
|
|
53
|
+
"in": _make_operator("in"),
|
|
54
|
+
"between": _make_operator("between"),
|
|
55
|
+
"is_null": _make_operator("is_null"),
|
|
56
|
+
"is_not_null": _make_operator("is_not_null"),
|
|
57
|
+
"is_empty": _make_operator("is_empty"), # New: was missing in query tool
|
|
58
|
+
"contains_any": _make_operator("contains_any"),
|
|
59
|
+
"contains_all": _make_operator("contains_all"),
|
|
60
|
+
"has_any": _make_operator("has_any"),
|
|
61
|
+
"has_all": _make_operator("has_all"),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# =============================================================================
|
|
66
|
+
# Field Path Resolution
|
|
67
|
+
# =============================================================================
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_field_path(record: dict[str, Any], path: str) -> Any:
|
|
71
|
+
"""Resolve a field path to a value.
|
|
72
|
+
|
|
73
|
+
Supports:
|
|
74
|
+
- Simple fields: "firstName"
|
|
75
|
+
- Nested fields: "address.city"
|
|
76
|
+
- Array fields: "emails[0]"
|
|
77
|
+
- Special fields: "fields.Status" for list entry fields
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
record: The record to extract value from
|
|
81
|
+
path: The field path
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The resolved value, or None if not found
|
|
85
|
+
"""
|
|
86
|
+
if not path:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
parts = _parse_field_path(path)
|
|
90
|
+
current: Any = record
|
|
91
|
+
|
|
92
|
+
for part in parts:
|
|
93
|
+
if current is None:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if isinstance(part, int):
|
|
97
|
+
# Array index
|
|
98
|
+
if isinstance(current, list) and 0 <= part < len(current):
|
|
99
|
+
current = current[part]
|
|
100
|
+
else:
|
|
101
|
+
return None
|
|
102
|
+
elif isinstance(current, dict):
|
|
103
|
+
# Object property
|
|
104
|
+
current = current.get(part)
|
|
105
|
+
else:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
return current
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_field_path(path: str) -> list[str | int]:
|
|
112
|
+
"""Parse a field path into parts.
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
"firstName" -> ["firstName"]
|
|
116
|
+
"address.city" -> ["address", "city"]
|
|
117
|
+
"emails[0]" -> ["emails", 0]
|
|
118
|
+
"fields.Status" -> ["fields", "Status"]
|
|
119
|
+
"""
|
|
120
|
+
parts: list[str | int] = []
|
|
121
|
+
current = ""
|
|
122
|
+
i = 0
|
|
123
|
+
|
|
124
|
+
while i < len(path):
|
|
125
|
+
char = path[i]
|
|
126
|
+
|
|
127
|
+
if char == ".":
|
|
128
|
+
if current:
|
|
129
|
+
parts.append(current)
|
|
130
|
+
current = ""
|
|
131
|
+
i += 1
|
|
132
|
+
elif char == "[":
|
|
133
|
+
if current:
|
|
134
|
+
parts.append(current)
|
|
135
|
+
current = ""
|
|
136
|
+
# Find closing bracket
|
|
137
|
+
end = path.find("]", i)
|
|
138
|
+
if end == -1:
|
|
139
|
+
raise QueryValidationError(f"Unclosed bracket in field path: {path}")
|
|
140
|
+
index_str = path[i + 1 : end]
|
|
141
|
+
try:
|
|
142
|
+
parts.append(int(index_str))
|
|
143
|
+
except ValueError:
|
|
144
|
+
# Non-numeric index, treat as string
|
|
145
|
+
parts.append(index_str)
|
|
146
|
+
i = end + 1
|
|
147
|
+
else:
|
|
148
|
+
current += char
|
|
149
|
+
i += 1
|
|
150
|
+
|
|
151
|
+
if current:
|
|
152
|
+
parts.append(current)
|
|
153
|
+
|
|
154
|
+
return parts
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# =============================================================================
|
|
158
|
+
# Filter Compilation
|
|
159
|
+
# =============================================================================
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def compile_filter(where: WhereClause) -> Callable[[dict[str, Any]], bool]:
|
|
163
|
+
"""Compile a WHERE clause into a filter function.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
where: The WHERE clause to compile
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
A function that takes a record and returns True if it matches
|
|
170
|
+
"""
|
|
171
|
+
# Single condition
|
|
172
|
+
if where.op is not None:
|
|
173
|
+
return _compile_condition(where)
|
|
174
|
+
|
|
175
|
+
# Compound conditions
|
|
176
|
+
if where.and_ is not None:
|
|
177
|
+
filters = [compile_filter(clause) for clause in where.and_]
|
|
178
|
+
return lambda record: all(f(record) for f in filters)
|
|
179
|
+
|
|
180
|
+
if where.or_ is not None:
|
|
181
|
+
filters = [compile_filter(clause) for clause in where.or_]
|
|
182
|
+
return lambda record: any(f(record) for f in filters)
|
|
183
|
+
|
|
184
|
+
if where.not_ is not None:
|
|
185
|
+
inner = compile_filter(where.not_)
|
|
186
|
+
return lambda record: not inner(record)
|
|
187
|
+
|
|
188
|
+
# Quantifiers (all, none) - placeholder, requires relationship fetching
|
|
189
|
+
if where.all_ is not None or where.none_ is not None:
|
|
190
|
+
# These require relationship data and are handled by executor
|
|
191
|
+
# For now, pass through
|
|
192
|
+
return lambda _: True
|
|
193
|
+
|
|
194
|
+
# Exists - placeholder, requires subquery execution
|
|
195
|
+
if where.exists_ is not None:
|
|
196
|
+
return lambda _: True
|
|
197
|
+
|
|
198
|
+
# No conditions - match all
|
|
199
|
+
return lambda _: True
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _compile_condition(where: WhereClause) -> Callable[[dict[str, Any]], bool]:
|
|
203
|
+
"""Compile a single filter condition."""
|
|
204
|
+
if where.op is None:
|
|
205
|
+
return lambda _: True
|
|
206
|
+
|
|
207
|
+
op_func = OPERATORS.get(where.op)
|
|
208
|
+
if op_func is None:
|
|
209
|
+
raise QueryValidationError(f"Unknown operator: {where.op}")
|
|
210
|
+
|
|
211
|
+
path = where.path
|
|
212
|
+
value = where.value
|
|
213
|
+
|
|
214
|
+
# Parse date values if they look like relative dates
|
|
215
|
+
if value is not None and isinstance(value, str):
|
|
216
|
+
parsed_value = parse_date_value(value)
|
|
217
|
+
if parsed_value is not None:
|
|
218
|
+
value = parsed_value
|
|
219
|
+
|
|
220
|
+
# Handle _count pseudo-field
|
|
221
|
+
if path and path.endswith("._count"):
|
|
222
|
+
# This requires relationship counting, handled by executor
|
|
223
|
+
# Return a placeholder that always matches
|
|
224
|
+
return lambda _: True
|
|
225
|
+
|
|
226
|
+
def filter_func(record: dict[str, Any]) -> bool:
|
|
227
|
+
if path is None:
|
|
228
|
+
return True
|
|
229
|
+
field_value = resolve_field_path(record, path)
|
|
230
|
+
return op_func(field_value, value)
|
|
231
|
+
|
|
232
|
+
return filter_func
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def matches(record: dict[str, Any], where: WhereClause | None) -> bool:
|
|
236
|
+
"""Check if a record matches a WHERE clause.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
record: The record to check
|
|
240
|
+
where: The WHERE clause, or None (matches all)
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
True if the record matches
|
|
244
|
+
"""
|
|
245
|
+
if where is None:
|
|
246
|
+
return True
|
|
247
|
+
filter_func = compile_filter(where)
|
|
248
|
+
return filter_func(record)
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Pydantic models for the query language.
|
|
2
|
+
|
|
3
|
+
These models define the structure of structured queries. They are CLI-only
|
|
4
|
+
and NOT part of the public SDK API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..results import ResultSummary
|
|
16
|
+
|
|
17
|
+
# =============================================================================
|
|
18
|
+
# Filter Condition Models
|
|
19
|
+
# =============================================================================
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FilterCondition(BaseModel):
|
|
23
|
+
"""A single filter condition.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
{"path": "email", "op": "contains", "value": "@acme.com"}
|
|
27
|
+
{"path": "createdAt", "op": "gt", "value": "-30d"}
|
|
28
|
+
{"path": "companies._count", "op": "gte", "value": 2}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(extra="forbid")
|
|
32
|
+
|
|
33
|
+
path: str | None = None
|
|
34
|
+
expr: dict[str, Any] | None = None
|
|
35
|
+
op: Literal[
|
|
36
|
+
"eq",
|
|
37
|
+
"neq",
|
|
38
|
+
"gt",
|
|
39
|
+
"gte",
|
|
40
|
+
"lt",
|
|
41
|
+
"lte",
|
|
42
|
+
"contains",
|
|
43
|
+
"starts_with",
|
|
44
|
+
"in",
|
|
45
|
+
"between",
|
|
46
|
+
"is_null",
|
|
47
|
+
"is_not_null",
|
|
48
|
+
"contains_any",
|
|
49
|
+
"contains_all",
|
|
50
|
+
]
|
|
51
|
+
value: Any = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class QuantifierClause(BaseModel):
|
|
55
|
+
"""Quantifier clause for 'all' or 'none' predicates.
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
{"all": {"path": "interactions", "where": {"path": "type", "op": "eq", "value": "MEETING"}}}
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(extra="forbid")
|
|
62
|
+
|
|
63
|
+
path: str
|
|
64
|
+
where: WhereClause
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ExistsClause(BaseModel):
|
|
68
|
+
"""EXISTS subquery clause.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
{"exists": {"from": "interactions", "via": "personId", "where": {...}}}
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
model_config = ConfigDict(extra="forbid")
|
|
75
|
+
|
|
76
|
+
from_: str = Field(..., alias="from")
|
|
77
|
+
via: str | None = None
|
|
78
|
+
where: WhereClause | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WhereClause(BaseModel):
|
|
82
|
+
"""WHERE clause supporting compound conditions.
|
|
83
|
+
|
|
84
|
+
Can be:
|
|
85
|
+
- A single condition: {"path": "x", "op": "eq", "value": "y"}
|
|
86
|
+
- Compound: {"and": [...]} or {"or": [...]}
|
|
87
|
+
- Negation: {"not": {...}}
|
|
88
|
+
- Quantifiers: {"all": {...}} or {"none": {...}}
|
|
89
|
+
- Existence: {"exists": {...}}
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
93
|
+
|
|
94
|
+
# Single condition fields
|
|
95
|
+
path: str | None = None
|
|
96
|
+
expr: dict[str, Any] | None = None
|
|
97
|
+
op: str | None = None
|
|
98
|
+
value: Any = None
|
|
99
|
+
|
|
100
|
+
# Compound conditions
|
|
101
|
+
and_: list[WhereClause] | None = Field(None, alias="and")
|
|
102
|
+
or_: list[WhereClause] | None = Field(None, alias="or")
|
|
103
|
+
not_: WhereClause | None = Field(None, alias="not")
|
|
104
|
+
|
|
105
|
+
# Quantifiers
|
|
106
|
+
all_: QuantifierClause | None = Field(None, alias="all")
|
|
107
|
+
none_: QuantifierClause | None = Field(None, alias="none")
|
|
108
|
+
|
|
109
|
+
# Existence
|
|
110
|
+
exists_: ExistsClause | None = Field(None, alias="exists")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# =============================================================================
|
|
114
|
+
# Aggregate Models
|
|
115
|
+
# =============================================================================
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class AggregateFunc(BaseModel):
|
|
119
|
+
"""Aggregate function definition.
|
|
120
|
+
|
|
121
|
+
Examples:
|
|
122
|
+
{"sum": "amount"}
|
|
123
|
+
{"count": true}
|
|
124
|
+
{"avg": "score"}
|
|
125
|
+
{"percentile": {"field": "amount", "p": 90}}
|
|
126
|
+
{"multiply": ["count", "avgAmount"]}
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
model_config = ConfigDict(extra="forbid")
|
|
130
|
+
|
|
131
|
+
# Simple aggregates
|
|
132
|
+
sum: str | None = None
|
|
133
|
+
avg: str | None = None
|
|
134
|
+
min: str | None = None
|
|
135
|
+
max: str | None = None
|
|
136
|
+
count: bool | str | None = None # True for count(*), str for count(field)
|
|
137
|
+
|
|
138
|
+
# Advanced aggregates
|
|
139
|
+
percentile: dict[str, Any] | None = None # {"field": "x", "p": 90}
|
|
140
|
+
first: str | None = None
|
|
141
|
+
last: str | None = None
|
|
142
|
+
|
|
143
|
+
# Expression aggregates (operate on other aggregates)
|
|
144
|
+
multiply: list[str | int | float] | None = None
|
|
145
|
+
divide: list[str | int | float] | None = None
|
|
146
|
+
add: list[str | int | float] | None = None
|
|
147
|
+
subtract: list[str | int | float] | None = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class HavingClause(BaseModel):
|
|
151
|
+
"""HAVING clause for filtering aggregated results.
|
|
152
|
+
|
|
153
|
+
Examples:
|
|
154
|
+
{"path": "totalAmount", "op": "gt", "value": 1000}
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
158
|
+
|
|
159
|
+
path: str | None = None
|
|
160
|
+
op: str | None = None
|
|
161
|
+
value: Any = None
|
|
162
|
+
|
|
163
|
+
and_: list[HavingClause] | None = Field(None, alias="and")
|
|
164
|
+
or_: list[HavingClause] | None = Field(None, alias="or")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# =============================================================================
|
|
168
|
+
# Order By Models
|
|
169
|
+
# =============================================================================
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class OrderByClause(BaseModel):
|
|
173
|
+
"""ORDER BY clause.
|
|
174
|
+
|
|
175
|
+
Examples:
|
|
176
|
+
{"field": "lastName", "direction": "asc"}
|
|
177
|
+
{"field": "createdAt", "direction": "desc"}
|
|
178
|
+
{"expr": {"daysUntil": "dueDate"}, "direction": "asc"}
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
model_config = ConfigDict(extra="forbid")
|
|
182
|
+
|
|
183
|
+
field: str | None = None
|
|
184
|
+
expr: dict[str, Any] | None = None
|
|
185
|
+
direction: Literal["asc", "desc"] = "asc"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# =============================================================================
|
|
189
|
+
# Subquery Models
|
|
190
|
+
# =============================================================================
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class SubqueryDef(BaseModel):
|
|
194
|
+
"""Named subquery definition.
|
|
195
|
+
|
|
196
|
+
Examples:
|
|
197
|
+
{
|
|
198
|
+
"recentDeals": {
|
|
199
|
+
"from": "opportunities",
|
|
200
|
+
"where": {"path": "closedAt", "op": "gt", "value": "-90d"}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
206
|
+
|
|
207
|
+
from_: str = Field(..., alias="from")
|
|
208
|
+
where: WhereClause | None = None
|
|
209
|
+
select: list[str] | None = None
|
|
210
|
+
limit: int | None = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# =============================================================================
|
|
214
|
+
# Main Query Model
|
|
215
|
+
# =============================================================================
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class Query(BaseModel):
|
|
219
|
+
"""The main query model.
|
|
220
|
+
|
|
221
|
+
This is the top-level structure for a structured query.
|
|
222
|
+
|
|
223
|
+
Examples:
|
|
224
|
+
{
|
|
225
|
+
"$version": "1.0",
|
|
226
|
+
"from": "persons",
|
|
227
|
+
"where": {"path": "email", "op": "contains", "value": "@acme.com"},
|
|
228
|
+
"include": ["companies"],
|
|
229
|
+
"orderBy": [{"field": "lastName", "direction": "asc"}],
|
|
230
|
+
"limit": 50
|
|
231
|
+
}
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
235
|
+
|
|
236
|
+
# Version (optional but recommended)
|
|
237
|
+
version: str | None = Field(None, alias="$version")
|
|
238
|
+
|
|
239
|
+
# Required: entity to query
|
|
240
|
+
from_: str = Field(..., alias="from")
|
|
241
|
+
|
|
242
|
+
# Optional: field selection
|
|
243
|
+
select: list[str] | None = None
|
|
244
|
+
|
|
245
|
+
# Optional: filtering
|
|
246
|
+
where: WhereClause | None = None
|
|
247
|
+
|
|
248
|
+
# Optional: includes (relationships to fetch)
|
|
249
|
+
include: list[str] | None = None
|
|
250
|
+
|
|
251
|
+
# Optional: named subqueries
|
|
252
|
+
subqueries: dict[str, SubqueryDef] | None = None
|
|
253
|
+
|
|
254
|
+
# Optional: ordering
|
|
255
|
+
order_by: list[OrderByClause] | None = Field(None, alias="orderBy")
|
|
256
|
+
|
|
257
|
+
# Optional: grouping and aggregation
|
|
258
|
+
group_by: str | None = Field(None, alias="groupBy")
|
|
259
|
+
aggregate: dict[str, AggregateFunc] | None = None
|
|
260
|
+
having: HavingClause | None = None
|
|
261
|
+
|
|
262
|
+
# Optional: pagination
|
|
263
|
+
limit: int | None = None
|
|
264
|
+
cursor: str | None = None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# =============================================================================
|
|
268
|
+
# Execution Plan Models (used by planner and executor)
|
|
269
|
+
# =============================================================================
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class PlanStep:
|
|
274
|
+
"""A single step in the execution plan."""
|
|
275
|
+
|
|
276
|
+
step_id: int
|
|
277
|
+
operation: Literal[
|
|
278
|
+
"fetch",
|
|
279
|
+
"filter",
|
|
280
|
+
"include",
|
|
281
|
+
"aggregate",
|
|
282
|
+
"sort",
|
|
283
|
+
"limit",
|
|
284
|
+
"exists_check",
|
|
285
|
+
"count_relationship",
|
|
286
|
+
]
|
|
287
|
+
description: str
|
|
288
|
+
entity: str | None = None
|
|
289
|
+
relationship: str | None = None
|
|
290
|
+
estimated_api_calls: int = 0
|
|
291
|
+
estimated_records: int | None = None
|
|
292
|
+
is_client_side: bool = False
|
|
293
|
+
warnings: list[str] = field(default_factory=list)
|
|
294
|
+
depends_on: list[int] = field(default_factory=list)
|
|
295
|
+
|
|
296
|
+
# Additional metadata for specific operations
|
|
297
|
+
filter_pushdown: bool = False
|
|
298
|
+
pushdown_filter: str | None = None
|
|
299
|
+
client_filter: WhereClause | None = None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass
|
|
303
|
+
class ExecutionPlan:
|
|
304
|
+
"""Complete execution plan for a query."""
|
|
305
|
+
|
|
306
|
+
query: Query
|
|
307
|
+
steps: list[PlanStep]
|
|
308
|
+
total_api_calls: int
|
|
309
|
+
estimated_records_fetched: int | None
|
|
310
|
+
estimated_memory_mb: float | None
|
|
311
|
+
warnings: list[str]
|
|
312
|
+
recommendations: list[str]
|
|
313
|
+
has_expensive_operations: bool
|
|
314
|
+
requires_full_scan: bool
|
|
315
|
+
version: str = "1.0"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass
|
|
319
|
+
class QueryResult:
|
|
320
|
+
"""Result of query execution."""
|
|
321
|
+
|
|
322
|
+
data: list[dict[str, Any]]
|
|
323
|
+
included: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
|
|
324
|
+
summary: ResultSummary | None = None # Standardized result summary
|
|
325
|
+
meta: dict[str, Any] = field(default_factory=dict) # Additional metadata (executionTime, etc.)
|
|
326
|
+
pagination: dict[str, Any] | None = None
|
|
327
|
+
rate_limit: Any | None = None # RateLimitSnapshot from client
|
|
328
|
+
warnings: list[str] = field(default_factory=list)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# Enable forward references
|
|
332
|
+
WhereClause.model_rebuild()
|
|
333
|
+
HavingClause.model_rebuild()
|