From 737f6fc379d62826bcafb2100fd5c51c641881f8 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Tue, 6 Jan 2026 16:02:44 -0600 Subject: [PATCH] Add wildcard and multi-value support for tag/search filters (#18) - Tag filters now support wildcards (*) converted to SQL LIKE (%) - Tag filters now support comma-separated multiple values - Applied to /api/v1/artifacts, /api/v1/tags, and /api/v1/uploads endpoints --- backend/app/routes.py | 74 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/backend/app/routes.py b/backend/app/routes.py index 4eb01b2..7975746 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -2593,7 +2593,10 @@ def list_package_artifacts( def list_all_artifacts( project: Optional[str] = Query(None, description="Filter by project name"), package: Optional[str] = Query(None, description="Filter by package name"), - tag: Optional[str] = Query(None, description="Filter by tag name"), + tag: Optional[str] = Query( + None, + description="Filter by tag name. Supports wildcards (*) and comma-separated values", + ), content_type: Optional[str] = Query(None, description="Filter by content type"), min_size: Optional[int] = Query(None, ge=0, description="Minimum size in bytes"), max_size: Optional[int] = Query(None, ge=0, description="Maximum size in bytes"), @@ -2628,7 +2631,26 @@ def list_all_artifacts( if package: tag_query = tag_query.filter(Package.name == package) if tag: - tag_query = tag_query.filter(Tag.name == tag) + # Support multiple values (comma-separated) and wildcards (*) + tag_values = [t.strip() for t in tag.split(",") if t.strip()] + if len(tag_values) == 1: + tag_val = tag_values[0] + if "*" in tag_val: + # Wildcard: convert * to SQL LIKE % + tag_query = tag_query.filter( + Tag.name.ilike(tag_val.replace("*", "%")) + ) + else: + tag_query = tag_query.filter(Tag.name == tag_val) + else: + # Multiple values: check if any match (with wildcard support) + tag_conditions = [] + for tag_val in tag_values: + if "*" in tag_val: + tag_conditions.append(Tag.name.ilike(tag_val.replace("*", "%"))) + else: + tag_conditions.append(Tag.name == tag_val) + tag_query = tag_query.filter(or_(*tag_conditions)) artifact_ids = tag_query.distinct().subquery() query = query.filter(Artifact.id.in_(artifact_ids)) @@ -2722,7 +2744,10 @@ def list_all_artifacts( def list_all_tags( project: Optional[str] = Query(None, description="Filter by project name"), package: Optional[str] = Query(None, description="Filter by package name"), - search: Optional[str] = Query(None, description="Search by tag name"), + search: Optional[str] = Query( + None, + description="Search by tag name. Supports wildcards (*) and comma-separated values", + ), from_date: Optional[datetime] = Query( None, alias="from", description="Created after" ), @@ -2749,7 +2774,24 @@ def list_all_tags( if package: query = query.filter(Package.name == package) if search: - query = query.filter(Tag.name.ilike(f"%{search}%")) + # Support multiple values (comma-separated) and wildcards (*) + search_values = [s.strip() for s in search.split(",") if s.strip()] + if len(search_values) == 1: + search_val = search_values[0] + if "*" in search_val: + query = query.filter(Tag.name.ilike(search_val.replace("*", "%"))) + else: + query = query.filter(Tag.name.ilike(f"%{search_val}%")) + else: + search_conditions = [] + for search_val in search_values: + if "*" in search_val: + search_conditions.append( + Tag.name.ilike(search_val.replace("*", "%")) + ) + else: + search_conditions.append(Tag.name.ilike(f"%{search_val}%")) + query = query.filter(or_(*search_conditions)) if from_date: query = query.filter(Tag.created_at >= from_date) if to_date: @@ -4011,7 +4053,10 @@ def list_all_uploads( None, description="Filter by deduplication status" ), search: Optional[str] = Query(None, description="Search by original filename"), - tag: Optional[str] = Query(None, description="Filter by tag name"), + tag: Optional[str] = Query( + None, + description="Filter by tag name. Supports wildcards (*) and comma-separated values", + ), sort: Optional[str] = Query( None, description="Sort field: uploaded_at, original_name, size" ), @@ -4055,7 +4100,24 @@ def list_all_uploads( if search: query = query.filter(Upload.original_name.ilike(f"%{search}%")) if tag: - query = query.filter(Upload.tag_name == tag) + # Support multiple values (comma-separated) and wildcards (*) + tag_values = [t.strip() for t in tag.split(",") if t.strip()] + if len(tag_values) == 1: + tag_val = tag_values[0] + if "*" in tag_val: + query = query.filter(Upload.tag_name.ilike(tag_val.replace("*", "%"))) + else: + query = query.filter(Upload.tag_name == tag_val) + else: + tag_conditions = [] + for tag_val in tag_values: + if "*" in tag_val: + tag_conditions.append( + Upload.tag_name.ilike(tag_val.replace("*", "%")) + ) + else: + tag_conditions.append(Upload.tag_name == tag_val) + query = query.filter(or_(*tag_conditions)) # Validate and apply sorting valid_sort_fields = {