Skip to content

Commit 78fdccf

Browse files
committed
feat: Create Pydantic models for Search Pipeline Run API
1 parent 962d30e commit 78fdccf

4 files changed

Lines changed: 408 additions & 1 deletion

File tree

cloud_pipelines_backend/api_server_sql.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import typing
77
from typing import Annotated, Any, Final, Optional
88

9-
from fastapi import Query
9+
from fastapi import HTTPException, Query
1010
from pydantic import BaseModel
1111

1212
if typing.TYPE_CHECKING:
@@ -33,6 +33,7 @@ def _get_current_time() -> datetime.datetime:
3333
from . import backend_types_sql as bts
3434
from . import errors
3535
from .errors import ItemNotFoundError
36+
from .filter_query_models import FilterQuery
3637

3738
_PAGE_TOKEN_OFFSET_KEY: Final[str] = "offset"
3839
_PAGE_TOKEN_FILTER_KEY: Final[str] = "filter"
@@ -73,6 +74,7 @@ class ListPipelineJobsResponse:
7374

7475
class ListPipelineRunsParams(BaseModel):
7576
filter: str | None = None
77+
filter_query: str | None = None
7678
page_token: str | None = None
7779
include_pipeline_names: bool = False
7880
include_execution_stats: bool = False
@@ -181,6 +183,19 @@ def list(
181183
current_user: str | None = None,
182184
params: Annotated[ListPipelineRunsParams, Query()],
183185
) -> ListPipelineJobsResponse:
186+
if params.filter and params.filter_query:
187+
raise HTTPException(
188+
status_code=422,
189+
detail="Cannot use both 'filter' and 'filter_query'. Use one or the other.",
190+
)
191+
192+
if params.filter_query:
193+
FilterQuery.model_validate_json(params.filter_query)
194+
raise HTTPException(
195+
status_code=501,
196+
detail="filter_query is not yet implemented.",
197+
)
198+
184199
filter_value, offset = _resolve_filter_value(
185200
filter=params.filter,
186201
page_token=params.page_token,
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations
2+
3+
from typing import Annotated
4+
5+
from pydantic import AwareDatetime, BaseModel, Field, StringConstraints, model_validator
6+
7+
NonEmptyStr = Annotated[str, StringConstraints(min_length=1)]
8+
9+
10+
class _StrictBase(BaseModel):
11+
model_config = {"extra": "forbid"}
12+
13+
14+
# --- Leaf argument models ---
15+
16+
17+
class KeyExists(BaseModel):
18+
key: NonEmptyStr
19+
20+
21+
class ValueContains(BaseModel):
22+
key: NonEmptyStr
23+
value_substring: NonEmptyStr
24+
25+
26+
class ValueIn(BaseModel):
27+
key: NonEmptyStr
28+
values: list[NonEmptyStr] = Field(min_length=1)
29+
30+
31+
class ValueEquals(BaseModel):
32+
key: NonEmptyStr
33+
value: NonEmptyStr
34+
35+
36+
class TimeRange(BaseModel):
37+
"""AwareDatetime requires timezone info (e.g. "2024-01-01T00:00:00Z").
38+
Naive datetimes like "2024-01-01T00:00:00" are rejected, preventing
39+
ambiguous timestamps that could silently resolve to the wrong timezone."""
40+
41+
key: NonEmptyStr
42+
start_time: AwareDatetime
43+
end_time: AwareDatetime | None = None
44+
45+
46+
# --- Predicate wrapper models (one field each) ---
47+
48+
49+
class KeyExistsPredicate(_StrictBase):
50+
key_exists: KeyExists
51+
52+
53+
class ValueContainsPredicate(_StrictBase):
54+
value_contains: ValueContains
55+
56+
57+
class ValueInPredicate(_StrictBase):
58+
value_in: ValueIn
59+
60+
61+
class ValueEqualsPredicate(_StrictBase):
62+
value_equals: ValueEquals
63+
64+
65+
class TimeRangePredicate(_StrictBase):
66+
time_range: TimeRange
67+
68+
69+
LeafPredicate = (
70+
KeyExistsPredicate
71+
| ValueContainsPredicate
72+
| ValueInPredicate
73+
| ValueEqualsPredicate
74+
| TimeRangePredicate
75+
)
76+
77+
78+
class NotPredicate(_StrictBase):
79+
not_: LeafPredicate = Field(alias="not")
80+
81+
82+
class AndPredicate(_StrictBase):
83+
and_: list["Predicate"] = Field(alias="and", min_length=1)
84+
85+
86+
class OrPredicate(_StrictBase):
87+
or_: list["Predicate"] = Field(alias="or", min_length=1)
88+
89+
90+
Predicate = (
91+
KeyExistsPredicate
92+
| ValueContainsPredicate
93+
| ValueInPredicate
94+
| ValueEqualsPredicate
95+
| TimeRangePredicate
96+
| NotPredicate
97+
| AndPredicate
98+
| OrPredicate
99+
)
100+
101+
# Resolve forward reference to "Predicate" in recursive and/or models
102+
AndPredicate.model_rebuild()
103+
OrPredicate.model_rebuild()
104+
105+
106+
class FilterQuery(_StrictBase):
107+
"""Root: must be exactly one of {"and": [...]} or {"or": [...]}."""
108+
109+
and_: list[Predicate] | None = Field(None, alias="and", min_length=1)
110+
or_: list[Predicate] | None = Field(None, alias="or", min_length=1)
111+
112+
@model_validator(mode="after")
113+
def _exactly_one_root_operator(self) -> FilterQuery:
114+
has_and = self.and_ is not None
115+
has_or = self.or_ is not None
116+
if has_and == has_or:
117+
raise ValueError("FilterQuery root must have exactly one of 'and' or 'or'.")
118+
return self

tests/test_api_server_sql.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,3 +551,44 @@ def test_text_search_raises(self):
551551
filter_value="some_text_without_colon",
552552
current_user=None,
553553
)
554+
555+
556+
class TestFilterQueryApiWiring:
557+
def test_filter_query_returns_501(self, session_factory, service):
558+
from fastapi import HTTPException
559+
560+
valid_json = '{"and": [{"key_exists": {"key": "team"}}]}'
561+
with session_factory() as session:
562+
with pytest.raises(HTTPException) as exc_info:
563+
service.list(
564+
session=session,
565+
params=_default_params(filter_query=valid_json),
566+
)
567+
assert exc_info.value.status_code == 501
568+
assert "not yet implemented" in exc_info.value.detail.lower()
569+
570+
def test_filter_query_validates_before_501(self, session_factory, service):
571+
from pydantic import ValidationError
572+
573+
invalid_json = '{"bad_key": "not_valid"}'
574+
with session_factory() as session:
575+
with pytest.raises(ValidationError):
576+
service.list(
577+
session=session,
578+
params=_default_params(filter_query=invalid_json),
579+
)
580+
581+
def test_mutual_exclusivity_rejected(self, session_factory, service):
582+
from fastapi import HTTPException
583+
584+
with session_factory() as session:
585+
with pytest.raises(HTTPException) as exc_info:
586+
service.list(
587+
session=session,
588+
params=_default_params(
589+
filter="created_by:alice",
590+
filter_query='{"and": [{"key_exists": {"key": "team"}}]}',
591+
),
592+
)
593+
assert exc_info.value.status_code == 422
594+
assert "Cannot use both" in exc_info.value.detail

0 commit comments

Comments
 (0)