Coverage for /home/airflow/.local/lib/python3.12/site-packages/airflow/api_fastapi/common/parameters.py: 82%

687 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 14:22 +0000

1# Licensed to the Apache Software Foundation (ASF) under one 

2# or more contributor license agreements. See the NOTICE file 

3# distributed with this work for additional information 

4# regarding copyright ownership. The ASF licenses this file 

5# to you under the Apache License, Version 2.0 (the 

6# "License"); you may not use this file except in compliance 

7# with the License. You may obtain a copy of the License at 

8# 

9# http://www.apache.org/licenses/LICENSE-2.0 

10# 

11# Unless required by applicable law or agreed to in writing, 

12# software distributed under the License is distributed on an 

13# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 

14# KIND, either express or implied. See the License for the 

15# specific language governing permissions and limitations 

16# under the License. 

17 

18from __future__ import annotations 

19 

20from abc import ABC, abstractmethod 

21from collections.abc import Callable, Iterable, Sequence 

22from datetime import datetime 

23from enum import Enum 

24from typing import ( 

25 TYPE_CHECKING, 

26 Annotated, 

27 Any, 

28 Generic, 

29 Literal, 

30 TypeVar, 

31 cast, 

32 overload, 

33) 

34 

35from fastapi import Depends, HTTPException, Query, status 

36from pendulum.parsing.exceptions import ParserError 

37from pydantic import AfterValidator, BaseModel, NonNegativeInt 

38from sqlalchemy import Column, String, and_, func, not_, or_, select as sql_select, true as sql_true 

39from sqlalchemy.ext.compiler import compiles 

40from sqlalchemy.inspection import inspect 

41from sqlalchemy.orm import aliased 

42from sqlalchemy.sql.functions import FunctionElement 

43 

44from airflow._shared.timezones import timezone 

45from airflow.api_fastapi.compat import HTTP_422_UNPROCESSABLE_CONTENT 

46from airflow.api_fastapi.core_api.base import OrmClause 

47from airflow.api_fastapi.core_api.security import GetUserDep 

48from airflow.configuration import conf 

49from airflow.models import Base 

50from airflow.models.asset import ( 

51 AssetAliasModel, 

52 AssetEvent, 

53 AssetModel, 

54 AssetPartitionDagRun, 

55 DagScheduleAssetReference, 

56 TaskInletAssetReference, 

57 TaskOutletAssetReference, 

58 association_table, 

59) 

60from airflow.models.connection import Connection 

61from airflow.models.dag import DagModel, DagTag 

62from airflow.models.dag_favorite import DagFavorite 

63from airflow.models.dag_version import DagVersion 

64from airflow.models.dagrun import DagRun 

65from airflow.models.errors import ParseImportError 

66from airflow.models.hitl import HITLDetail 

67from airflow.models.pool import Pool 

68from airflow.models.taskinstance import TaskInstance 

69from airflow.models.variable import Variable 

70from airflow.models.xcom import XComModel 

71from airflow.typing_compat import Self 

72from airflow.utils.state import DagRunState, TaskInstanceState 

73from airflow.utils.types import DagRunType 

74 

75if TYPE_CHECKING: 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 from sqlalchemy.orm.attributes import InstrumentedAttribute 

77 from sqlalchemy.sql import ColumnElement, Select 

78 from sqlalchemy.sql.compiler import SQLCompiler 

79 

80 from airflow.serialization.definitions.dag import SerializedDAG 

81 

82T = TypeVar("T") 

83 

84_FALLBACK_PAGE_LIMIT: int = conf.getint("api", "fallback_page_limit") 

85 

86 

87class _MySQLCollate(FunctionElement): 

88 """ 

89 Wraps a SQL expression so that on MySQL it is emitted with an explicit ``COLLATE`` clause. 

90 

91 On every other dialect the expression is passed through unchanged. 

92 

93 This is needed when a computed expression (e.g. a ``CASE … END`` that mixes 

94 a stored ``VARCHAR`` column with a ``CAST(integer AS CHAR)``) ends up with 

95 MySQL coercibility ``NONE`` because the two branches carry different implicit 

96 collations. Comparing such an expression with a bound parameter fails with 

97 "Illegal mix of collations". Wrapping the expression in an explicit 

98 ``COLLATE`` gives it ``EXPLICIT`` coercibility, which MySQL accepts in all 

99 comparison operators. 

100 """ 

101 

102 type = String() 

103 inherit_cache = True 

104 

105 def __init__(self, expr: ColumnElement[Any], collation: str) -> None: 

106 super().__init__(expr) 

107 self.collation = collation 

108 

109 

110@compiles(_MySQLCollate) 

111def _compile_mysql_collate_default(element: _MySQLCollate, compiler: SQLCompiler, **kw: Any) -> str: 

112 """Non-MySQL: render the inner expression without any COLLATE clause.""" 

113 (expr,) = element.clauses 

114 return compiler.process(expr, **kw) 

115 

116 

117@compiles(_MySQLCollate, "mysql") 

118def _compile_mysql_collate_mysql(element: _MySQLCollate, compiler: SQLCompiler, **kw: Any) -> str: 

119 """MySQL: wrap the inner expression with the requested COLLATE clause.""" 

120 (expr,) = element.clauses 

121 inner = compiler.process(expr, **kw) 

122 return f"({inner}) COLLATE {element.collation}" 

123 

124 

125class BaseParam(OrmClause[T], ABC): 

126 """Base class for path or query parameters with ORM transformation.""" 

127 

128 def __init__(self, value: T | None = None, skip_none: bool = True) -> None: 

129 super().__init__(value) 

130 self.attribute: ColumnElement | InstrumentedAttribute | None = None 

131 self.skip_none = skip_none 

132 

133 def set_value(self, value: T | None) -> Self: 

134 self.value = value 

135 return self 

136 

137 @classmethod 

138 @abstractmethod 

139 def depends(cls, *args: Any, **kwargs: Any) -> Self: 

140 pass 

141 

142 

143class LimitFilter(BaseParam[NonNegativeInt]): 

144 """Filter on the limit.""" 

145 

146 def to_orm(self, select: Select) -> Select: 

147 if self.value is None and self.skip_none: 147 ↛ 148line 147 didn't jump to line 148 because the condition on line 147 was never true

148 return select 

149 

150 return select.limit(self.value) 

151 

152 @classmethod 

153 def depends(cls, limit: NonNegativeInt = _FALLBACK_PAGE_LIMIT) -> LimitFilter: 

154 return cls().set_value(min(limit, conf.getint("api", "maximum_page_limit"))) 

155 

156 

157class OffsetFilter(BaseParam[NonNegativeInt]): 

158 """Filter on offset.""" 

159 

160 def to_orm(self, select: Select) -> Select: 

161 if self.value is None and self.skip_none: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 return select 

163 return select.offset(self.value) 

164 

165 @classmethod 

166 def depends( 

167 cls, 

168 offset: NonNegativeInt = 0, 

169 ) -> OffsetFilter: 

170 return cls().set_value(offset) 

171 

172 

173class _FavoriteFilter(BaseParam[bool]): 

174 """Filter Dags by favorite status.""" 

175 

176 def __init__(self, user_id: str, value: T | None = None, skip_none: bool = True) -> None: 

177 super().__init__(skip_none=skip_none) 

178 self.user_id = user_id 

179 

180 def to_orm(self, select_stmt: Select) -> Select: 

181 if self.value is None and self.skip_none: 

182 return select_stmt 

183 

184 if self.value: 

185 select_stmt = select_stmt.join(DagFavorite, DagFavorite.dag_id == DagModel.dag_id).where( 

186 DagFavorite.user_id == self.user_id 

187 ) 

188 else: 

189 select_stmt = select_stmt.where( 

190 not_( 

191 sql_select(DagFavorite) 

192 .where(and_(DagFavorite.dag_id == DagModel.dag_id, DagFavorite.user_id == self.user_id)) 

193 .exists() 

194 ) 

195 ) 

196 

197 return select_stmt 

198 

199 @classmethod 

200 def depends(cls, user: GetUserDep, is_favorite: bool | None = Query(None)) -> _FavoriteFilter: 

201 instance = cls(user_id=str(user.get_id())).set_value(is_favorite) 

202 return instance 

203 

204 

205class _ExcludeStaleFilter(BaseParam[bool]): 

206 """Filter on is_stale.""" 

207 

208 def to_orm(self, select: Select) -> Select: 

209 if self.value and self.skip_none: 

210 return select.where(DagModel.is_stale != self.value) 

211 return select 

212 

213 @classmethod 

214 def depends(cls, exclude_stale: bool = True) -> _ExcludeStaleFilter: 

215 return cls().set_value(exclude_stale) 

216 

217 

218class _PrefixPatternParam(BaseParam[str], ABC): 

219 """ 

220 Shared prefix pattern: pipe ``|`` for OR, ``~`` → empty (match all), Unicode prefix range. 

221 

222 .. note:: 

223 Trailing non-alphanumeric characters in a search term are stripped before the range 

224 is computed. A range scan with a punctuation-terminated upper bound is unsafe under 

225 PostgreSQL's default locale-aware collation (``en_US.utf8`` sorts punctuation in a 

226 way that breaks the range), and additionally stopping the range at an alphanumeric 

227 character keeps the upper bound alphanumeric too, so the predicate stays usable by 

228 default btree indexes. A user who asks for prefix ``"test_"`` gets matches starting 

229 with ``"test"`` — a small over-match trade-off made explicit in the public 

230 ``*_prefix_pattern`` query-param description. 

231 """ 

232 

233 pipe_as_or: bool = True 

234 

235 @staticmethod 

236 def _prefix_range_upper(term: str) -> str | None: 

237 """ 

238 Compute the exclusive upper bound for a prefix range scan. 

239 

240 Returns ``None`` if the term has no alphanumeric characters. Trailing non-alphanumeric 

241 characters are dropped before bumping the last character so the resulting upper bound 

242 is itself alphanumeric and behaves predictably under locale-aware collations. If 

243 incrementing would land outside the alphanumeric range (e.g. ``'z' → '{'``), we drop 

244 that character and retry. 

245 """ 

246 while term and not term[-1].isalnum(): 

247 term = term[:-1] 

248 if not term: 

249 return None 

250 last = ord(term[-1]) 

251 if last >= 0x10FFFF: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 return _PrefixPatternParam._prefix_range_upper(term[:-1]) 

253 bumped = chr(last + 1) 

254 if not bumped.isalnum(): 

255 return _PrefixPatternParam._prefix_range_upper(term[:-1]) 

256 return term[:-1] + bumped 

257 

258 @staticmethod 

259 def _prefix_lower_bound(term: str) -> str: 

260 """Return the matching lower bound: strip trailing non-alphanumeric chars to pair with the upper.""" 

261 while term and not term[-1].isalnum(): 

262 term = term[:-1] 

263 return term 

264 

265 @abstractmethod 

266 def _prefix_clause(self, term: str): 

267 """Return the SQL boolean for one prefix term (including empty string after ``~`` alias).""" 

268 

269 def to_orm(self, select: Select) -> Select: 

270 # ``skip_none`` only gates the "no value" behavior for the callers that must keep 

271 # the filter slot present (e.g. ``QueryDagIdPrefixPatternSearchWithNone``); applying 

272 # a ``None`` value as a filter produces nonsense predicates, so always skip it here. 

273 if self.value is None: 

274 return select 

275 

276 val_str = str(self.value) 

277 if self.pipe_as_or and "|" in val_str: 

278 search_terms = [term.strip() for term in val_str.split("|") if term.strip()] 

279 if search_terms: 279 ↛ 282line 279 didn't jump to line 282 because the condition on line 279 was always true

280 return select.where(or_(*(self._prefix_clause(term) for term in search_terms))) 

281 

282 return select.where(self._prefix_clause(val_str)) 

283 

284 def transform_aliases(self, value: str | None) -> str | None: 

285 if value == "~": 285 ↛ 286line 285 didn't jump to line 286 because the condition on line 285 was never true

286 value = "" 

287 return value 

288 

289 

290def _build_pipe_clause(pipe_as_or: bool) -> str: 

291 """Build the per-parameter pipe note. OR is the documented default (see the API description), so only the literal exception is spelled out.""" 

292 return "" if pipe_as_or else "Here `|` is matched literally, not as OR. " 

293 

294 

295_LIKE_ESCAPE_CHAR = "\\" 

296 

297 

298def _escape_like_pattern(value: str) -> str: 

299 r""" 

300 Escape SQL ``LIKE`` / ``ILIKE`` metacharacters in a user-supplied value. 

301 

302 Use together with ``column.ilike(f"%{_escape_like_pattern(value)}%", escape="\\")`` on filter 

303 parameters that intend literal substring matching (so a user-supplied ``%`` or ``_`` does not 

304 widen the match beyond what the filter semantics promise). Search parameters that explicitly 

305 expose wildcard semantics (see :class:`_SearchParam`) must not call this — they want the 

306 metacharacters to pass through. 

307 """ 

308 return ( 

309 value.replace(_LIKE_ESCAPE_CHAR, _LIKE_ESCAPE_CHAR * 2) 

310 .replace("%", _LIKE_ESCAPE_CHAR + "%") 

311 .replace("_", _LIKE_ESCAPE_CHAR + "_") 

312 ) 

313 

314 

315class _SearchParam(BaseParam[str]): 

316 """ 

317 Substring search on a column using ``ILIKE '%term%'`` (case-insensitive). 

318 

319 .. note:: 

320 This full-match substring search most of the time prevents the database 

321 from using B-tree indexes on ``attribute``, which can be very slow on 

322 large tables. Prefer :class:`_PrefixSearchParam` (the ``*_prefix_pattern`` 

323 query-param counterpart) when matching from the beginning of the value 

324 is acceptable. 

325 """ 

326 

327 def __init__(self, attribute: ColumnElement, skip_none: bool = True, pipe_as_or: bool = True) -> None: 

328 super().__init__(skip_none=skip_none) 

329 self.attribute: ColumnElement = attribute 

330 self.pipe_as_or = pipe_as_or 

331 

332 def to_orm(self, select: Select) -> Select: 

333 if self.value is None and self.skip_none: 

334 return select 

335 

336 val_str = str(self.value) 

337 if self.pipe_as_or and "|" in val_str: 

338 search_terms = [term.strip() for term in val_str.split("|") if term.strip()] 

339 if search_terms: 339 ↛ 342line 339 didn't jump to line 342 because the condition on line 339 was always true

340 return select.where(or_(*(self.attribute.ilike(f"%{term}%") for term in search_terms))) 

341 

342 return select.where(self.attribute.ilike(f"%{val_str}%")) 

343 

344 def transform_aliases(self, value: str | None) -> str | None: 

345 if value == "~": 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 value = "%" 

347 return value 

348 

349 @classmethod 

350 def depends(cls, *args: Any, **kwargs: Any) -> Self: 

351 raise NotImplementedError("Use search_param_factory instead , depends is not implemented.") 

352 

353 

354class _PrefixSearchParam(_PrefixPatternParam): 

355 """ 

356 Prefix search on a column using range comparison (case-sensitive, index-friendly). 

357 

358 Unlike :class:`_SearchParam`, wildcard characters are treated as literals and the query 

359 plan can use the column's default B-tree index for the range scan. Trailing 

360 non-alphanumeric characters in ``term`` are stripped first (see 

361 :class:`_PrefixPatternParam` for why). 

362 """ 

363 

364 def __init__(self, attribute: ColumnElement, skip_none: bool = True, pipe_as_or: bool = True) -> None: 

365 super().__init__(skip_none=skip_none) 

366 self.attribute: ColumnElement = attribute 

367 self.pipe_as_or = pipe_as_or 

368 

369 def _prefix_clause(self, term: str): 

370 lower = self._prefix_lower_bound(term) 

371 if not lower: 

372 return self.attribute.is_not(None) 

373 upper = self._prefix_range_upper(term) 

374 if upper is None: 

375 return self.attribute >= lower 

376 return and_(self.attribute >= lower, self.attribute < upper) 

377 

378 @classmethod 

379 def depends(cls, *args: Any, **kwargs: Any) -> Self: 

380 raise NotImplementedError("Use prefix_search_param_factory instead, depends is not implemented.") 

381 

382 

383class _TaskDisplayNamePrefixPatternParam(_PrefixPatternParam): 

384 """ 

385 Prefix filter equivalent to :attr:`TaskInstance.task_display_name`, rewritten for composite-index use. 

386 

387 The hybrid expression ``coalesce(_task_display_property_value, task_id)`` cannot use those indexes; 

388 this implementation applies an equivalent ``OR`` of simpler range predicates instead. Trailing 

389 non-alphanumeric characters in ``term`` are stripped first (see :class:`_PrefixPatternParam`). 

390 """ 

391 

392 def _prefix_clause(self, term: str): 

393 lower = self._prefix_lower_bound(term) 

394 if not lower: 

395 return sql_true() 

396 upper = self._prefix_range_upper(term) 

397 if upper is None: 

398 return or_( 

399 and_( 

400 TaskInstance._task_display_property_value.is_(None), 

401 TaskInstance.task_id >= lower, 

402 ), 

403 and_( 

404 TaskInstance._task_display_property_value.is_not(None), 

405 TaskInstance._task_display_property_value >= lower, 

406 ), 

407 ) 

408 return or_( 

409 and_( 

410 TaskInstance._task_display_property_value.is_(None), 

411 TaskInstance.task_id >= lower, 

412 TaskInstance.task_id < upper, 

413 ), 

414 and_( 

415 TaskInstance._task_display_property_value.is_not(None), 

416 TaskInstance._task_display_property_value >= lower, 

417 TaskInstance._task_display_property_value < upper, 

418 ), 

419 ) 

420 

421 @classmethod 

422 def depends( 

423 cls, 

424 task_display_name_prefix_pattern: str | None = Query( 

425 default=None, 

426 description=( 

427 "Case-sensitive prefix match on task display name (`_task_display_property_value` else " 

428 "`task_id`). Index-friendly alternative to `task_display_name_pattern`; on large databases " 

429 "combine with `dag_id_prefix_pattern` (or a specific Dag in the path) so composite indexes " 

430 'apply. See "Filtering with pattern parameters".' 

431 ), 

432 ), 

433 ) -> Self: 

434 param = cls() 

435 return param.set_value(param.transform_aliases(task_display_name_prefix_pattern)) 

436 

437 

438class QueryTaskInstanceTaskGroupFilter(BaseParam[str]): 

439 """Task group filter - returns all tasks in the specified group.""" 

440 

441 def __init__(self, dag=None, skip_none: bool = True): 

442 super().__init__(skip_none=skip_none) 

443 self._dag: None | SerializedDAG = dag 

444 

445 @property 

446 def dag(self) -> None | SerializedDAG: 

447 return self._dag 

448 

449 @dag.setter 

450 def dag(self, value: None | SerializedDAG) -> None: 

451 self._dag = value 

452 

453 def to_orm(self, select: Select) -> Select: 

454 if self.value is None and self.skip_none: 

455 return select 

456 

457 if not self.dag: 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true

458 raise ValueError("Dag must be set before calling to_orm") 

459 

460 if not hasattr(self.dag, "task_group"): 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 return select 

462 

463 # Exact matching on group_id 

464 task_groups = self.dag.task_group.get_task_group_dict() 

465 task_group = task_groups.get(self.value) 

466 if not task_group: 466 ↛ 475line 466 didn't jump to line 475 because the condition on line 466 was always true

467 raise HTTPException( 

468 status.HTTP_404_NOT_FOUND, 

469 detail={ 

470 "reason": "not_found", 

471 "message": f"Task group {self.value} not found", 

472 }, 

473 ) 

474 

475 return select.where(TaskInstance.task_id.in_(task.task_id for task in task_group.iter_tasks())) 

476 

477 @classmethod 

478 def depends( 

479 cls, 

480 value: str | None = Query( 

481 alias="task_group_id", 

482 default=None, 

483 description="Filter by exact task group ID. Returns all tasks within the specified task group.", 

484 ), 

485 ) -> QueryTaskInstanceTaskGroupFilter: 

486 return cls(dag=None).set_value(value) 

487 

488 

489def search_param_factory( 

490 attribute: ColumnElement, 

491 pattern_name: str, 

492 skip_none: bool = True, 

493 pipe_as_or: bool = True, 

494) -> Callable[[str | None], _SearchParam]: 

495 prefix_pattern_name = pattern_name.replace("_pattern", "_prefix_pattern") 

496 DESCRIPTION = ( 

497 "Case-insensitive substring match (SQL `ILIKE`). " 

498 f"{_build_pipe_clause(pipe_as_or)}" 

499 f'Slower than `{prefix_pattern_name}` on large tables — see "Filtering with pattern parameters".' 

500 ) 

501 

502 def depends_search( 

503 value: str | None = Query(alias=pattern_name, default=None, description=DESCRIPTION), 

504 ) -> _SearchParam: 

505 search_parm = _SearchParam(attribute, skip_none, pipe_as_or=pipe_as_or) 

506 value = search_parm.transform_aliases(value) 

507 return search_parm.set_value(value) 

508 

509 return depends_search 

510 

511 

512def prefix_search_param_factory( 

513 attribute: ColumnElement, 

514 prefix_pattern_name: str, 

515 skip_none: bool = True, 

516 pipe_as_or: bool = True, 

517) -> Callable[[str | None], _PrefixSearchParam]: 

518 """ 

519 Build a FastAPI ``Depends`` returning a :class:`_PrefixSearchParam` for prefix matching. 

520 

521 Prefer this over :func:`search_param_factory` for performance: prefix matching uses a 

522 B-tree index range scan, while substring matching requires a full table scan. 

523 """ 

524 DESCRIPTION = ( 

525 "Case-sensitive, index-friendly prefix match. " 

526 f"{_build_pipe_clause(pipe_as_or)}" 

527 'See "Filtering with pattern parameters".' 

528 ) 

529 

530 def depends_prefix_search( 

531 value: str | None = Query(alias=prefix_pattern_name, default=None, description=DESCRIPTION), 

532 ) -> _PrefixSearchParam: 

533 search_parm = _PrefixSearchParam(attribute, skip_none, pipe_as_or=pipe_as_or) 

534 value = search_parm.transform_aliases(value) 

535 return search_parm.set_value(value) 

536 

537 return depends_prefix_search 

538 

539 

540class SortParam(BaseParam[list[str]]): 

541 """Order result by the attribute.""" 

542 

543 MAX_SORT_PARAMS = 10 

544 

545 def __init__( 

546 self, 

547 allowed_attrs: list[str], 

548 model: Base, 

549 to_replace: dict[str, str | Column | list[Column]] | None = None, 

550 ) -> None: 

551 super().__init__() 

552 self.allowed_attrs = allowed_attrs 

553 self.model = model 

554 self.to_replace = to_replace 

555 self._cached_resolution: list[tuple[str, ColumnElement, bool]] | None = None 

556 

557 def set_value(self, value: list[str] | None) -> Self: 

558 self._cached_resolution = None 

559 return super().set_value(value) 

560 

561 def _resolve(self) -> list[tuple[str, ColumnElement, bool]]: 

562 """Resolve sort columns as (attr_name, column, is_descending) tuples. Cached after first call.""" 

563 if self._cached_resolution is not None: 

564 return self._cached_resolution 

565 

566 if self.value is None: 

567 self.value = [self.get_primary_key_string()] 

568 

569 order_by_values = self.value 

570 if len(order_by_values) > self.MAX_SORT_PARAMS: 

571 raise HTTPException( 

572 400, 

573 f"Ordering with more than {self.MAX_SORT_PARAMS} parameters is not allowed. Provided: {order_by_values}", 

574 ) 

575 

576 resolved: list[tuple[str, ColumnElement, bool]] = [] 

577 for order_by_value in order_by_values: 

578 lstriped_orderby = order_by_value.lstrip("-") 

579 # Store the user-facing name in the resolved tuple. ``row_value`` resolves 

580 # it back to the actual row accessor via ``to_replace`` when reading values 

581 # for cursor encoding. 

582 attr_name = lstriped_orderby 

583 column: Column | None = None 

584 if self.to_replace: 

585 replacement = self.to_replace.get(lstriped_orderby, lstriped_orderby) 

586 if isinstance(replacement, str): 586 ↛ 588line 586 didn't jump to line 588 because the condition on line 586 was always true

587 lstriped_orderby = replacement 

588 elif isinstance(replacement, list): 

589 # Compound sort: expand the list into multiple sort entries. 

590 # Each column's ORM key becomes its attr_name so that 

591 # row_value() can read the corresponding attribute via 

592 # getattr(row, attr_name) without further to_replace lookups. 

593 is_desc = order_by_value.startswith("-") 

594 for col in replacement: 

595 col_attr_name = col.key 

596 resolved.append((col_attr_name, col, is_desc)) 

597 continue 

598 else: 

599 column = replacement 

600 

601 if (self.allowed_attrs and lstriped_orderby not in self.allowed_attrs) and column is None: 

602 raise HTTPException( 

603 400, 

604 f"Ordering with '{lstriped_orderby}' is disallowed or " 

605 f"the attribute does not exist on the model", 

606 ) 

607 if column is None: 607 ↛ 610line 607 didn't jump to line 610 because the condition on line 607 was always true

608 column = getattr(self.model, lstriped_orderby) 

609 

610 resolved.append((attr_name, column, order_by_value.startswith("-"))) 

611 

612 primary_key_column = self.get_primary_key_column() 

613 pk_name = self.get_primary_key_string() 

614 resolved_column_keys = {getattr(col, "key", None) for _, col, _ in resolved} 

615 if pk_name not in resolved_column_keys: 

616 pk_desc = bool(order_by_values and order_by_values[0].startswith("-")) 

617 resolved.append((pk_name, primary_key_column, pk_desc)) 

618 

619 self._cached_resolution = resolved 

620 return self._cached_resolution 

621 

622 def to_orm(self, select: Select, *, reversed: bool = False) -> Select: 

623 if self.skip_none is False: 623 ↛ 624line 623 didn't jump to line 624 because the condition on line 623 was never true

624 raise ValueError(f"Cannot set 'skip_none' to False on a {type(self)}") 

625 

626 resolved = self._resolve() 

627 if reversed: 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true

628 columns = [col.asc() if is_desc else col.desc() for _, col, is_desc in resolved] 

629 else: 

630 columns = [col.desc() if is_desc else col.asc() for _, col, is_desc in resolved] 

631 return select.order_by(None).order_by(*columns) 

632 

633 def get_resolved_columns(self) -> list[tuple[str, ColumnElement, bool]]: 

634 """Return resolved sort columns as (attr_name, column_element, is_descending) tuples.""" 

635 return self._resolve() 

636 

637 def row_value(self, row: Any, name: str) -> Any: 

638 """ 

639 Extract the sort-key value for ``name`` from a result row. 

640 

641 Resolves the accessor through ``to_replace`` for string aliases 

642 (e.g. ``{"dag_run_id": "run_id"}``). For column-form mappings 

643 (e.g. ``{"run_after": DagRun.run_after}``), resolves through the 

644 primary model's attribute so association proxies can still be used 

645 for cursor values. Raises ``NotImplementedError`` when the model 

646 exposes no such attribute rather than emitting a ``None`` cursor token. 

647 """ 

648 if self.to_replace: 

649 replacement = self.to_replace.get(name) 

650 if isinstance(replacement, str): 

651 return getattr(row, replacement, None) 

652 if replacement is not None and not isinstance(replacement, list): 

653 # Column-form mapping resolves through the primary model's attribute, 

654 # often an association proxy onto the joined entity 

655 # (``TaskInstance.run_after`` -> ``dag_run.run_after``). Fail loudly if the 

656 # model exposes no such attribute, rather than emitting a ``None`` cursor token. 

657 try: 

658 return getattr(row, name) 

659 except AttributeError: 

660 raise NotImplementedError( 

661 f"Cursor pagination cannot resolve column-form ``to_replace`` for " 

662 f"``{name}``: the primary model exposes no such attribute. Add an " 

663 f"association proxy, use a string alias, or sort by a primary-model column." 

664 ) 

665 # List-form replacements are expanded in _resolve() into individual entries 

666 # each using the column's own ORM key as attr_name, so ``name`` at this point 

667 # is already a concrete model attribute (e.g. ``_rendered_map_index`` or 

668 # ``map_index``) — fall through to the getattr below. 

669 return getattr(row, name, None) 

670 

671 def get_primary_key_column(self) -> Column: 

672 """Get the primary key column of the model of SortParam object.""" 

673 return inspect(self.model).primary_key[0] 

674 

675 def get_primary_key_string(self) -> str: 

676 """Get the primary key string of the model of SortParam object.""" 

677 return self.get_primary_key_column().name 

678 

679 @classmethod 

680 def depends(cls, *args: Any, **kwargs: Any) -> Self: 

681 raise NotImplementedError("Use dynamic_depends, depends not implemented.") 

682 

683 def dynamic_depends(self, default: str | Sequence[str] | None = None) -> Callable: 

684 # Include to_replace keys that are not already in allowed_attrs to avoid 

685 # duplicate entries in the spec description. 

686 allowed_set = set(self.allowed_attrs) 

687 to_replace_attrs = [k for k in self.to_replace if k not in allowed_set] if self.to_replace else [] 

688 

689 all_attrs = self.allowed_attrs + to_replace_attrs 

690 

691 if default is None: 

692 default_list = [self.get_primary_key_string()] 

693 elif isinstance(default, str): 

694 default_list = [default] 

695 else: 

696 default_list = list(default) 

697 

698 _order_by_query = Query( 

699 default=default_list, 

700 description=f"Attributes to order by, multi criteria sort is supported. Prefix with `-` for descending order. " 

701 f"Supported attributes: `{', '.join(all_attrs) if all_attrs else self.get_primary_key_string()}`", 

702 ) 

703 

704 def inner(order_by: list[str] = _order_by_query) -> SortParam: 

705 return SortParam(self.allowed_attrs, self.model, self.to_replace).set_value(order_by) 

706 

707 return inner 

708 

709 

710class FilterOptionEnum(Enum): 

711 """Filter options for FilterParam.""" 

712 

713 EQUAL = "eq" 

714 NOT_EQUAL = "ne" 

715 LESS_THAN = "lt" 

716 LESS_THAN_EQUAL = "le" 

717 GREATER_THAN = "gt" 

718 GREATER_THAN_EQUAL = "ge" 

719 IN = "in" 

720 NOT_IN = "not_in" 

721 ANY_EQUAL = "any_eq" 

722 ALL_EQUAL = "all_eq" 

723 IS_NONE = "is_none" 

724 CONTAINS = "contains" 

725 

726 

727class FilterParam(BaseParam[T]): 

728 """Filter on attribute.""" 

729 

730 def __init__( 

731 self, 

732 attribute: InstrumentedAttribute, 

733 value: T | None = None, 

734 filter_option: FilterOptionEnum = FilterOptionEnum.EQUAL, 

735 skip_none: bool = True, 

736 ) -> None: 

737 super().__init__(value, skip_none) 

738 self.attribute: InstrumentedAttribute = attribute 

739 self.value: T | None = value 

740 self.filter_option: FilterOptionEnum = filter_option 

741 

742 def to_orm(self, select: Select) -> Select: 

743 if isinstance(self.value, (list, str)) and not self.value and self.skip_none: 

744 return select 

745 if self.value is None and self.skip_none: 

746 return select 

747 

748 if isinstance(self.value, list): 

749 if self.filter_option == FilterOptionEnum.IN: 

750 return select.where(self.attribute.in_(self.value)) 

751 if self.filter_option == FilterOptionEnum.NOT_IN: 

752 return select.where(self.attribute.notin_(self.value)) 

753 if self.filter_option == FilterOptionEnum.ANY_EQUAL: 753 ↛ 756line 753 didn't jump to line 756 because the condition on line 753 was always true

754 conditions = [self.attribute == val for val in self.value] 

755 return select.where(or_(*conditions)) 

756 if self.filter_option == FilterOptionEnum.ALL_EQUAL: 

757 conditions = [self.attribute == val for val in self.value] 

758 return select.where(and_(*conditions)) 

759 raise HTTPException( 

760 400, f"Invalid filter option {self.filter_option} for list value {self.value}" 

761 ) 

762 

763 if self.filter_option == FilterOptionEnum.EQUAL: 

764 return select.where(self.attribute == self.value) 

765 if self.filter_option == FilterOptionEnum.NOT_EQUAL: 765 ↛ 766line 765 didn't jump to line 766 because the condition on line 765 was never true

766 return select.where(self.attribute != self.value) 

767 if self.filter_option == FilterOptionEnum.LESS_THAN: 

768 return select.where(self.attribute < self.value) 

769 if self.filter_option == FilterOptionEnum.LESS_THAN_EQUAL: 769 ↛ 770line 769 didn't jump to line 770 because the condition on line 769 was never true

770 return select.where(self.attribute <= self.value) 

771 if self.filter_option == FilterOptionEnum.GREATER_THAN: 

772 return select.where(self.attribute > self.value) 

773 if self.filter_option == FilterOptionEnum.GREATER_THAN_EQUAL: 773 ↛ 774line 773 didn't jump to line 774 because the condition on line 773 was never true

774 return select.where(self.attribute >= self.value) 

775 if self.filter_option == FilterOptionEnum.IS_NONE: 775 ↛ 776line 775 didn't jump to line 776 because the condition on line 775 was never true

776 if self.value is None: 

777 return select 

778 if self.value is False: 

779 return select.where(self.attribute.is_not(None)) 

780 if self.value is True: 

781 return select.where(self.attribute.is_(None)) 

782 if self.filter_option == FilterOptionEnum.CONTAINS: 782 ↛ 789line 782 didn't jump to line 789 because the condition on line 782 was always true

783 # For JSON/JSONB columns, convert to text before applying LIKE 

784 from sqlalchemy import Text, cast 

785 

786 if str(self.attribute.type).upper() in ("JSON", "JSONB"): 786 ↛ 788line 786 didn't jump to line 788 because the condition on line 786 was always true

787 return select.where(cast(self.attribute, Text).contains(self.value)) 

788 return select.where(self.attribute.contains(self.value)) 

789 raise ValueError(f"Invalid filter option {self.filter_option} for value {self.value}") 

790 

791 @classmethod 

792 def depends(cls, *args: Any, **kwargs: Any) -> Self: 

793 raise NotImplementedError("Use filter_param_factory instead , depends is not implemented.") 

794 

795 

796def filter_param_factory( 

797 attribute: ColumnElement | InstrumentedAttribute, 

798 _type: type, 

799 filter_option: FilterOptionEnum = FilterOptionEnum.EQUAL, 

800 filter_name: str | None = None, 

801 default_value: T | None = None, 

802 default_factory: Callable[[], T | None] | None = None, 

803 skip_none: bool = True, 

804 transform_callable: Callable[[T | None], Any] | None = None, 

805 *, 

806 description: str | None = None, 

807) -> Callable[[T | None], FilterParam[T | None]]: 

808 # if filter_name is not provided, use the attribute name as the default 

809 filter_name = filter_name or getattr(attribute, "name", str(attribute)) 

810 # can only set either default_value or default_factory 

811 query = ( 

812 Query(alias=filter_name, default_factory=default_factory, description=description) 

813 if default_factory is not None 

814 else Query(alias=filter_name, default=default_value, description=description) 

815 ) 

816 

817 def depends_filter(value: T | None = query) -> FilterParam[T | None]: 

818 if transform_callable: 

819 value = transform_callable(value) 

820 # Cast to InstrumentedAttribute for type compatibility 

821 attr = cast("InstrumentedAttribute", attribute) 

822 return FilterParam(attr, value, filter_option, skip_none) 

823 

824 # add type hint to value at runtime 

825 depends_filter.__annotations__["value"] = _type 

826 

827 return depends_filter 

828 

829 

830class _TagFilterModel(BaseModel): 

831 """Tag Filter Model with a match mode parameter.""" 

832 

833 tags: list[str] 

834 tags_match_mode: Literal["any", "all"] | None 

835 

836 

837class _TagsFilter(BaseParam[_TagFilterModel]): 

838 """Filter on tags.""" 

839 

840 def to_orm(self, select: Select) -> Select: 

841 if self.skip_none is False: 841 ↛ 842line 841 didn't jump to line 842 because the condition on line 841 was never true

842 raise ValueError(f"Cannot set 'skip_none' to False on a {type(self)}") 

843 

844 if not self.value or not self.value.tags: 

845 return select 

846 

847 conditions = [DagModel.tags.any(DagTag.name == tag) for tag in self.value.tags] 

848 operator = or_ if not self.value.tags_match_mode or self.value.tags_match_mode == "any" else and_ 

849 return select.where(operator(*conditions)) 

850 

851 @classmethod 

852 def depends( 

853 cls, 

854 tags: list[str] = Query(default_factory=list), 

855 tags_match_mode: Literal["any", "all"] | None = None, 

856 ) -> _TagsFilter: 

857 return cls().set_value(_TagFilterModel(tags=tags, tags_match_mode=tags_match_mode)) 

858 

859 

860class _OwnersFilter(BaseParam[list[str]]): 

861 """Filter on owners.""" 

862 

863 def to_orm(self, select: Select) -> Select: 

864 if self.skip_none is False: 864 ↛ 865line 864 didn't jump to line 865 because the condition on line 864 was never true

865 raise ValueError(f"Cannot set 'skip_none' to False on a {type(self)}") 

866 

867 if not self.value: 

868 return select 

869 

870 conditions = [ 

871 DagModel.owners.ilike(f"%{_escape_like_pattern(owner)}%", escape=_LIKE_ESCAPE_CHAR) 

872 for owner in self.value 

873 ] 

874 return select.where(or_(*conditions)) 

875 

876 @classmethod 

877 def depends(cls, owners: list[str] = Query(default_factory=list)) -> _OwnersFilter: 

878 return cls().set_value(owners) 

879 

880 

881def _safe_parse_datetime(date_to_check: str) -> datetime: 

882 """ 

883 Parse datetime and raise error for invalid dates. 

884 

885 :param date_to_check: the string value to be parsed 

886 """ 

887 if not date_to_check: 

888 raise ValueError(f"{date_to_check} cannot be None.") 

889 return _safe_parse_datetime_optional(date_to_check) 

890 

891 

892@overload 

893def _safe_parse_datetime_optional(date_to_check: str) -> datetime: ... 893 ↛ exitline 893 didn't return from function '_safe_parse_datetime_optional' because

894 

895 

896@overload 

897def _safe_parse_datetime_optional(date_to_check: None) -> None: ... 897 ↛ exitline 897 didn't return from function '_safe_parse_datetime_optional' because

898 

899 

900def _safe_parse_datetime_optional(date_to_check: str | None) -> datetime | None: 

901 """ 

902 Parse datetime and raise error for invalid dates. 

903 

904 Allow None values. 

905 

906 :param date_to_check: the string value to be parsed 

907 """ 

908 if date_to_check is None: 908 ↛ 909line 908 didn't jump to line 909 because the condition on line 908 was never true

909 return None 

910 try: 

911 return timezone.parse(date_to_check, strict=True) 

912 except (TypeError, ParserError): 

913 raise HTTPException( 

914 400, f"Invalid datetime: {date_to_check!r}. Please check the date parameter have this value." 

915 ) 

916 

917 

918class _DagIdAssetReferenceFilter(BaseParam[list[str]]): 

919 """Search on dag_id.""" 

920 

921 def __init__(self, skip_none: bool = True) -> None: 

922 super().__init__(skip_none=skip_none) 

923 

924 @classmethod 

925 def depends(cls, dag_ids: list[str] = Query(None)) -> _DagIdAssetReferenceFilter: 

926 # needed to handle cases where dag_ids=a1,b1 

927 if dag_ids and len(dag_ids) == 1 and "," in dag_ids[0]: 927 ↛ 928line 927 didn't jump to line 928 because the condition on line 927 was never true

928 dag_ids = dag_ids[0].split(",") 

929 return cls().set_value(dag_ids) 

930 

931 def to_orm(self, select: Select) -> Select: 

932 if self.value is None and self.skip_none: 

933 return select 

934 

935 # At this point, self.value is either a list[str] or None -> coerce falsy None to an empty list 

936 dag_ids = self.value or [] 

937 return select.where( 

938 (AssetModel.scheduled_dags.any(DagScheduleAssetReference.dag_id.in_(dag_ids))) 

939 | (AssetModel.producing_tasks.any(TaskOutletAssetReference.dag_id.in_(dag_ids))) 

940 | (AssetModel.consuming_tasks.any(TaskInletAssetReference.dag_id.in_(dag_ids))) 

941 ) 

942 

943 

944class Range(BaseModel, Generic[T]): 

945 """Range with a lower and upper bound.""" 

946 

947 lower_bound_gte: T | None 

948 lower_bound_gt: T | None 

949 upper_bound_lte: T | None 

950 upper_bound_lt: T | None 

951 

952 

953class RangeFilter(BaseParam[Range]): 

954 """Filter on range in between the lower and upper bound.""" 

955 

956 def __init__(self, value: Range | None, attribute: InstrumentedAttribute) -> None: 

957 super().__init__(value) 

958 self.attribute: InstrumentedAttribute = attribute 

959 

960 def to_orm(self, select: Select) -> Select: 

961 if self.skip_none is False: 961 ↛ 962line 961 didn't jump to line 962 because the condition on line 961 was never true

962 raise ValueError(f"Cannot set 'skip_none' to False on a {type(self)}") 

963 

964 if self.value is None: 964 ↛ 965line 964 didn't jump to line 965 because the condition on line 964 was never true

965 return select 

966 

967 if self.value.lower_bound_gte: 

968 select = select.where(self.attribute >= self.value.lower_bound_gte) 

969 if self.value.lower_bound_gt: 

970 select = select.where(self.attribute > self.value.lower_bound_gt) 

971 if self.value.upper_bound_lte: 

972 select = select.where(self.attribute <= self.value.upper_bound_lte) 

973 if self.value.upper_bound_lt: 

974 select = select.where(self.attribute < self.value.upper_bound_lt) 

975 

976 return select 

977 

978 @classmethod 

979 def depends(cls, *args: Any, **kwargs: Any) -> Self: 

980 raise NotImplementedError("Use the `range_filter_factory` function to create the dependency") 

981 

982 def is_active(self) -> bool: 

983 """Check if the range filter has any active bounds.""" 

984 return self.value is not None and ( 

985 self.value.lower_bound_gte is not None 

986 or self.value.lower_bound_gt is not None 

987 or self.value.upper_bound_lte is not None 

988 or self.value.upper_bound_lt is not None 

989 ) 

990 

991 

992class NullableDatetimeRangeFilter(RangeFilter): 

993 """ 

994 RangeFilter for nullable datetime columns (``start_date``, ``end_date``), rewritten for index use. 

995 

996 ``COALESCE(column, now())`` wraps the column in a function call that prevents PostgreSQL from 

997 using btree indexes, forcing sequential scans on large tables. This class emits equivalent 

998 ``OR`` predicates so each branch can be satisfied by an independent index scan. 

999 

1000 NULL semantics: ``start_date=NULL`` means the task has not started yet; ``end_date=NULL`` means 

1001 the task is still running. For lower bounds the NULL branch passes unconditionally — a not-yet- 

1002 started/ended task will eventually satisfy any past lower bound. For upper bounds the NULL branch 

1003 is ``col IS NULL AND now() <= x``, preserving the COALESCE(col, now()) semantics without the 

1004 function-wrap index penalty. 

1005 """ 

1006 

1007 def to_orm(self, select: Select) -> Select: 

1008 if self.skip_none is False: 1008 ↛ 1009line 1008 didn't jump to line 1009 because the condition on line 1008 was never true

1009 raise ValueError(f"Cannot set 'skip_none' to False on a {type(self)}") 

1010 

1011 if self.value is None: 1011 ↛ 1012line 1011 didn't jump to line 1012 because the condition on line 1011 was never true

1012 return select 

1013 

1014 if self.value.lower_bound_gte: 

1015 x = self.value.lower_bound_gte 

1016 select = select.where(or_(self.attribute >= x, self.attribute.is_(None))) 

1017 if self.value.lower_bound_gt: 

1018 x = self.value.lower_bound_gt 

1019 select = select.where(or_(self.attribute > x, self.attribute.is_(None))) 

1020 if self.value.upper_bound_lte: 

1021 x = self.value.upper_bound_lte 

1022 select = select.where(or_(self.attribute <= x, and_(self.attribute.is_(None), func.now() <= x))) 

1023 if self.value.upper_bound_lt: 

1024 x = self.value.upper_bound_lt 

1025 select = select.where(or_(self.attribute < x, and_(self.attribute.is_(None), func.now() < x))) 

1026 

1027 return select 

1028 

1029 

1030def datetime_range_filter_factory( 

1031 filter_name: str, model: Base, attribute_name: str | None = None 

1032) -> Callable[[datetime | None, datetime | None, datetime | None, datetime | None], RangeFilter]: 

1033 def depends_datetime( 

1034 lower_bound_gte: datetime | None = Query(alias=f"{filter_name}_gte", default=None), 

1035 lower_bound_gt: datetime | None = Query(alias=f"{filter_name}_gt", default=None), 

1036 upper_bound_lte: datetime | None = Query(alias=f"{filter_name}_lte", default=None), 

1037 upper_bound_lt: datetime | None = Query(alias=f"{filter_name}_lt", default=None), 

1038 ) -> RangeFilter: 

1039 attr = getattr(model, attribute_name or filter_name) 

1040 range_val = Range( 

1041 lower_bound_gte=lower_bound_gte, 

1042 lower_bound_gt=lower_bound_gt, 

1043 upper_bound_lte=upper_bound_lte, 

1044 upper_bound_lt=upper_bound_lt, 

1045 ) 

1046 if filter_name in ("start_date", "end_date"): 

1047 return NullableDatetimeRangeFilter(range_val, attr) 

1048 return RangeFilter(range_val, attr) 

1049 

1050 return depends_datetime 

1051 

1052 

1053def float_range_filter_factory( 

1054 filter_name: str, model: Base 

1055) -> Callable[[float | None, float | None, float | None, float | None], RangeFilter]: 

1056 def depends_float( 

1057 lower_bound_gte: float | None = Query(alias=f"{filter_name}_gte", default=None), 

1058 lower_bound_gt: float | None = Query(alias=f"{filter_name}_gt", default=None), 

1059 upper_bound_lte: float | None = Query(alias=f"{filter_name}_lte", default=None), 

1060 upper_bound_lt: float | None = Query(alias=f"{filter_name}_lt", default=None), 

1061 ) -> RangeFilter: 

1062 return RangeFilter( 

1063 Range( 

1064 lower_bound_gte=lower_bound_gte, 

1065 lower_bound_gt=lower_bound_gt, 

1066 upper_bound_lte=upper_bound_lte, 

1067 upper_bound_lt=upper_bound_lt, 

1068 ), 

1069 getattr(model, filter_name), 

1070 ) 

1071 

1072 return depends_float 

1073 

1074 

1075# Common Safe DateTime 

1076DateTimeQuery = Annotated[str, AfterValidator(_safe_parse_datetime)] 

1077OptionalDateTimeQuery = Annotated[str | None, AfterValidator(_safe_parse_datetime_optional)] 

1078 

1079# Dag 

1080QueryLimit = Annotated[LimitFilter, Depends(LimitFilter.depends)] 

1081QueryOffset = Annotated[OffsetFilter, Depends(OffsetFilter.depends)] 

1082QueryPausedFilter = Annotated[ 

1083 FilterParam[bool | None], 

1084 Depends(filter_param_factory(DagModel.is_paused, bool | None, filter_name="paused")), 

1085] 

1086QueryHasImportErrorsFilter = Annotated[ 

1087 FilterParam[bool | None], 

1088 Depends( 

1089 filter_param_factory( 

1090 DagModel.has_import_errors, 

1091 bool | None, 

1092 filter_name="has_import_errors", 

1093 description="Filter Dags by having import errors. Only Dags that have been successfully loaded before will be returned.", 

1094 ) 

1095 ), 

1096] 

1097QueryFavoriteFilter = Annotated[_FavoriteFilter, Depends(_FavoriteFilter.depends)] 

1098QueryExcludeStaleFilter = Annotated[_ExcludeStaleFilter, Depends(_ExcludeStaleFilter.depends)] 

1099QueryDagIdPatternSearch = Annotated[ 

1100 _SearchParam, Depends(search_param_factory(DagModel.dag_id, "dag_id_pattern")) 

1101] 

1102QueryDagIdPrefixPatternSearch = Annotated[ 

1103 _PrefixSearchParam, Depends(prefix_search_param_factory(DagModel.dag_id, "dag_id_prefix_pattern")) 

1104] 

1105QueryDagDisplayNamePatternSearch = Annotated[ 

1106 _SearchParam, Depends(search_param_factory(DagModel.dag_display_name, "dag_display_name_pattern")) 

1107] 

1108QueryDagDisplayNamePrefixPatternSearch = Annotated[ 

1109 _PrefixSearchParam, 

1110 Depends(prefix_search_param_factory(DagModel.dag_display_name, "dag_display_name_prefix_pattern")), 

1111] 

1112QueryBundleNameFilter = Annotated[ 

1113 FilterParam[str | None], 

1114 Depends(filter_param_factory(DagModel.bundle_name, str | None, filter_name="bundle_name")), 

1115] 

1116QueryBundleVersionFilter = Annotated[ 

1117 FilterParam[str | None], 

1118 Depends(filter_param_factory(DagModel.bundle_version, str | None, filter_name="bundle_version")), 

1119] 

1120QueryDagIdPatternSearchWithNone = Annotated[ 

1121 _SearchParam, Depends(search_param_factory(DagModel.dag_id, "dag_id_pattern", False)) 

1122] 

1123QueryDagIdPrefixPatternSearchWithNone = Annotated[ 

1124 _PrefixSearchParam, 

1125 Depends(prefix_search_param_factory(DagModel.dag_id, "dag_id_prefix_pattern", False)), 

1126] 

1127QueryTagsFilter = Annotated[_TagsFilter, Depends(_TagsFilter.depends)] 

1128QueryOwnersFilter = Annotated[_OwnersFilter, Depends(_OwnersFilter.depends)] 

1129 

1130 

1131class _HasAssetScheduleFilter(BaseParam[bool]): 

1132 """Filter Dags that have asset-based scheduling.""" 

1133 

1134 def to_orm(self, select: Select) -> Select: 

1135 if self.value is None and self.skip_none: 

1136 return select 

1137 

1138 asset_ref_subquery = sql_select(DagScheduleAssetReference.dag_id).distinct() 

1139 

1140 if self.value: 

1141 # Filter Dags that have asset-based scheduling 

1142 return select.where(DagModel.dag_id.in_(asset_ref_subquery)) 

1143 

1144 # Filter Dags that do NOT have asset-based scheduling 

1145 return select.where(DagModel.dag_id.notin_(asset_ref_subquery)) 

1146 

1147 @classmethod 

1148 def depends( 

1149 cls, 

1150 has_asset_schedule: bool | None = Query(None, description="Filter Dags with asset-based scheduling"), 

1151 ) -> _HasAssetScheduleFilter: 

1152 return cls().set_value(has_asset_schedule) 

1153 

1154 

1155class _AssetDependencyFilter(BaseParam[str]): 

1156 """Filter Dags by specific asset dependencies.""" 

1157 

1158 def to_orm(self, select: Select) -> Select: 

1159 if self.value is None: 

1160 return select 

1161 

1162 escaped = _escape_like_pattern(self.value) 

1163 asset_dag_subquery = ( 

1164 sql_select(DagScheduleAssetReference.dag_id) 

1165 .join(AssetModel, DagScheduleAssetReference.asset_id == AssetModel.id) 

1166 .where( 

1167 or_( 

1168 AssetModel.name.ilike(f"%{escaped}%", escape=_LIKE_ESCAPE_CHAR), 

1169 AssetModel.uri.ilike(f"%{escaped}%", escape=_LIKE_ESCAPE_CHAR), 

1170 ) 

1171 ) 

1172 .distinct() 

1173 ) 

1174 

1175 return select.where(DagModel.dag_id.in_(asset_dag_subquery)) 

1176 

1177 @classmethod 

1178 def depends( 

1179 cls, 

1180 asset_dependency: str | None = Query( 

1181 None, description="Filter Dags by asset dependency (name or URI)" 

1182 ), 

1183 ) -> _AssetDependencyFilter: 

1184 return cls().set_value(asset_dependency) 

1185 

1186 

1187QueryHasAssetScheduleFilter = Annotated[_HasAssetScheduleFilter, Depends(_HasAssetScheduleFilter.depends)] 

1188QueryAssetDependencyFilter = Annotated[_AssetDependencyFilter, Depends(_AssetDependencyFilter.depends)] 

1189 

1190 

1191class _ConsumingAssetFilter(BaseParam[str | None]): 

1192 """Filter Dag runs by consuming asset (name or URI).""" 

1193 

1194 def to_orm(self, select: Select) -> Select: 

1195 if not self.value: 

1196 return select 

1197 

1198 escaped = _escape_like_pattern(self.value) 

1199 event_subquery = ( 

1200 sql_select(AssetEvent.id) 

1201 .join(AssetModel, AssetEvent.asset_id == AssetModel.id) 

1202 .where( 

1203 or_( 

1204 AssetModel.name.ilike(f"%{escaped}%", escape=_LIKE_ESCAPE_CHAR), 

1205 AssetModel.uri.ilike(f"%{escaped}%", escape=_LIKE_ESCAPE_CHAR), 

1206 ) 

1207 ) 

1208 .distinct() 

1209 ) 

1210 

1211 dagrun_subquery = ( 

1212 sql_select(association_table.c.dag_run_id) 

1213 .where(association_table.c.event_id.in_(event_subquery)) 

1214 .distinct() 

1215 ) 

1216 

1217 return select.where(DagRun.id.in_(dagrun_subquery)) 

1218 

1219 @classmethod 

1220 def depends( 

1221 cls, 

1222 consuming_asset_pattern: str | None = Query( 

1223 None, 

1224 description=( 

1225 "Case-insensitive substring match against the consuming asset name or URI. " 

1226 "Unlike the wildcard `*_pattern` parameters, `%` and `_` are matched literally, " 

1227 "`|` is not an OR separator, and `~` does not match everything." 

1228 ), 

1229 ), 

1230 ) -> _ConsumingAssetFilter: 

1231 return cls().set_value(consuming_asset_pattern) 

1232 

1233 

1234QueryConsumingAssetPatternSearch = Annotated[_ConsumingAssetFilter, Depends(_ConsumingAssetFilter.depends)] 

1235 

1236 

1237class _PendingActionsFilter(BaseParam[bool]): 

1238 """Filter Dags by having pending HITL actions (more than 1).""" 

1239 

1240 def to_orm(self, select: Select) -> Select: 

1241 if self.value is None and self.skip_none: 

1242 return select 

1243 

1244 from airflow.models.hitl import HITLDetail 

1245 from airflow.models.taskinstance import TaskInstance 

1246 

1247 # Join with HITLDetail and TaskInstance to find Dags 

1248 pending_actions_count_subquery = ( 

1249 sql_select(func.count(HITLDetail.ti_id)) 

1250 .join(TaskInstance, HITLDetail.ti_id == TaskInstance.id) 

1251 .where( 

1252 HITLDetail.responded_at.is_(None), 

1253 TaskInstance.state.in_((TaskInstanceState.DEFERRED, TaskInstanceState.AWAITING_INPUT)), 

1254 ) 

1255 .where(TaskInstance.dag_id == DagModel.dag_id) 

1256 .scalar_subquery() 

1257 ) 

1258 

1259 if self.value is True: 

1260 # Filter to show only Dags with pending actions 

1261 where_clause = pending_actions_count_subquery >= 1 

1262 else: 

1263 # Filter to show only Dags without pending actions 

1264 where_clause = pending_actions_count_subquery == 0 

1265 

1266 return select.where(where_clause) 

1267 

1268 @classmethod 

1269 def depends(cls, has_pending_actions: bool | None = Query(None)) -> _PendingActionsFilter: 

1270 return cls().set_value(has_pending_actions) 

1271 

1272 

1273QueryPendingActionsFilter = Annotated[_PendingActionsFilter, Depends(_PendingActionsFilter.depends)] 

1274 

1275 

1276class _AnyDagRunStateFilter(BaseParam[DagRunState | None]): 

1277 """Filter Dags that have any DagRun in the given state, not only the latest one.""" 

1278 

1279 def to_orm(self, select: Select) -> Select: 

1280 if self.value is None and self.skip_none: 

1281 return select 

1282 

1283 # Alias DagRun so this EXISTS subquery cannot auto-correlate to a DagRun the outer query 

1284 # may already reference (e.g. the last_dag_run_state filter), which would strip the 

1285 # subquery's FROM and raise. EXISTS resolves each Dag via the (dag_id, state) index. 

1286 any_run = aliased(DagRun) 

1287 has_run_in_state = ( 

1288 sql_select(any_run.dag_id) 

1289 .where(any_run.dag_id == DagModel.dag_id, any_run.state == self.value) 

1290 .exists() 

1291 ) 

1292 return select.where(has_run_in_state) 

1293 

1294 @classmethod 

1295 def depends( 

1296 cls, 

1297 dag_run_state: DagRunState | None = Query( 

1298 None, 

1299 description="Filter Dags that have any DagRun in the given state.", 

1300 ), 

1301 ) -> _AnyDagRunStateFilter: 

1302 return cls().set_value(dag_run_state) 

1303 

1304 

1305# DagRun 

1306QueryLastDagRunStateFilter = Annotated[ 

1307 FilterParam[DagRunState | None], 

1308 Depends(filter_param_factory(DagRun.state, DagRunState | None, filter_name="last_dag_run_state")), 

1309] 

1310QueryAnyDagRunStateFilter = Annotated[_AnyDagRunStateFilter, Depends(_AnyDagRunStateFilter.depends)] 

1311 

1312 

1313def _transform_dag_run_states(states: Iterable[str] | None) -> list[DagRunState | None] | None: 

1314 try: 

1315 if not states: 

1316 return None 

1317 return [None if s in ("none", None) else DagRunState(s) for s in states] 

1318 except ValueError: 

1319 raise HTTPException( 

1320 status_code=HTTP_422_UNPROCESSABLE_CONTENT, 

1321 detail=f"Invalid value for state. Valid values are {', '.join(DagRunState)}", 

1322 ) 

1323 

1324 

1325QueryDagRunStateFilter = Annotated[ 

1326 FilterParam[list[str]], 

1327 Depends( 

1328 filter_param_factory( 

1329 DagRun.state, 

1330 list[str], 

1331 FilterOptionEnum.ANY_EQUAL, 

1332 default_factory=list, 

1333 transform_callable=_transform_dag_run_states, 

1334 ) 

1335 ), 

1336] 

1337 

1338 

1339def _transform_dag_run_types(types: list[str] | None) -> list[DagRunType | None] | None: 

1340 try: 

1341 if not types: 

1342 return None 

1343 return [None if run_type in ("none", None) else DagRunType(run_type) for run_type in types] 

1344 except ValueError: 

1345 raise HTTPException( 

1346 status_code=HTTP_422_UNPROCESSABLE_CONTENT, 

1347 detail=f"Invalid value for run type. Valid values are {', '.join(DagRunType)}", 

1348 ) 

1349 

1350 

1351QueryDagRunRunTypesFilter = Annotated[ 

1352 FilterParam[list[str]], 

1353 Depends( 

1354 filter_param_factory( 

1355 attribute=DagRun.run_type, 

1356 _type=list[str], 

1357 filter_option=FilterOptionEnum.ANY_EQUAL, 

1358 default_factory=list, 

1359 transform_callable=_transform_dag_run_types, 

1360 ) 

1361 ), 

1362] 

1363 

1364QueryDagRunTriggeringUserSearch = Annotated[ 

1365 _SearchParam, Depends(search_param_factory(DagRun.triggering_user_name, "triggering_user")) 

1366] 

1367QueryDagRunTriggeringUserPrefixSearch = Annotated[ 

1368 _PrefixSearchParam, 

1369 Depends(prefix_search_param_factory(DagRun.triggering_user_name, "triggering_user_prefix")), 

1370] 

1371QueryDagRunPartitionKeySearch = Annotated[ 

1372 _SearchParam, 

1373 Depends(search_param_factory(DagRun.partition_key, "partition_key_pattern", pipe_as_or=False)), 

1374] 

1375QueryDagRunPartitionKeyPrefixSearch = Annotated[ 

1376 _PrefixSearchParam, 

1377 Depends( 

1378 prefix_search_param_factory(DagRun.partition_key, "partition_key_prefix_pattern", pipe_as_or=False) 

1379 ), 

1380] 

1381 

1382# DagTags 

1383QueryDagTagPatternSearch = Annotated[ 

1384 _SearchParam, Depends(search_param_factory(DagTag.name, "tag_name_pattern")) 

1385] 

1386QueryDagTagPrefixPatternSearch = Annotated[ 

1387 _PrefixSearchParam, Depends(prefix_search_param_factory(DagTag.name, "tag_name_prefix_pattern")) 

1388] 

1389 

1390 

1391# TI 

1392def _transform_ti_states(states: list[str] | None) -> list[TaskInstanceState | None] | None: 

1393 """Transform a list of state strings into a list of TaskInstanceState enums handling special 'None' cases.""" 

1394 if not states: 

1395 return None 

1396 

1397 try: 

1398 return [None if s in ("no_status", "none", None) else TaskInstanceState(s) for s in states] 

1399 except ValueError: 

1400 raise HTTPException( 

1401 status_code=HTTP_422_UNPROCESSABLE_CONTENT, 

1402 detail=f"Invalid value for state. Valid values are {', '.join(TaskInstanceState)}", 

1403 ) 

1404 

1405 

1406QueryTIStateFilter = Annotated[ 

1407 FilterParam[list[str]], 

1408 Depends( 

1409 filter_param_factory( 

1410 TaskInstance.state, 

1411 list[str], 

1412 FilterOptionEnum.ANY_EQUAL, 

1413 default_factory=list, 

1414 transform_callable=_transform_ti_states, 

1415 ) 

1416 ), 

1417] 

1418QueryTIPoolFilter = Annotated[ 

1419 FilterParam[list[str]], 

1420 Depends( 

1421 filter_param_factory(TaskInstance.pool, list[str], FilterOptionEnum.ANY_EQUAL, default_factory=list) 

1422 ), 

1423] 

1424QueryTIQueueFilter = Annotated[ 

1425 FilterParam[list[str]], 

1426 Depends( 

1427 filter_param_factory(TaskInstance.queue, list[str], FilterOptionEnum.ANY_EQUAL, default_factory=list) 

1428 ), 

1429] 

1430QueryTIPoolNamePatternSearch = Annotated[ 

1431 _SearchParam, 

1432 Depends(search_param_factory(TaskInstance.pool, "pool_name_pattern")), 

1433] 

1434QueryTIPoolNamePrefixPatternSearch = Annotated[ 

1435 _PrefixSearchParam, 

1436 Depends(prefix_search_param_factory(TaskInstance.pool, "pool_name_prefix_pattern")), 

1437] 

1438 

1439QueryTIQueueNamePatternSearch = Annotated[ 

1440 _SearchParam, 

1441 Depends(search_param_factory(TaskInstance.queue, "queue_name_pattern")), 

1442] 

1443QueryTIQueueNamePrefixPatternSearch = Annotated[ 

1444 _PrefixSearchParam, 

1445 Depends(prefix_search_param_factory(TaskInstance.queue, "queue_name_prefix_pattern")), 

1446] 

1447QueryTIExecutorFilter = Annotated[ 

1448 FilterParam[list[str]], 

1449 Depends( 

1450 filter_param_factory( 

1451 TaskInstance.executor, list[str], FilterOptionEnum.ANY_EQUAL, default_factory=list 

1452 ) 

1453 ), 

1454] 

1455QueryTITaskDisplayNamePatternSearch = Annotated[ 

1456 _SearchParam, 

1457 Depends(search_param_factory(TaskInstance.task_display_name, "task_display_name_pattern")), 

1458] 

1459QueryTITaskDisplayNamePrefixPatternSearch = Annotated[ 

1460 _TaskDisplayNamePrefixPatternParam, Depends(_TaskDisplayNamePrefixPatternParam.depends) 

1461] 

1462QueryTITaskGroupFilter = Annotated[ 

1463 QueryTaskInstanceTaskGroupFilter, Depends(QueryTaskInstanceTaskGroupFilter.depends) 

1464] 

1465QueryTIDagVersionFilter = Annotated[ 

1466 FilterParam[list[int]], 

1467 Depends( 

1468 filter_param_factory( 

1469 DagVersion.version_number, 

1470 list[int], 

1471 FilterOptionEnum.ANY_EQUAL, 

1472 default_factory=list, 

1473 ) 

1474 ), 

1475] 

1476QueryDagRunVersionFilter = Annotated[ 

1477 FilterParam[list[int]], 

1478 Depends( 

1479 filter_param_factory( 

1480 DagVersion.version_number, 

1481 list[int], 

1482 FilterOptionEnum.ANY_EQUAL, 

1483 default_factory=list, 

1484 filter_name="dag_version", 

1485 ) 

1486 ), 

1487] 

1488QueryTITryNumberFilter = Annotated[ 

1489 FilterParam[list[int]], 

1490 Depends( 

1491 filter_param_factory( 

1492 TaskInstance.try_number, list[int], FilterOptionEnum.ANY_EQUAL, default_factory=list 

1493 ) 

1494 ), 

1495] 

1496 

1497QueryTIOperatorFilter = Annotated[ 

1498 FilterParam[list[str]], 

1499 Depends( 

1500 filter_param_factory( 

1501 TaskInstance.operator, list[str], FilterOptionEnum.ANY_EQUAL, default_factory=list 

1502 ) 

1503 ), 

1504] 

1505QueryTIOperatorNamePatternSearch = Annotated[ 

1506 _SearchParam, 

1507 Depends( 

1508 search_param_factory( 

1509 TaskInstance.custom_operator_name, 

1510 "operator_name_pattern", 

1511 ) 

1512 ), 

1513] 

1514QueryTIOperatorNamePrefixPatternSearch = Annotated[ 

1515 _PrefixSearchParam, 

1516 Depends( 

1517 prefix_search_param_factory( 

1518 TaskInstance.custom_operator_name, 

1519 "operator_name_prefix_pattern", 

1520 ) 

1521 ), 

1522] 

1523 

1524QueryTIMapIndexFilter = Annotated[ 

1525 FilterParam[list[int]], 

1526 Depends( 

1527 filter_param_factory( 

1528 TaskInstance.map_index, list[int], FilterOptionEnum.ANY_EQUAL, default_factory=list 

1529 ) 

1530 ), 

1531] 

1532# On MySQL the CASE expression that backs rendered_map_index mixes a stored 

1533# VARCHAR column (utf8mb4_bin, IMPLICIT) with CAST(map_index AS CHAR) 

1534# (utf8mb4_0900_ai_ci, IMPLICIT), which gives the whole expression NONE 

1535# coercibility. Comparing it against a bound parameter then fails with 

1536# "Illegal mix of collations". _MySQLCollate wraps the expression so that 

1537# on MySQL an explicit COLLATE clause is emitted (giving EXPLICIT coercibility); 

1538# on PostgreSQL and SQLite the wrapper is transparent. 

1539_rendered_map_index_collated = _MySQLCollate( 

1540 cast("ColumnElement[Any]", TaskInstance.rendered_map_index), "utf8mb4_0900_ai_ci" 

1541) 

1542 

1543QueryTIRenderedMapIndexPatternSearch = Annotated[ 

1544 _SearchParam, 

1545 Depends( 

1546 search_param_factory( 

1547 _rendered_map_index_collated, 

1548 "rendered_map_index_pattern", 

1549 ) 

1550 ), 

1551] 

1552QueryTIRenderedMapIndexPrefixPatternSearch = Annotated[ 

1553 _PrefixSearchParam, 

1554 Depends( 

1555 prefix_search_param_factory( 

1556 _rendered_map_index_collated, 

1557 "rendered_map_index_prefix_pattern", 

1558 ) 

1559 ), 

1560] 

1561 

1562# XCom 

1563QueryXComKeyPatternSearch = Annotated[ 

1564 _SearchParam, Depends(search_param_factory(XComModel.key, "xcom_key_pattern")) 

1565] 

1566QueryXComKeyPrefixPatternSearch = Annotated[ 

1567 _PrefixSearchParam, Depends(prefix_search_param_factory(XComModel.key, "xcom_key_prefix_pattern")) 

1568] 

1569 

1570QueryXComDagDisplayNamePatternSearch = Annotated[ 

1571 _SearchParam, Depends(search_param_factory(DagModel.dag_display_name, "dag_display_name_pattern")) 

1572] 

1573QueryXComDagDisplayNamePrefixPatternSearch = Annotated[ 

1574 _PrefixSearchParam, 

1575 Depends(prefix_search_param_factory(DagModel.dag_display_name, "dag_display_name_prefix_pattern")), 

1576] 

1577QueryXComRunIdPatternSearch = Annotated[ 

1578 _SearchParam, Depends(search_param_factory(XComModel.run_id, "run_id_pattern")) 

1579] 

1580QueryXComRunIdPrefixPatternSearch = Annotated[ 

1581 _PrefixSearchParam, Depends(prefix_search_param_factory(XComModel.run_id, "run_id_prefix_pattern")) 

1582] 

1583QueryXComTaskIdPatternSearch = Annotated[ 

1584 _SearchParam, Depends(search_param_factory(XComModel.task_id, "task_id_pattern")) 

1585] 

1586QueryXComTaskIdPrefixPatternSearch = Annotated[ 

1587 _PrefixSearchParam, Depends(prefix_search_param_factory(XComModel.task_id, "task_id_prefix_pattern")) 

1588] 

1589 

1590# Assets 

1591QueryAssetNamePatternSearch = Annotated[ 

1592 _SearchParam, Depends(search_param_factory(AssetModel.name, "name_pattern")) 

1593] 

1594QueryAssetNamePrefixPatternSearch = Annotated[ 

1595 _PrefixSearchParam, Depends(prefix_search_param_factory(AssetModel.name, "name_prefix_pattern")) 

1596] 

1597QueryUriPatternSearch = Annotated[_SearchParam, Depends(search_param_factory(AssetModel.uri, "uri_pattern"))] 

1598QueryUriPrefixPatternSearch = Annotated[ 

1599 _PrefixSearchParam, Depends(prefix_search_param_factory(AssetModel.uri, "uri_prefix_pattern")) 

1600] 

1601QueryAssetAliasNamePatternSearch = Annotated[ 

1602 _SearchParam, Depends(search_param_factory(AssetAliasModel.name, "name_pattern")) 

1603] 

1604QueryAssetAliasNamePrefixPatternSearch = Annotated[ 

1605 _PrefixSearchParam, Depends(prefix_search_param_factory(AssetAliasModel.name, "name_prefix_pattern")) 

1606] 

1607QueryAssetDagIdPatternSearch = Annotated[ 

1608 _DagIdAssetReferenceFilter, Depends(_DagIdAssetReferenceFilter.depends) 

1609] 

1610QueryPartitionedDagRunHasCreatedDagRunIdFilter = Annotated[ 

1611 FilterParam[bool | None], 

1612 Depends( 

1613 filter_param_factory( 

1614 AssetPartitionDagRun.created_dag_run_id, 

1615 bool | None, 

1616 FilterOptionEnum.IS_NONE, 

1617 filter_name="has_created_dag_run_id", 

1618 transform_callable=lambda v: not v if v is not None else None, 

1619 ) 

1620 ), 

1621] 

1622QueryPartitionedDagRunDagIdFilter = Annotated[ 

1623 FilterParam[str | None], 

1624 Depends( 

1625 filter_param_factory( 

1626 AssetPartitionDagRun.target_dag_id, 

1627 str | None, 

1628 filter_name="dag_id", 

1629 ) 

1630 ), 

1631] 

1632 

1633# Variables 

1634QueryVariableKeyPatternSearch = Annotated[ 

1635 _SearchParam, Depends(search_param_factory(Variable.key, "variable_key_pattern")) 

1636] 

1637QueryVariableKeyPrefixPatternSearch = Annotated[ 

1638 _PrefixSearchParam, 

1639 Depends(prefix_search_param_factory(Variable.key, "variable_key_prefix_pattern")), 

1640] 

1641 

1642# Pools 

1643QueryPoolNamePatternSearch = Annotated[ 

1644 _SearchParam, Depends(search_param_factory(Pool.pool, "pool_name_pattern")) 

1645] 

1646QueryPoolNamePrefixPatternSearch = Annotated[ 

1647 _PrefixSearchParam, Depends(prefix_search_param_factory(Pool.pool, "pool_name_prefix_pattern")) 

1648] 

1649 

1650 

1651# UI Shared 

1652def _optional_boolean(value: bool | None) -> bool | None: 

1653 return value if value is not None else False 

1654 

1655 

1656QueryIncludeUpstream = Annotated[bool, AfterValidator(_optional_boolean)] 

1657QueryIncludeDownstream = Annotated[bool, AfterValidator(_optional_boolean)] 

1658 

1659state_priority: list[None | TaskInstanceState] = [ 

1660 TaskInstanceState.FAILED, 

1661 TaskInstanceState.UPSTREAM_FAILED, 

1662 TaskInstanceState.UP_FOR_RETRY, 

1663 TaskInstanceState.UP_FOR_RESCHEDULE, 

1664 TaskInstanceState.RUNNING, 

1665 TaskInstanceState.RESTARTING, 

1666 TaskInstanceState.DEFERRED, 

1667 TaskInstanceState.AWAITING_INPUT, 

1668 TaskInstanceState.QUEUED, 

1669 TaskInstanceState.SCHEDULED, 

1670 None, 

1671 TaskInstanceState.SUCCESS, 

1672 TaskInstanceState.SKIPPED, 

1673 TaskInstanceState.REMOVED, 

1674] 

1675 

1676# Connections 

1677QueryConnectionIdPatternSearch = Annotated[ 

1678 _SearchParam, Depends(search_param_factory(Connection.conn_id, "connection_id_pattern")) 

1679] 

1680QueryConnectionIdPrefixPatternSearch = Annotated[ 

1681 _PrefixSearchParam, 

1682 Depends(prefix_search_param_factory(Connection.conn_id, "connection_id_prefix_pattern")), 

1683] 

1684 

1685# Human in the loop 

1686QueryHITLDetailDagIdPatternSearch = Annotated[ 

1687 _SearchParam, 

1688 Depends( 

1689 search_param_factory( 

1690 TaskInstance.dag_id, 

1691 "dag_id_pattern", 

1692 ) 

1693 ), 

1694] 

1695QueryHITLDetailDagIdPrefixPatternSearch = Annotated[ 

1696 _PrefixSearchParam, 

1697 Depends( 

1698 prefix_search_param_factory( 

1699 TaskInstance.dag_id, 

1700 "dag_id_prefix_pattern", 

1701 ) 

1702 ), 

1703] 

1704QueryHITLDetailTaskIdPatternSearch = Annotated[ 

1705 _SearchParam, 

1706 Depends( 

1707 search_param_factory( 

1708 TaskInstance.task_id, 

1709 "task_id_pattern", 

1710 ) 

1711 ), 

1712] 

1713QueryHITLDetailTaskIdPrefixPatternSearch = Annotated[ 

1714 _PrefixSearchParam, 

1715 Depends( 

1716 prefix_search_param_factory( 

1717 TaskInstance.task_id, 

1718 "task_id_prefix_pattern", 

1719 ) 

1720 ), 

1721] 

1722QueryHITLDetailTaskIdFilter = Annotated[ 

1723 FilterParam[str | None], 

1724 Depends( 

1725 filter_param_factory( 

1726 TaskInstance.task_id, 

1727 str | None, 

1728 filter_name="task_id", 

1729 ) 

1730 ), 

1731] 

1732QueryHITLDetailMapIndexFilter = Annotated[ 

1733 FilterParam[int | None], 

1734 Depends( 

1735 filter_param_factory( 

1736 TaskInstance.map_index, 

1737 int | None, 

1738 filter_name="map_index", 

1739 ) 

1740 ), 

1741] 

1742QueryHITLDetailSubjectSearch = Annotated[ 

1743 _SearchParam, 

1744 Depends( 

1745 search_param_factory( 

1746 HITLDetail.subject, 

1747 "subject_search", 

1748 ) 

1749 ), 

1750] 

1751QueryHITLDetailBodySearch = Annotated[ 

1752 _SearchParam, 

1753 Depends( 

1754 search_param_factory( 

1755 HITLDetail.body, 

1756 "body_search", 

1757 ) 

1758 ), 

1759] 

1760QueryHITLDetailResponseReceivedFilter = Annotated[ 

1761 FilterParam[bool | None], 

1762 Depends( 

1763 filter_param_factory( 

1764 HITLDetail.response_received, 

1765 bool | None, 

1766 filter_name="response_received", 

1767 ) 

1768 ), 

1769] 

1770QueryHITLDetailRespondedUserIdFilter = Annotated[ 

1771 FilterParam[list[str]], 

1772 Depends( 

1773 filter_param_factory( 

1774 HITLDetail.responded_by_user_id, 

1775 list[str], 

1776 FilterOptionEnum.ANY_EQUAL, 

1777 default_factory=list, 

1778 filter_name="responded_by_user_id", 

1779 ) 

1780 ), 

1781] 

1782QueryHITLDetailRespondedUserNameFilter = Annotated[ 

1783 FilterParam[list[str]], 

1784 Depends( 

1785 filter_param_factory( 

1786 HITLDetail.responded_by_user_name, 

1787 list[str], 

1788 FilterOptionEnum.ANY_EQUAL, 

1789 default_factory=list, 

1790 filter_name="responded_by_user_name", 

1791 ) 

1792 ), 

1793] 

1794 

1795# Parse Import Errors 

1796QueryParseImportErrorFilenamePatternSearch = Annotated[ 

1797 _SearchParam, Depends(search_param_factory(ParseImportError.filename, "filename_pattern")) 

1798] 

1799QueryParseImportErrorFilenamePrefixPatternSearch = Annotated[ 

1800 _PrefixSearchParam, 

1801 Depends(prefix_search_param_factory(ParseImportError.filename, "filename_prefix_pattern")), 

1802] 

1803QueryParseImportErrorFilenameFilter = Annotated[ 

1804 FilterParam, 

1805 Depends( 

1806 filter_param_factory( 

1807 ParseImportError.filename, 

1808 str | None, 

1809 filter_name="filename", 

1810 description="Exact filename match. Returns only the import error for this specific file path.", 

1811 ) 

1812 ), 

1813] 

1814QueryParseImportErrorBundleNameFilter = Annotated[ 

1815 FilterParam, 

1816 Depends( 

1817 filter_param_factory( 

1818 ParseImportError.bundle_name, 

1819 str | None, 

1820 filter_name="bundle_name", 

1821 description="Exact bundle name match. Returns only import errors from this specific bundle.", 

1822 ) 

1823 ), 

1824]