-
Notifications
You must be signed in to change notification settings - Fork 17.4k
Add partition_key and partition_key_pattern filters for asset events #64610
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
02b4b83
7f86185
3677209
54a42fa
0ee34eb
8ff1f1e
81464ec
39811e0
89e26ad
e837b15
586c210
be74e9c
f0b6405
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import re | ||
| from abc import ABC, abstractmethod | ||
| from collections.abc import Callable, Iterable, Sequence | ||
| from datetime import datetime | ||
|
|
@@ -515,6 +516,71 @@ def depends_search( | |
| return depends_search | ||
|
|
||
|
|
||
| class _RegexParam(BaseParam[str]): | ||
| """ | ||
| Filter using database-level regex matching (regexp_match). | ||
|
|
||
| SQLAlchemy's ``regexp_match`` is supported on PostgreSQL (``~`` operator), | ||
| MySQL/MariaDB (``REGEXP``), and SQLite (via Python ``re.match``). | ||
| Note: SQLite uses ``re.match`` semantics (anchored at the start of the | ||
| string), while PostgreSQL uses ``re.search`` (matches anywhere). | ||
| """ | ||
|
|
||
| def __init__(self, attribute: ColumnElement, skip_none: bool = True) -> None: | ||
| super().__init__(skip_none=skip_none) | ||
| self.attribute: ColumnElement = attribute | ||
|
|
||
| def to_orm(self, select: Select) -> Select: | ||
| if self.value is None and self.skip_none: | ||
| return select | ||
| return select.where(self.attribute.regexp_match(self.value)) | ||
|
|
||
|
hussein-awala marked this conversation as resolved.
|
||
| @classmethod | ||
| def depends(cls, *args: Any, **kwargs: Any) -> Self: | ||
| raise NotImplementedError("Use regex_param_factory instead, depends is not implemented.") | ||
|
|
||
|
|
||
| def _validate_regex_pattern(value: str | None) -> str | None: | ||
| """Validate that the regex pattern is enabled and syntactically correct.""" | ||
| if value is None: | ||
| return value | ||
| if not conf.getboolean("api", "enable_regexp_query_filters"): | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail="Regex query filters are disabled. Set [api] enable_regexp_query_filters = True to use them.", | ||
| ) | ||
|
hussein-awala marked this conversation as resolved.
Outdated
|
||
| try: | ||
| re.compile(value) | ||
| except re.error as e: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail=f"Invalid regular expression: {e}", | ||
| ) | ||
| return value | ||
|
hussein-awala marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| _DEFAULT_REGEX_DESCRIPTION = ( | ||
| "Regex filter. Uses database-native regex " | ||
| "(PostgreSQL ~ operator, MySQL REGEXP, SQLite re.match). " | ||
| "Note: on SQLite, matching is anchored at the start of the string." | ||
| ) | ||
|
pierrejeambrun marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def regex_param_factory( | ||
| attribute: ColumnElement, | ||
| pattern_name: str, | ||
| skip_none: bool = True, | ||
| description: str = _DEFAULT_REGEX_DESCRIPTION, | ||
| ) -> Callable[[str | None], _RegexParam]: | ||
| def depends_regex( | ||
| value: str | None = Query(alias=pattern_name, default=None, description=description), | ||
| ) -> _RegexParam: | ||
| _validate_regex_pattern(value) | ||
| return _RegexParam(attribute, skip_none).set_value(value) | ||
|
|
||
| return depends_regex | ||
|
hussein-awala marked this conversation as resolved.
|
||
|
|
||
|
|
||
| def prefix_search_param_factory( | ||
| attribute: ColumnElement, | ||
| prefix_pattern_name: str, | ||
|
|
@@ -1584,6 +1650,13 @@ def _transform_ti_states(states: list[str] | None) -> list[TaskInstanceState | N | |
| QueryAssetAliasNamePrefixPatternSearch = Annotated[ | ||
| _PrefixSearchParam, Depends(prefix_search_param_factory(AssetAliasModel.name, "name_prefix_pattern")) | ||
| ] | ||
| QueryAssetEventPartitionKeyFilter = Annotated[ | ||
| FilterParam[str | None], | ||
| Depends(filter_param_factory(AssetEvent.partition_key, str | None, filter_name="partition_key")), | ||
| ] | ||
| QueryAssetEventPartitionKeyRegex = Annotated[ | ||
| _RegexParam, Depends(regex_param_factory(AssetEvent.partition_key, "partition_key_pattern")) | ||
| ] | ||
|
Comment on lines
+1698
to
+1704
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The thing I wanted to prevent by moving the timeout inside the regex_param_factory or the filter class directly, is because if someone add a filter in here using the factory, and forget to call the So I was trying to be defensive for a future bug.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe in That doesn't cover 'all cases' someone could still use a bare _RegexpParam without using the And maybe something in the |
||
| QueryAssetDagIdPatternSearch = Annotated[ | ||
| _DagIdAssetReferenceFilter, Depends(_DagIdAssetReferenceFilter.depends) | ||
| ] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
|
|
||
| from __future__ import annotations | ||
|
|
||
| import re | ||
| from typing import Annotated | ||
|
|
||
| from fastapi import APIRouter, HTTPException, Query, status | ||
|
|
@@ -29,7 +30,9 @@ | |
| AssetEventResponse, | ||
| AssetEventsResponse, | ||
| ) | ||
| from airflow.configuration import conf | ||
| from airflow.models.asset import AssetAliasModel, AssetEvent, AssetModel | ||
| from airflow.utils.sqlalchemy import apply_regex_query_timeout | ||
|
|
||
| router = APIRouter( | ||
| responses={ | ||
|
|
@@ -44,7 +47,7 @@ def _get_asset_events_through_sql_clauses( | |
| ) -> AssetEventsResponse: | ||
| order_by_clause = AssetEvent.timestamp.asc() if ascending else AssetEvent.timestamp.desc() | ||
| asset_events_query = select(AssetEvent).join(join_clause).where(where_clause).order_by(order_by_clause) | ||
| if limit: | ||
| if limit is not None: | ||
|
hussein-awala marked this conversation as resolved.
Outdated
|
||
| asset_events_query = asset_events_query.limit(limit) | ||
| asset_events = session.scalars(asset_events_query) | ||
|
hussein-awala marked this conversation as resolved.
Outdated
|
||
| return AssetEventsResponse.model_validate( | ||
|
|
@@ -73,6 +76,40 @@ def _get_asset_events_through_sql_clauses( | |
| ) | ||
|
|
||
|
|
||
| def _validate_partition_key_params(partition_key: str | None, partition_key_pattern: str | None) -> None: | ||
| """Validate partition_key and partition_key_pattern are not both set, and that the pattern is valid regex.""" | ||
| if partition_key is not None and partition_key_pattern is not None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail={ | ||
| "reason": "Mutually exclusive parameters", | ||
| "message": "partition_key and partition_key_pattern cannot both be provided. " | ||
| "Use partition_key for exact match or partition_key_pattern for regex filtering.", | ||
| }, | ||
| ) | ||
| if partition_key_pattern is None: | ||
| return | ||
|
hussein-awala marked this conversation as resolved.
Outdated
|
||
| if not conf.getboolean("api", "enable_regexp_query_filters"): | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail={ | ||
| "reason": "Regex query filters disabled", | ||
| "message": "Regex query filters are disabled. " | ||
| "Set [api] enable_regexp_query_filters = True to use partition_key_pattern.", | ||
| }, | ||
| ) | ||
| try: | ||
| re.compile(partition_key_pattern) | ||
| except re.error as e: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
|
pierrejeambrun marked this conversation as resolved.
Outdated
|
||
| detail={ | ||
| "reason": "Invalid regex", | ||
| "message": f"The partition_key_pattern is not a valid regular expression: {e}", | ||
| }, | ||
| ) | ||
|
|
||
|
|
||
| @router.get("/by-asset") | ||
| def get_asset_event_by_asset_name_uri( | ||
| name: Annotated[str | None, Query(description="The name of the Asset")], | ||
|
|
@@ -82,6 +119,18 @@ def get_asset_event_by_asset_name_uri( | |
| before: Annotated[UtcDateTime | None, Query(description="The end of the time range")] = None, | ||
| ascending: Annotated[bool, Query(description="Whether to sort results in ascending order")] = True, | ||
| limit: Annotated[int | None, Query(description="The maximum number of results to return")] = None, | ||
| partition_key: Annotated[ | ||
| str | None, | ||
| Query(description="Exact partition key filter."), | ||
| ] = None, | ||
| partition_key_pattern: Annotated[ | ||
| str | None, | ||
| Query( | ||
| description="Partition key regex filter. Uses database-native regex " | ||
| "(PostgreSQL ~ operator, MySQL REGEXP, SQLite re.match). " | ||
| "Note: on SQLite, matching is anchored at the start of the string." | ||
| ), | ||
| ] = None, | ||
| ) -> AssetEventsResponse: | ||
| if name and uri: | ||
| where_clause = and_(AssetModel.name == name, AssetModel.uri == uri) | ||
|
|
@@ -103,6 +152,13 @@ def get_asset_event_by_asset_name_uri( | |
| if before: | ||
| where_clause = and_(where_clause, AssetEvent.timestamp <= before) | ||
|
|
||
| _validate_partition_key_params(partition_key, partition_key_pattern) | ||
| if partition_key is not None: | ||
| where_clause = and_(where_clause, AssetEvent.partition_key == partition_key) | ||
| elif partition_key_pattern is not None: | ||
| apply_regex_query_timeout(session) | ||
| where_clause = and_(where_clause, AssetEvent.partition_key.regexp_match(partition_key_pattern)) | ||
|
|
||
| return _get_asset_events_through_sql_clauses( | ||
|
hussein-awala marked this conversation as resolved.
|
||
| join_clause=AssetEvent.asset, | ||
| where_clause=where_clause, | ||
|
|
@@ -120,13 +176,32 @@ def get_asset_event_by_asset_alias( | |
| before: Annotated[UtcDateTime | None, Query(description="The end of the time range")] = None, | ||
| ascending: Annotated[bool, Query(description="Whether to sort results in ascending order")] = True, | ||
| limit: Annotated[int | None, Query(description="The maximum number of results to return")] = None, | ||
| partition_key: Annotated[ | ||
| str | None, | ||
| Query(description="Exact partition key filter."), | ||
| ] = None, | ||
| partition_key_pattern: Annotated[ | ||
| str | None, | ||
| Query( | ||
| description="Partition key regex filter. Uses database-native regex " | ||
| "(PostgreSQL ~ operator, MySQL REGEXP, SQLite re.match). " | ||
| "Note: on SQLite, matching is anchored at the start of the string." | ||
| ), | ||
| ] = None, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not use This create duplicated validation code in the view
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, they were different when I started the implementation but it is not the case now. The execution routes now depend on the shared |
||
| ) -> AssetEventsResponse: | ||
| where_clause = AssetAliasModel.name == name | ||
| if after: | ||
| where_clause = and_(where_clause, AssetEvent.timestamp >= after) | ||
| if before: | ||
| where_clause = and_(where_clause, AssetEvent.timestamp <= before) | ||
|
|
||
| _validate_partition_key_params(partition_key, partition_key_pattern) | ||
| if partition_key is not None: | ||
| where_clause = and_(where_clause, AssetEvent.partition_key == partition_key) | ||
| elif partition_key_pattern is not None: | ||
| apply_regex_query_timeout(session) | ||
| where_clause = and_(where_clause, AssetEvent.partition_key.regexp_match(partition_key_pattern)) | ||
|
|
||
| return _get_asset_events_through_sql_clauses( | ||
| join_clause=AssetEvent.source_aliases, | ||
| where_clause=where_clause, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.