labtasker-server 2.0.0__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.
- labtasker_server/__init__.py +3 -0
- labtasker_server/__main__.py +3 -0
- labtasker_server/app.py +416 -0
- labtasker_server/cli.py +371 -0
- labtasker_server/config.py +42 -0
- labtasker_server/database.py +176 -0
- labtasker_server/errors.py +27 -0
- labtasker_server/filtering.py +528 -0
- labtasker_server/local.py +453 -0
- labtasker_server/logging.py +47 -0
- labtasker_server/middleware.py +73 -0
- labtasker_server/migrations/__init__.py +1 -0
- labtasker_server/migrations/env.py +20 -0
- labtasker_server/migrations/versions/0001_initial.py +135 -0
- labtasker_server/migrations/versions/__init__.py +1 -0
- labtasker_server/models.py +139 -0
- labtasker_server/pagination.py +126 -0
- labtasker_server/py.typed +1 -0
- labtasker_server/schemas.py +265 -0
- labtasker_server/services/__init__.py +1 -0
- labtasker_server/services/queues.py +66 -0
- labtasker_server/services/tasks.py +859 -0
- labtasker_server/validation.py +150 -0
- labtasker_server-2.0.0.dist-info/METADATA +16 -0
- labtasker_server-2.0.0.dist-info/RECORD +28 -0
- labtasker_server-2.0.0.dist-info/WHEEL +4 -0
- labtasker_server-2.0.0.dist-info/entry_points.txt +2 -0
- labtasker_server-2.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any, Literal, TypeAlias, cast
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import and_, false, func, not_, or_, select, true
|
|
11
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
12
|
+
|
|
13
|
+
from labtasker_server.errors import DomainError, invalid
|
|
14
|
+
from labtasker_server.models import TaskRouteRow, TaskRow
|
|
15
|
+
from labtasker_server.validation import INT64_MAX, INT64_MIN, MAX_FILTER_BYTES
|
|
16
|
+
|
|
17
|
+
Scalar: TypeAlias = bool | int | float | str | None
|
|
18
|
+
CompareOperator: TypeAlias = Literal["==", "!=", "<", "<=", ">", ">="]
|
|
19
|
+
MembershipOperator: TypeAlias = Literal["in", "not in"]
|
|
20
|
+
PATH_SEGMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
21
|
+
RFC3339_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$")
|
|
22
|
+
|
|
23
|
+
BUILTIN_TYPES: dict[str, tuple[str, bool]] = {
|
|
24
|
+
"id": ("string", False),
|
|
25
|
+
"status": ("status", False),
|
|
26
|
+
"name": ("string", True),
|
|
27
|
+
"priority": ("number", False),
|
|
28
|
+
"attempt": ("number", False),
|
|
29
|
+
"max_attempts": ("number", False),
|
|
30
|
+
"last_route": ("string", True),
|
|
31
|
+
"created_at": ("timestamp", False),
|
|
32
|
+
"updated_at": ("timestamp", False),
|
|
33
|
+
"started_at": ("timestamp", True),
|
|
34
|
+
"finished_at": ("timestamp", True),
|
|
35
|
+
}
|
|
36
|
+
LAST_ERROR_TYPES: dict[str, tuple[str, bool, str]] = {
|
|
37
|
+
"type": ("string", False, "type"),
|
|
38
|
+
"message": ("string", False, "message"),
|
|
39
|
+
"traceback": ("string", True, "traceback"),
|
|
40
|
+
"occurred_at": ("timestamp", False, "occurred_at_us"),
|
|
41
|
+
"attempt": ("number", False, "attempt"),
|
|
42
|
+
"run_id": ("string", False, "run_id"),
|
|
43
|
+
}
|
|
44
|
+
TASK_STATUSES = {"pending", "running", "succeeded", "failed", "cancelled"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class FilterPath:
|
|
49
|
+
root: str
|
|
50
|
+
segments: tuple[str, ...] = ()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class Comparison:
|
|
55
|
+
path: FilterPath
|
|
56
|
+
operator: CompareOperator
|
|
57
|
+
value: Scalar
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Membership:
|
|
62
|
+
path: FilterPath
|
|
63
|
+
operator: MembershipOperator
|
|
64
|
+
values: tuple[Scalar, ...]
|
|
65
|
+
mode: Literal["candidate_set", "array_contains"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class Presence:
|
|
70
|
+
path: FilterPath
|
|
71
|
+
exists: bool
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class BooleanExpression:
|
|
76
|
+
operator: Literal["and", "or"]
|
|
77
|
+
children: tuple[FilterNode, ...]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
FilterNode: TypeAlias = Comparison | Membership | Presence | BooleanExpression
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class RuntimePath:
|
|
85
|
+
kind: Literal["fixed", "dynamic", "routes"]
|
|
86
|
+
value: Any
|
|
87
|
+
json_type: Any
|
|
88
|
+
declared_type: str | None
|
|
89
|
+
nullable: bool
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_filter(expression: str) -> FilterNode:
|
|
93
|
+
if any(0xD800 <= ord(character) <= 0xDFFF for character in expression):
|
|
94
|
+
raise invalid("invalid_filter", "Filter contains a lone Unicode surrogate.")
|
|
95
|
+
if len(expression.encode("utf-8")) > MAX_FILTER_BYTES:
|
|
96
|
+
raise invalid(
|
|
97
|
+
"filter_too_large",
|
|
98
|
+
"Filter exceeds the 8192-byte limit.",
|
|
99
|
+
max_bytes=MAX_FILTER_BYTES,
|
|
100
|
+
)
|
|
101
|
+
if not expression.strip():
|
|
102
|
+
raise invalid("invalid_filter", "Filter must not be empty.")
|
|
103
|
+
try:
|
|
104
|
+
parsed = ast.parse(expression, mode="eval")
|
|
105
|
+
return _parse_expression(parsed.body)
|
|
106
|
+
except (SyntaxError, ValueError, RecursionError) as error:
|
|
107
|
+
details: dict[str, object] = {}
|
|
108
|
+
if isinstance(error, SyntaxError) and error.offset is not None:
|
|
109
|
+
details["column"] = error.offset
|
|
110
|
+
raise invalid("invalid_filter", "Filter syntax is invalid.", **details) from error
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def compile_filter(expression: str) -> ColumnElement[bool]:
|
|
114
|
+
try:
|
|
115
|
+
return compile_filter_node(parse_filter(expression))
|
|
116
|
+
except RecursionError as error:
|
|
117
|
+
raise invalid("invalid_filter", "Filter expression is too deeply nested.") from error
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def compile_filter_node(node: FilterNode) -> ColumnElement[bool]:
|
|
121
|
+
if isinstance(node, BooleanExpression):
|
|
122
|
+
compiled = [compile_filter_node(child) for child in node.children]
|
|
123
|
+
return and_(*compiled) if node.operator == "and" else or_(*compiled)
|
|
124
|
+
if isinstance(node, Presence):
|
|
125
|
+
runtime = _runtime_path(node.path)
|
|
126
|
+
if runtime.kind in {"fixed", "routes"}:
|
|
127
|
+
return true() if node.exists else false()
|
|
128
|
+
present = runtime.json_type.is_not(None)
|
|
129
|
+
return cast(ColumnElement[bool], present if node.exists else not_(present))
|
|
130
|
+
if isinstance(node, Comparison):
|
|
131
|
+
return _compile_comparison(node)
|
|
132
|
+
if isinstance(node, Membership):
|
|
133
|
+
return _compile_membership(node)
|
|
134
|
+
raise AssertionError(f"Unknown filter node: {node!r}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_expression(node: ast.expr) -> FilterNode:
|
|
138
|
+
if isinstance(node, ast.BoolOp):
|
|
139
|
+
if isinstance(node.op, ast.And):
|
|
140
|
+
bool_operator: Literal["and", "or"] = "and"
|
|
141
|
+
elif isinstance(node.op, ast.Or):
|
|
142
|
+
bool_operator = "or"
|
|
143
|
+
else:
|
|
144
|
+
raise _filter_error(node, "Only 'and' and 'or' Boolean operators are supported.")
|
|
145
|
+
return BooleanExpression(
|
|
146
|
+
bool_operator,
|
|
147
|
+
tuple(_parse_expression(value) for value in node.values),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if isinstance(node, ast.Call):
|
|
151
|
+
if (
|
|
152
|
+
not isinstance(node.func, ast.Name)
|
|
153
|
+
or node.func.id not in {"exists", "missing"}
|
|
154
|
+
or len(node.args) != 1
|
|
155
|
+
or node.keywords
|
|
156
|
+
):
|
|
157
|
+
raise _filter_error(node, "Only exists(path) and missing(path) are supported.")
|
|
158
|
+
return Presence(_parse_path(node.args[0]), exists=node.func.id == "exists")
|
|
159
|
+
|
|
160
|
+
if not isinstance(node, ast.Compare) or len(node.ops) != 1 or len(node.comparators) != 1:
|
|
161
|
+
raise _filter_error(node, "A filter predicate must be one comparison or membership test.")
|
|
162
|
+
|
|
163
|
+
left = node.left
|
|
164
|
+
right = node.comparators[0]
|
|
165
|
+
operator_node = node.ops[0]
|
|
166
|
+
if isinstance(operator_node, (ast.In, ast.NotIn)):
|
|
167
|
+
membership_operator: MembershipOperator = (
|
|
168
|
+
"not in" if isinstance(operator_node, ast.NotIn) else "in"
|
|
169
|
+
)
|
|
170
|
+
left_path = _try_parse_path(left)
|
|
171
|
+
right_path = _try_parse_path(right)
|
|
172
|
+
if left_path is not None and isinstance(right, ast.List):
|
|
173
|
+
return Membership(
|
|
174
|
+
left_path,
|
|
175
|
+
membership_operator,
|
|
176
|
+
tuple(_parse_scalar(element) for element in right.elts),
|
|
177
|
+
"candidate_set",
|
|
178
|
+
)
|
|
179
|
+
if right_path is not None:
|
|
180
|
+
return Membership(
|
|
181
|
+
right_path,
|
|
182
|
+
membership_operator,
|
|
183
|
+
(_parse_scalar(left),),
|
|
184
|
+
"array_contains",
|
|
185
|
+
)
|
|
186
|
+
raise _filter_error(
|
|
187
|
+
node,
|
|
188
|
+
"Membership must be 'path in [values]' or 'value in path'.",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
comparison_operator = _comparison_operator(operator_node, node)
|
|
192
|
+
left_path = _try_parse_path(left)
|
|
193
|
+
right_path = _try_parse_path(right)
|
|
194
|
+
if left_path is not None and right_path is None:
|
|
195
|
+
return Comparison(left_path, comparison_operator, _parse_scalar(right))
|
|
196
|
+
if right_path is not None and left_path is None:
|
|
197
|
+
return Comparison(
|
|
198
|
+
right_path,
|
|
199
|
+
_reverse_operator(comparison_operator),
|
|
200
|
+
_parse_scalar(left),
|
|
201
|
+
)
|
|
202
|
+
raise _filter_error(node, "A comparison must contain exactly one path and one scalar literal.")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _try_parse_path(node: ast.expr) -> FilterPath | None:
|
|
206
|
+
if not isinstance(node, (ast.Name, ast.Attribute)):
|
|
207
|
+
return None
|
|
208
|
+
return _parse_path(node)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _parse_path(node: ast.expr) -> FilterPath:
|
|
212
|
+
segments: list[str] = []
|
|
213
|
+
current = node
|
|
214
|
+
while isinstance(current, ast.Attribute):
|
|
215
|
+
segments.append(current.attr)
|
|
216
|
+
current = current.value
|
|
217
|
+
if not isinstance(current, ast.Name):
|
|
218
|
+
raise _filter_error(node, "Filter paths use dot-separated names only.")
|
|
219
|
+
root = current.id
|
|
220
|
+
segments.reverse()
|
|
221
|
+
|
|
222
|
+
if root in BUILTIN_TYPES or root == "routes":
|
|
223
|
+
if segments:
|
|
224
|
+
raise _filter_error(node, f"'{root}' does not have nested fields.")
|
|
225
|
+
return FilterPath(root)
|
|
226
|
+
if root not in {"args", "metadata", "result", "last_error"} or not segments:
|
|
227
|
+
raise _filter_error(node, f"Unsupported filter path '{_display_path(root, segments)}'.")
|
|
228
|
+
for segment in segments:
|
|
229
|
+
if not PATH_SEGMENT_RE.fullmatch(segment):
|
|
230
|
+
raise _filter_error(
|
|
231
|
+
node,
|
|
232
|
+
"Path segments must match [A-Za-z_][A-Za-z0-9_]*.",
|
|
233
|
+
)
|
|
234
|
+
if root == "last_error" and (len(segments) != 1 or segments[0] not in LAST_ERROR_TYPES):
|
|
235
|
+
raise _filter_error(node, f"Unsupported filter path '{_display_path(root, segments)}'.")
|
|
236
|
+
return FilterPath(root, tuple(segments))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _parse_scalar(node: ast.expr) -> Scalar:
|
|
240
|
+
sign = 1
|
|
241
|
+
literal = node
|
|
242
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
|
243
|
+
sign = -1
|
|
244
|
+
literal = node.operand
|
|
245
|
+
if not isinstance(literal, ast.Constant):
|
|
246
|
+
raise _filter_error(node, "Filter values must be scalar JSON literals.")
|
|
247
|
+
value = literal.value
|
|
248
|
+
if value is None or isinstance(value, (bool, str)):
|
|
249
|
+
if sign == -1:
|
|
250
|
+
raise _filter_error(node, "Only numeric literals may have a minus sign.")
|
|
251
|
+
if isinstance(value, str) and any(
|
|
252
|
+
0xD800 <= ord(character) <= 0xDFFF for character in value
|
|
253
|
+
):
|
|
254
|
+
raise _filter_error(node, "String literals must contain Unicode scalar values.")
|
|
255
|
+
return value
|
|
256
|
+
if isinstance(value, int):
|
|
257
|
+
value *= sign
|
|
258
|
+
if not INT64_MIN <= value <= INT64_MAX:
|
|
259
|
+
raise _filter_error(node, "Integer literal is outside the signed 64-bit range.")
|
|
260
|
+
return value
|
|
261
|
+
if isinstance(value, float):
|
|
262
|
+
value *= sign
|
|
263
|
+
if not math.isfinite(value):
|
|
264
|
+
raise _filter_error(node, "Number literal must be finite.")
|
|
265
|
+
return value
|
|
266
|
+
raise _filter_error(node, "Filter values must be scalar JSON literals.")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _comparison_operator(node: ast.cmpop, parent: ast.AST) -> CompareOperator:
|
|
270
|
+
operators: list[tuple[type[ast.cmpop], CompareOperator]] = [
|
|
271
|
+
(ast.Eq, "=="),
|
|
272
|
+
(ast.NotEq, "!="),
|
|
273
|
+
(ast.Lt, "<"),
|
|
274
|
+
(ast.LtE, "<="),
|
|
275
|
+
(ast.Gt, ">"),
|
|
276
|
+
(ast.GtE, ">="),
|
|
277
|
+
]
|
|
278
|
+
for node_type, name in operators:
|
|
279
|
+
if isinstance(node, node_type):
|
|
280
|
+
return name
|
|
281
|
+
raise _filter_error(parent, "Unsupported comparison operator.")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _reverse_operator(operator: CompareOperator) -> CompareOperator:
|
|
285
|
+
reversed_operators: dict[CompareOperator, CompareOperator] = {
|
|
286
|
+
"==": "==",
|
|
287
|
+
"!=": "!=",
|
|
288
|
+
"<": ">",
|
|
289
|
+
"<=": ">=",
|
|
290
|
+
">": "<",
|
|
291
|
+
">=": "<=",
|
|
292
|
+
}
|
|
293
|
+
return reversed_operators[operator]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _runtime_path(path: FilterPath) -> RuntimePath:
|
|
297
|
+
if path.root == "routes":
|
|
298
|
+
return RuntimePath("routes", None, None, "array", False)
|
|
299
|
+
if path.root in BUILTIN_TYPES:
|
|
300
|
+
declared_type, nullable = BUILTIN_TYPES[path.root]
|
|
301
|
+
columns = {
|
|
302
|
+
"id": TaskRow.task_id,
|
|
303
|
+
"status": TaskRow.status,
|
|
304
|
+
"name": TaskRow.name,
|
|
305
|
+
"priority": TaskRow.priority,
|
|
306
|
+
"attempt": TaskRow.attempt,
|
|
307
|
+
"max_attempts": TaskRow.max_attempts,
|
|
308
|
+
"last_route": TaskRow.last_route,
|
|
309
|
+
"created_at": TaskRow.created_at_us,
|
|
310
|
+
"updated_at": TaskRow.updated_at_us,
|
|
311
|
+
"started_at": TaskRow.started_at_us,
|
|
312
|
+
"finished_at": TaskRow.finished_at_us,
|
|
313
|
+
}
|
|
314
|
+
return RuntimePath("fixed", columns[path.root], None, declared_type, nullable)
|
|
315
|
+
|
|
316
|
+
json_column = {
|
|
317
|
+
"args": TaskRow.args_json,
|
|
318
|
+
"metadata": TaskRow.metadata_json,
|
|
319
|
+
"result": TaskRow.result_json,
|
|
320
|
+
"last_error": TaskRow.last_error_json,
|
|
321
|
+
}[path.root]
|
|
322
|
+
segments = list(path.segments)
|
|
323
|
+
dynamic_declared_type: str | None = None
|
|
324
|
+
nullable = True
|
|
325
|
+
if path.root == "last_error":
|
|
326
|
+
dynamic_declared_type, nullable, stored_name = LAST_ERROR_TYPES[segments[0]]
|
|
327
|
+
segments[0] = stored_name
|
|
328
|
+
json_path = "$" + "".join(f'."{segment}"' for segment in segments)
|
|
329
|
+
return RuntimePath(
|
|
330
|
+
"dynamic",
|
|
331
|
+
func.json_extract(json_column, json_path),
|
|
332
|
+
func.json_type(json_column, json_path),
|
|
333
|
+
dynamic_declared_type,
|
|
334
|
+
nullable,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _compile_comparison(node: Comparison) -> ColumnElement[bool]:
|
|
339
|
+
runtime = _runtime_path(node.path)
|
|
340
|
+
if runtime.kind == "routes":
|
|
341
|
+
raise _invalid_filter("Routes support membership tests only.")
|
|
342
|
+
if runtime.kind == "fixed" or runtime.declared_type is not None:
|
|
343
|
+
value = _normalize_declared_literal(runtime, node.value)
|
|
344
|
+
return _declared_comparison(runtime, node.operator, value)
|
|
345
|
+
return _dynamic_comparison(runtime, node.operator, node.value)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _normalize_declared_literal(runtime: RuntimePath, value: Scalar) -> Scalar | int:
|
|
349
|
+
declared_type = runtime.declared_type
|
|
350
|
+
if value is None:
|
|
351
|
+
if runtime.nullable:
|
|
352
|
+
return None
|
|
353
|
+
raise _invalid_filter("A non-nullable field cannot be compared with None.")
|
|
354
|
+
if declared_type == "number":
|
|
355
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
356
|
+
raise _invalid_filter("This field requires a numeric literal.")
|
|
357
|
+
return value
|
|
358
|
+
if declared_type in {"string", "status"}:
|
|
359
|
+
if not isinstance(value, str):
|
|
360
|
+
raise _invalid_filter("This field requires a string literal.")
|
|
361
|
+
if declared_type == "status" and value not in TASK_STATUSES:
|
|
362
|
+
raise _invalid_filter("Status literal is not a valid Task status.")
|
|
363
|
+
return value
|
|
364
|
+
if declared_type == "timestamp":
|
|
365
|
+
if not isinstance(value, str):
|
|
366
|
+
raise _invalid_filter("Timestamp fields require an RFC 3339 string literal.")
|
|
367
|
+
return _timestamp_us(value)
|
|
368
|
+
raise AssertionError(f"Unknown declared filter type: {declared_type}")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _declared_comparison(
|
|
372
|
+
runtime: RuntimePath,
|
|
373
|
+
operator: CompareOperator,
|
|
374
|
+
value: Scalar | int,
|
|
375
|
+
) -> ColumnElement[bool]:
|
|
376
|
+
column = runtime.value
|
|
377
|
+
if runtime.kind == "dynamic":
|
|
378
|
+
if operator in {"==", "!="}:
|
|
379
|
+
equal = _json_equal(column, runtime.json_type, value)
|
|
380
|
+
if operator == "==":
|
|
381
|
+
return equal
|
|
382
|
+
return and_(runtime.json_type.is_not(None), not_(equal))
|
|
383
|
+
if value is None:
|
|
384
|
+
raise _invalid_filter("None supports only equality and inequality comparisons.")
|
|
385
|
+
expected_types = (
|
|
386
|
+
["integer", "real"] if runtime.declared_type in {"number", "timestamp"} else ["text"]
|
|
387
|
+
)
|
|
388
|
+
if operator == "<":
|
|
389
|
+
comparison = column < value
|
|
390
|
+
elif operator == "<=":
|
|
391
|
+
comparison = column <= value
|
|
392
|
+
elif operator == ">":
|
|
393
|
+
comparison = column > value
|
|
394
|
+
else:
|
|
395
|
+
comparison = column >= value
|
|
396
|
+
return and_(runtime.json_type.in_(expected_types), comparison)
|
|
397
|
+
|
|
398
|
+
if value is None:
|
|
399
|
+
if operator == "==":
|
|
400
|
+
return cast(ColumnElement[bool], column.is_(None))
|
|
401
|
+
if operator == "!=":
|
|
402
|
+
return cast(ColumnElement[bool], column.is_not(None))
|
|
403
|
+
raise _invalid_filter("None supports only equality and inequality comparisons.")
|
|
404
|
+
if operator == "==":
|
|
405
|
+
comparison = column == value
|
|
406
|
+
elif operator == "!=":
|
|
407
|
+
comparison = or_(column.is_(None), column != value) if runtime.nullable else column != value
|
|
408
|
+
elif operator == "<":
|
|
409
|
+
comparison = column < value
|
|
410
|
+
elif operator == "<=":
|
|
411
|
+
comparison = column <= value
|
|
412
|
+
elif operator == ">":
|
|
413
|
+
comparison = column > value
|
|
414
|
+
else:
|
|
415
|
+
comparison = column >= value
|
|
416
|
+
return cast(ColumnElement[bool], comparison)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _dynamic_comparison(
|
|
420
|
+
runtime: RuntimePath,
|
|
421
|
+
operator: CompareOperator,
|
|
422
|
+
value: Scalar,
|
|
423
|
+
) -> ColumnElement[bool]:
|
|
424
|
+
if operator in {"==", "!="}:
|
|
425
|
+
equal = _json_equal(runtime.value, runtime.json_type, value)
|
|
426
|
+
if operator == "==":
|
|
427
|
+
return equal
|
|
428
|
+
return and_(runtime.json_type.is_not(None), not_(equal))
|
|
429
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
430
|
+
raise _invalid_filter("Ordering a dynamic JSON path requires a numeric literal.")
|
|
431
|
+
numeric = runtime.json_type.in_(["integer", "real"])
|
|
432
|
+
comparisons = {
|
|
433
|
+
"<": runtime.value < value,
|
|
434
|
+
"<=": runtime.value <= value,
|
|
435
|
+
">": runtime.value > value,
|
|
436
|
+
">=": runtime.value >= value,
|
|
437
|
+
}
|
|
438
|
+
return and_(numeric, comparisons[operator])
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _compile_membership(node: Membership) -> ColumnElement[bool]:
|
|
442
|
+
runtime = _runtime_path(node.path)
|
|
443
|
+
if node.mode == "candidate_set":
|
|
444
|
+
if runtime.kind == "routes":
|
|
445
|
+
raise _invalid_filter("Use a scalar literal on the left to test route membership.")
|
|
446
|
+
if runtime.kind == "fixed" or runtime.declared_type is not None:
|
|
447
|
+
normalized = [_normalize_declared_literal(runtime, value) for value in node.values]
|
|
448
|
+
matches = (
|
|
449
|
+
or_(*[_declared_comparison(runtime, "==", value) for value in normalized])
|
|
450
|
+
if normalized
|
|
451
|
+
else false()
|
|
452
|
+
)
|
|
453
|
+
present = true() if runtime.kind == "fixed" else runtime.json_type.is_not(None)
|
|
454
|
+
else:
|
|
455
|
+
comparisons = [
|
|
456
|
+
_json_equal(runtime.value, runtime.json_type, value) for value in node.values
|
|
457
|
+
]
|
|
458
|
+
matches = or_(*comparisons) if comparisons else false()
|
|
459
|
+
present = runtime.json_type.in_(["null", "true", "false", "integer", "real", "text"])
|
|
460
|
+
return and_(present, not_(matches) if node.operator == "not in" else matches)
|
|
461
|
+
|
|
462
|
+
value = node.values[0]
|
|
463
|
+
if runtime.kind == "fixed":
|
|
464
|
+
raise _invalid_filter("The right side of array containment must be an array-valued path.")
|
|
465
|
+
if runtime.kind == "routes":
|
|
466
|
+
if not isinstance(value, str):
|
|
467
|
+
raise _invalid_filter("Route membership requires a string literal.")
|
|
468
|
+
match = _route_exists(value)
|
|
469
|
+
return not_(match) if node.operator == "not in" else match
|
|
470
|
+
|
|
471
|
+
each = func.json_each(runtime.value).table_valued("key", "value", "type").alias()
|
|
472
|
+
match = (
|
|
473
|
+
select(1).select_from(each).where(_json_equal(each.c.value, each.c.type, value)).exists()
|
|
474
|
+
)
|
|
475
|
+
is_array = runtime.json_type == "array"
|
|
476
|
+
return and_(is_array, not_(match) if node.operator == "not in" else match)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _route_exists(route: str) -> ColumnElement[bool]:
|
|
480
|
+
return (
|
|
481
|
+
select(1)
|
|
482
|
+
.select_from(TaskRouteRow)
|
|
483
|
+
.where(
|
|
484
|
+
TaskRouteRow.queue_name == TaskRow.queue_name,
|
|
485
|
+
TaskRouteRow.task_id == TaskRow.task_id,
|
|
486
|
+
TaskRouteRow.route == route,
|
|
487
|
+
)
|
|
488
|
+
.exists()
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _json_equal(value_expression: Any, type_expression: Any, value: Scalar) -> ColumnElement[bool]:
|
|
493
|
+
if value is None:
|
|
494
|
+
return cast(ColumnElement[bool], type_expression == "null")
|
|
495
|
+
if isinstance(value, bool):
|
|
496
|
+
return cast(ColumnElement[bool], type_expression == ("true" if value else "false"))
|
|
497
|
+
if isinstance(value, (int, float)):
|
|
498
|
+
return and_(type_expression.in_(["integer", "real"]), value_expression == value)
|
|
499
|
+
return and_(type_expression == "text", value_expression == value)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _timestamp_us(value: str) -> int:
|
|
503
|
+
if not RFC3339_RE.fullmatch(value):
|
|
504
|
+
raise _invalid_filter("Timestamp literal must be a strict RFC 3339 string.")
|
|
505
|
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
506
|
+
try:
|
|
507
|
+
parsed = datetime.fromisoformat(normalized)
|
|
508
|
+
except ValueError as error:
|
|
509
|
+
raise _invalid_filter("Timestamp literal must be a valid RFC 3339 time.") from error
|
|
510
|
+
if parsed.utcoffset() is None:
|
|
511
|
+
raise _invalid_filter("Timestamp literal must include a UTC offset.")
|
|
512
|
+
delta = parsed.astimezone(UTC) - datetime(1970, 1, 1, tzinfo=UTC)
|
|
513
|
+
return (delta.days * 86_400 + delta.seconds) * 1_000_000 + delta.microseconds
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _filter_error(node: ast.AST, message: str) -> DomainError:
|
|
517
|
+
details: dict[str, object] = {}
|
|
518
|
+
if hasattr(node, "col_offset"):
|
|
519
|
+
details["column"] = node.col_offset + 1
|
|
520
|
+
return invalid("invalid_filter", message, **details)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _invalid_filter(message: str) -> DomainError:
|
|
524
|
+
return invalid("invalid_filter", message)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _display_path(root: str, segments: list[str]) -> str:
|
|
528
|
+
return ".".join([root, *segments])
|