Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 98 additions & 5 deletions app/assets/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
AssetValidationError,
UploadError,
)
from app.assets.helpers import validate_blake3_hash
from app.assets.helpers import normalize_tags, validate_blake3_hash
from app.assets.api.upload import (
delete_temp_file_if_exists,
parse_multipart_upload,
Expand Down Expand Up @@ -117,6 +117,87 @@ def _build_validation_error_response(code: str, ve: ValidationError) -> web.Resp
return _build_error_response(400, code, "Validation failed.", {"errors": errors})


class InvalidTagFilterError(Exception):
"""Invalid combination of tag-filter query parameters."""

def __init__(self, message: str, details: dict):
super().__init__(message)
self.details = details


# Caps the per-tag EXISTS fan-out; deliberately covers the legacy spellings too.
MAX_TAG_FILTER_TAGS = 100


def _resolve_tag_filters(
q: schemas_in.ListAssetsQuery | schemas_in.TagsRefineQuery,
) -> tuple[list[str], list[str], list[str]]:
"""Resolve legacy (include/exclude) and new (all/any/none) tag-filter
spellings into effective (all, any, none) lists.

Combination validation applies only when the request uses at least one
new-name parameter (non-empty after normalisation); requests using only
the legacy names keep their historical behaviour, including degenerate
combinations like include_tags=a&exclude_tags=a.
"""
# model_dump, not attribute access: deprecated fields warn on every attribute read.
legacy = q.model_dump(include={"include_tags", "exclude_tags"})
include_tags = normalize_tags(legacy["include_tags"])
exclude_tags = normalize_tags(legacy["exclude_tags"])
tags_all = normalize_tags(q.tags_all)
tags_any = normalize_tags(q.tags_any)
tags_none = normalize_tags(q.tags_none)

for param_name, values in (
("include_tags", include_tags),
("exclude_tags", exclude_tags),
("tags_all", tags_all),
("tags_any", tags_any),
("tags_none", tags_none),
):
if len(values) > MAX_TAG_FILTER_TAGS:
raise InvalidTagFilterError(
f"'{param_name}' lists {len(values)} tags; the maximum is "
f"{MAX_TAG_FILTER_TAGS}.",
{
"parameter": param_name,
"count": len(values),
"max": MAX_TAG_FILTER_TAGS,
},
)

if not (tags_all or tags_any or tags_none):
return include_tags, [], exclude_tags

if include_tags and tags_all:
raise InvalidTagFilterError(
"Cannot combine 'include_tags' with 'tags_all'; use 'tags_all'.",
{"parameters": ["include_tags", "tags_all"]},
)
if exclude_tags and tags_none:
raise InvalidTagFilterError(
"Cannot combine 'exclude_tags' with 'tags_none'; use 'tags_none'.",
{"parameters": ["exclude_tags", "tags_none"]},
)

all_param, all_list = (
("tags_all", tags_all) if tags_all else ("include_tags", include_tags)
)
none_param, none_list = (
("tags_none", tags_none) if tags_none else ("exclude_tags", exclude_tags)
)

conflicting = sorted(set(all_list) & set(none_list))
if conflicting:
raise InvalidTagFilterError(
f"Query can never match: {', '.join(repr(t) for t in conflicting)} "
f"required by '{all_param}' but rejected by '{none_param}'.",
{"conflicting_tags": conflicting, "parameters": [all_param, none_param]},
)

return all_list, tags_any, none_list


def _validate_sort_field(requested: str | None) -> str:
if not requested:
return "created_at"
Expand Down Expand Up @@ -217,15 +298,21 @@ async def list_assets_route(request: web.Request) -> web.Response:
except ValidationError as ve:
return _build_validation_error_response("INVALID_QUERY", ve)

try:
tags_all, tags_any, tags_none = _resolve_tag_filters(q)
except InvalidTagFilterError as e:
return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details)

sort = _validate_sort_field(q.sort)
order_candidate = (q.order or "desc").lower()
order = order_candidate if order_candidate in {"asc", "desc"} else "desc"

try:
result = list_assets_page(
owner_id=USER_MANAGER.get_request_user_id(request),
include_tags=q.include_tags,
exclude_tags=q.exclude_tags,
include_tags=tags_all,
exclude_tags=tags_none,
any_tags=tags_any,
name_contains=q.name_contains,
metadata_filter=q.metadata_filter,
limit=q.limit,
Expand Down Expand Up @@ -715,10 +802,16 @@ async def get_tags_refine(request: web.Request) -> web.Response:
except ValidationError as ve:
return _build_validation_error_response("INVALID_QUERY", ve)

try:
tags_all, tags_any, tags_none = _resolve_tag_filters(q)
except InvalidTagFilterError as e:
return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details)

tag_counts = list_tag_histogram(
owner_id=USER_MANAGER.get_request_user_id(request),
include_tags=q.include_tags,
exclude_tags=q.exclude_tags,
include_tags=tags_all,
exclude_tags=tags_none,
any_tags=tags_any,
name_contains=q.name_contains,
metadata_filter=q.metadata_filter,
limit=q.limit,
Expand Down
26 changes: 20 additions & 6 deletions app/assets/api/schemas_in.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,12 @@ class ParsedUpload:


class ListAssetsQuery(BaseModel):
include_tags: list[str] = Field(default_factory=list)
exclude_tags: list[str] = Field(default_factory=list)
# Deprecated spellings: include_tags ≡ tags_all, exclude_tags ≡ tags_none.
include_tags: list[str] = Field(default_factory=list, deprecated=True)
exclude_tags: list[str] = Field(default_factory=list, deprecated=True)
tags_all: list[str] = Field(default_factory=list)
tags_any: list[str] = Field(default_factory=list)
tags_none: list[str] = Field(default_factory=list)
name_contains: str | None = None

# Accept either a JSON string (query param) or a dict
Expand All @@ -70,7 +74,10 @@ class ListAssetsQuery(BaseModel):
)
order: Literal["asc", "desc"] = "desc"

@field_validator("include_tags", "exclude_tags", mode="before")
@field_validator(
"include_tags", "exclude_tags", "tags_all", "tags_any", "tags_none",
mode="before",
)
@classmethod
def _split_csv_tags(cls, v):
# Accept "a,b,c" or ["a","b"] (we are liberal in what we accept)
Expand Down Expand Up @@ -154,13 +161,20 @@ def _normalize_tags_field(cls, v):


class TagsRefineQuery(BaseModel):
include_tags: list[str] = Field(default_factory=list)
exclude_tags: list[str] = Field(default_factory=list)
# Deprecated spellings: include_tags ≡ tags_all, exclude_tags ≡ tags_none.
include_tags: list[str] = Field(default_factory=list, deprecated=True)
exclude_tags: list[str] = Field(default_factory=list, deprecated=True)
tags_all: list[str] = Field(default_factory=list)
tags_any: list[str] = Field(default_factory=list)
tags_none: list[str] = Field(default_factory=list)
name_contains: str | None = None
metadata_filter: dict[str, Any] | None = None
limit: conint(ge=1, le=1000) = 100

@field_validator("include_tags", "exclude_tags", mode="before")
@field_validator(
"include_tags", "exclude_tags", "tags_all", "tags_any", "tags_none",
mode="before",
)
@classmethod
def _split_csv_tags(cls, v):
if v is None:
Expand Down
6 changes: 4 additions & 2 deletions app/assets/database/queries/asset_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ def list_references_page(
order: str | None = None,
after_cursor_value: object | None = None,
after_cursor_id: str | None = None,
# Appended last so pre-existing positional callers keep binding correctly.
any_tags: Sequence[str] | None = None,
) -> tuple[list[AssetReference], dict[str, list[str]], int]:
"""List references with pagination, filtering, and sorting.

Expand All @@ -293,7 +295,7 @@ def list_references_page(
escaped, esc = escape_sql_like_string(name_contains)
base = base.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))

base = apply_tag_filters(base, include_tags, exclude_tags)
base = apply_tag_filters(base, include_tags, exclude_tags, any_tags)
base = apply_metadata_filter(base, metadata_filter)

sort = (sort or "created_at").lower()
Expand Down Expand Up @@ -345,7 +347,7 @@ def list_references_page(
count_stmt = count_stmt.where(
AssetReference.name.ilike(f"%{escaped}%", escape=esc)
)
count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags)
count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags, any_tags)
count_stmt = apply_metadata_filter(count_stmt, metadata_filter)

total = int(session.execute(count_stmt).scalar_one() or 0)
Expand Down
13 changes: 12 additions & 1 deletion app/assets/database/queries/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,13 @@ def apply_tag_filters(
stmt: sa.sql.Select,
include_tags: Sequence[str] | None = None,
exclude_tags: Sequence[str] | None = None,
any_tags: Sequence[str] | None = None,
) -> sa.sql.Select:
"""include_tags: every tag must be present; exclude_tags: none may be present."""
"""include_tags: every tag must be present; any_tags: at least one must be
present; exclude_tags: none may be present."""
include_tags = normalize_tags(include_tags)
exclude_tags = normalize_tags(exclude_tags)
any_tags = normalize_tags(any_tags)

if include_tags:
for tag_name in include_tags:
Expand All @@ -74,6 +77,14 @@ def apply_tag_filters(
)
)

if any_tags:
stmt = stmt.where(
exists().where(
(AssetReferenceTag.asset_reference_id == AssetReference.id)
& (AssetReferenceTag.tag_name.in_(any_tags))
)
)

if exclude_tags:
stmt = stmt.where(
~exists().where(
Expand Down
4 changes: 3 additions & 1 deletion app/assets/database/queries/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,8 @@ def list_tag_counts_for_filtered_assets(
name_contains: str | None = None,
metadata_filter: dict | None = None,
limit: int = 100,
# Appended last so pre-existing positional callers keep binding correctly.
any_tags: Sequence[str] | None = None,
) -> dict[str, int]:
"""Return tag counts for assets matching the given filters.

Expand All @@ -359,7 +361,7 @@ def list_tag_counts_for_filtered_assets(
escaped, esc = escape_sql_like_string(name_contains)
ref_sq = ref_sq.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc))

ref_sq = apply_tag_filters(ref_sq, include_tags, exclude_tags)
ref_sq = apply_tag_filters(ref_sq, include_tags, exclude_tags, any_tags)
ref_sq = apply_metadata_filter(ref_sq, metadata_filter)
ref_sq = ref_sq.subquery()

Expand Down
3 changes: 3 additions & 0 deletions app/assets/services/asset_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ def list_assets_page(
sort: str = "created_at",
order: str = "desc",
after: str | None = None,
# Appended last so pre-existing positional callers keep binding correctly.
any_tags: Sequence[str] | None = None,
) -> ListAssetsResult:
"""List assets with optional cursor pagination.

Expand Down Expand Up @@ -317,6 +319,7 @@ def list_assets_page(
owner_id=owner_id,
include_tags=include_tags,
exclude_tags=exclude_tags,
any_tags=any_tags,
name_contains=name_contains,
metadata_filter=metadata_filter,
limit=fetch_limit,
Expand Down
3 changes: 3 additions & 0 deletions app/assets/services/tagging.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,16 @@ def list_tag_histogram(
name_contains: str | None = None,
metadata_filter: dict | None = None,
limit: int = 100,
# Appended last so pre-existing positional callers keep binding correctly.
any_tags: Sequence[str] | None = None,
) -> dict[str, int]:
with create_session() as session:
return list_tag_counts_for_filtered_assets(
session,
owner_id=owner_id,
include_tags=include_tags,
exclude_tags=exclude_tags,
any_tags=any_tags,
name_contains=name_contains,
metadata_filter=metadata_filter,
limit=limit,
Expand Down
Loading
Loading