From 2adbc8419a9df7a64b92837682879da1ff945205 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Mon, 26 Jan 2026 15:39:53 +0100 Subject: [PATCH 1/2] add more decorators for filtering, sorting and searching --- backend/api/decorators/__init__.py | 19 +- backend/api/decorators/filter_sort.py | 624 +++++++++++++++++++++ backend/api/decorators/full_text_search.py | 112 ++++ backend/api/inputs.py | 113 ++++ backend/api/resolvers/patient.py | 22 +- backend/api/resolvers/task.py | 33 +- backend/api/resolvers/user.py | 25 +- 7 files changed, 924 insertions(+), 24 deletions(-) create mode 100644 backend/api/decorators/filter_sort.py create mode 100644 backend/api/decorators/full_text_search.py diff --git a/backend/api/decorators/__init__.py b/backend/api/decorators/__init__.py index 5c8500d2..ef535125 100644 --- a/backend/api/decorators/__init__.py +++ b/backend/api/decorators/__init__.py @@ -1,3 +1,20 @@ +from api.decorators.filter_sort import ( + apply_filtering, + apply_sorting, + filtered_and_sorted_query, +) +from api.decorators.full_text_search import ( + apply_full_text_search, + full_text_search_query, +) from api.decorators.pagination import apply_pagination, paginated_query -__all__ = ["apply_pagination", "paginated_query"] +__all__ = [ + "apply_pagination", + "paginated_query", + "apply_sorting", + "apply_filtering", + "filtered_and_sorted_query", + "apply_full_text_search", + "full_text_search_query", +] diff --git a/backend/api/decorators/filter_sort.py b/backend/api/decorators/filter_sort.py new file mode 100644 index 00000000..2b843a2b --- /dev/null +++ b/backend/api/decorators/filter_sort.py @@ -0,0 +1,624 @@ +from datetime import date as date_type +from functools import wraps +from typing import Any, Callable, TypeVar + +import strawberry +from api.decorators.pagination import apply_pagination +from api.inputs import ( + ColumnType, + FilterInput, + FilterOperator, + PaginationInput, + SortDirection, + SortInput, +) +from database import models +from database.models.base import Base +from sqlalchemy import Select, and_, func, or_, select +from sqlalchemy.orm import aliased + +T = TypeVar("T") + + +def detect_entity_type(model_class: type[Base]) -> str | None: + if model_class == models.Patient: + return "patient" + if model_class == models.Task: + return "task" + return None + + +def get_property_value_column(field_type: str) -> str: + field_type_mapping = { + "FIELD_TYPE_TEXT": "text_value", + "FIELD_TYPE_NUMBER": "number_value", + "FIELD_TYPE_CHECKBOX": "boolean_value", + "FIELD_TYPE_DATE": "date_value", + "FIELD_TYPE_DATE_TIME": "date_time_value", + "FIELD_TYPE_SELECT": "select_value", + "FIELD_TYPE_MULTI_SELECT": "multi_select_values", + } + return field_type_mapping.get(field_type, "text_value") + + +def get_property_join_alias( + query: Select[Any], + model_class: type[Base], + property_definition_id: str, + field_type: str, +) -> Any: + entity_type = detect_entity_type(model_class) + if not entity_type: + raise ValueError(f"Unsupported entity type for property filtering: {model_class}") + + property_alias = aliased(models.PropertyValue) + value_column = get_property_value_column(field_type) + + if entity_type == "patient": + join_condition = and_( + property_alias.patient_id == model_class.id, + property_alias.definition_id == property_definition_id, + ) + else: + join_condition = and_( + property_alias.task_id == model_class.id, + property_alias.definition_id == property_definition_id, + ) + + query = query.outerjoin(property_alias, join_condition) + return query, property_alias, getattr(property_alias, value_column) + + +def apply_sorting( + query: Select[Any], + sorting: list[SortInput] | None, + model_class: type[Base], + property_field_types: dict[str, str] | None = None, +) -> Select[Any]: + if not sorting: + return query + + order_by_clauses = [] + property_field_types = property_field_types or {} + + for sort_input in sorting: + if sort_input.column_type == ColumnType.DIRECT_ATTRIBUTE: + try: + column = getattr(model_class, sort_input.column) + if sort_input.direction == SortDirection.DESC: + order_by_clauses.append(column.desc()) + else: + order_by_clauses.append(column.asc()) + except AttributeError: + continue + + elif sort_input.column_type == ColumnType.PROPERTY: + if not sort_input.property_definition_id: + continue + + field_type = property_field_types.get( + sort_input.property_definition_id, "FIELD_TYPE_TEXT" + ) + query, property_alias, value_column = get_property_join_alias( + query, model_class, sort_input.property_definition_id, field_type + ) + + if sort_input.direction == SortDirection.DESC: + order_by_clauses.append(value_column.desc().nulls_last()) + else: + order_by_clauses.append(value_column.asc().nulls_first()) + + if order_by_clauses: + query = query.order_by(*order_by_clauses) + + return query + + +def apply_text_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: + search_text = parameter.search_text + if search_text is None: + return None + + if operator == FilterOperator.TEXT_EQUALS: + return column.ilike(search_text) + if operator == FilterOperator.TEXT_NOT_EQUALS: + return ~column.ilike(search_text) + if operator == FilterOperator.TEXT_NOT_WHITESPACE: + return func.trim(column) != "" + if operator == FilterOperator.TEXT_CONTAINS: + return column.ilike(f"%{search_text}%") + if operator == FilterOperator.TEXT_NOT_CONTAINS: + return ~column.ilike(f"%{search_text}%") + if operator == FilterOperator.TEXT_STARTS_WITH: + return column.ilike(f"{search_text}%") + if operator == FilterOperator.TEXT_ENDS_WITH: + return column.ilike(f"%{search_text}") + + return None + + +def apply_number_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: + compare_value = parameter.compare_value + min_value = parameter.min + max_value = parameter.max + + if operator == FilterOperator.NUMBER_EQUALS: + if compare_value is not None: + return column == compare_value + elif operator == FilterOperator.NUMBER_NOT_EQUALS: + if compare_value is not None: + return column != compare_value + elif operator == FilterOperator.NUMBER_GREATER_THAN: + if compare_value is not None: + return column > compare_value + elif operator == FilterOperator.NUMBER_GREATER_THAN_OR_EQUAL: + if compare_value is not None: + return column >= compare_value + elif operator == FilterOperator.NUMBER_LESS_THAN: + if compare_value is not None: + return column < compare_value + elif operator == FilterOperator.NUMBER_LESS_THAN_OR_EQUAL: + if compare_value is not None: + return column <= compare_value + elif operator == FilterOperator.NUMBER_BETWEEN: + if min_value is not None and max_value is not None: + return column.between(min_value, max_value) + elif operator == FilterOperator.NUMBER_NOT_BETWEEN: + if min_value is not None and max_value is not None: + return ~column.between(min_value, max_value) + + return None + + +def normalize_date_for_comparison(date_value: Any) -> Any: + return date_value + + +def apply_date_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: + compare_date = parameter.compare_date + min_date = parameter.min_date + max_date = parameter.max_date + + if operator == FilterOperator.DATE_EQUALS: + if compare_date is not None: + if isinstance(compare_date, date_type): + return func.date(column) == compare_date + return column == compare_date + elif operator == FilterOperator.DATE_NOT_EQUALS: + if compare_date is not None: + if isinstance(compare_date, date_type): + return func.date(column) != compare_date + return column != compare_date + elif operator == FilterOperator.DATE_GREATER_THAN: + if compare_date is not None: + if isinstance(compare_date, date_type): + return func.date(column) > compare_date + return column > compare_date + elif operator == FilterOperator.DATE_GREATER_THAN_OR_EQUAL: + if compare_date is not None: + if isinstance(compare_date, date_type): + return func.date(column) >= compare_date + return column >= compare_date + elif operator == FilterOperator.DATE_LESS_THAN: + if compare_date is not None: + if isinstance(compare_date, date_type): + return func.date(column) < compare_date + return column < compare_date + elif operator == FilterOperator.DATE_LESS_THAN_OR_EQUAL: + if compare_date is not None: + if isinstance(compare_date, date_type): + return func.date(column) <= compare_date + return column <= compare_date + elif operator == FilterOperator.DATE_BETWEEN: + if min_date is not None and max_date is not None: + if isinstance(min_date, date_type) and isinstance(max_date, date_type): + return func.date(column).between(min_date, max_date) + return column.between(min_date, max_date) + elif operator == FilterOperator.DATE_NOT_BETWEEN: + if min_date is not None and max_date is not None: + if isinstance(min_date, date_type) and isinstance(max_date, date_type): + return ~func.date(column).between(min_date, max_date) + return ~column.between(min_date, max_date) + + return None + + +def apply_datetime_filter( + column: Any, operator: FilterOperator, parameter: Any +) -> Any: + compare_date_time = parameter.compare_date_time + min_date_time = parameter.min_date_time + max_date_time = parameter.max_date_time + + if operator == FilterOperator.DATETIME_EQUALS: + if compare_date_time is not None: + return column == compare_date_time + elif operator == FilterOperator.DATETIME_NOT_EQUALS: + if compare_date_time is not None: + return column != compare_date_time + elif operator == FilterOperator.DATETIME_GREATER_THAN: + if compare_date_time is not None: + return column > compare_date_time + elif operator == FilterOperator.DATETIME_GREATER_THAN_OR_EQUAL: + if compare_date_time is not None: + return column >= compare_date_time + elif operator == FilterOperator.DATETIME_LESS_THAN: + if compare_date_time is not None: + return column < compare_date_time + elif operator == FilterOperator.DATETIME_LESS_THAN_OR_EQUAL: + if compare_date_time is not None: + return column <= compare_date_time + elif operator == FilterOperator.DATETIME_BETWEEN: + if min_date_time is not None and max_date_time is not None: + return column.between(min_date_time, max_date_time) + elif operator == FilterOperator.DATETIME_NOT_BETWEEN: + if min_date_time is not None and max_date_time is not None: + return ~column.between(min_date_time, max_date_time) + + return None + + +def apply_boolean_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: + if operator == FilterOperator.BOOLEAN_IS_TRUE: + return column.is_(True) + if operator == FilterOperator.BOOLEAN_IS_FALSE: + return column.is_(False) + return None + + +def apply_tags_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: + search_tags = parameter.search_tags + if not search_tags: + return None + + if operator == FilterOperator.TAGS_EQUALS: + tags_str = ",".join(sorted(search_tags)) + return column == tags_str + if operator == FilterOperator.TAGS_NOT_EQUALS: + tags_str = ",".join(sorted(search_tags)) + return column != tags_str + if operator == FilterOperator.TAGS_CONTAINS: + conditions = [] + for tag in search_tags: + conditions.append(column.contains(tag)) + return and_(*conditions) + if operator == FilterOperator.TAGS_NOT_CONTAINS: + conditions = [] + for tag in search_tags: + conditions.append(~column.contains(tag)) + return or_(*conditions) + + return None + + +def apply_tags_single_filter( + column: Any, operator: FilterOperator, parameter: Any +) -> Any: + search_tags = parameter.search_tags + if not search_tags: + return None + + if operator == FilterOperator.TAGS_SINGLE_EQUALS: + if len(search_tags) == 1: + return column == search_tags[0] + if operator == FilterOperator.TAGS_SINGLE_NOT_EQUALS: + if len(search_tags) == 1: + return column != search_tags[0] + if operator == FilterOperator.TAGS_SINGLE_CONTAINS: + conditions = [] + for tag in search_tags: + conditions.append(column == tag) + return or_(*conditions) + if operator == FilterOperator.TAGS_SINGLE_NOT_CONTAINS: + conditions = [] + for tag in search_tags: + conditions.append(column != tag) + return and_(*conditions) + + return None + + +def apply_null_filter(column: Any, operator: FilterOperator, parameter: Any) -> Any: + if operator == FilterOperator.IS_NULL: + return column.is_(None) + if operator == FilterOperator.IS_NOT_NULL: + return column.isnot(None) + return None + + +def apply_filtering( + query: Select[Any], + filtering: list[FilterInput] | None, + model_class: type[Base], + property_field_types: dict[str, str] | None = None, +) -> Select[Any]: + if not filtering: + return query + + filter_conditions = [] + property_field_types = property_field_types or {} + + for filter_input in filtering: + condition = None + + if filter_input.column_type == ColumnType.DIRECT_ATTRIBUTE: + try: + column = getattr(model_class, filter_input.column) + except AttributeError: + continue + + operator = filter_input.operator + parameter = filter_input.parameter + + if operator in [ + FilterOperator.TEXT_EQUALS, + FilterOperator.TEXT_NOT_EQUALS, + FilterOperator.TEXT_NOT_WHITESPACE, + FilterOperator.TEXT_CONTAINS, + FilterOperator.TEXT_NOT_CONTAINS, + FilterOperator.TEXT_STARTS_WITH, + FilterOperator.TEXT_ENDS_WITH, + ]: + condition = apply_text_filter(column, operator, parameter) + + elif operator in [ + FilterOperator.NUMBER_EQUALS, + FilterOperator.NUMBER_NOT_EQUALS, + FilterOperator.NUMBER_GREATER_THAN, + FilterOperator.NUMBER_GREATER_THAN_OR_EQUAL, + FilterOperator.NUMBER_LESS_THAN, + FilterOperator.NUMBER_LESS_THAN_OR_EQUAL, + FilterOperator.NUMBER_BETWEEN, + FilterOperator.NUMBER_NOT_BETWEEN, + ]: + condition = apply_number_filter(column, operator, parameter) + + elif operator in [ + FilterOperator.DATE_EQUALS, + FilterOperator.DATE_NOT_EQUALS, + FilterOperator.DATE_GREATER_THAN, + FilterOperator.DATE_GREATER_THAN_OR_EQUAL, + FilterOperator.DATE_LESS_THAN, + FilterOperator.DATE_LESS_THAN_OR_EQUAL, + FilterOperator.DATE_BETWEEN, + FilterOperator.DATE_NOT_BETWEEN, + ]: + condition = apply_date_filter(column, operator, parameter) + + elif operator in [ + FilterOperator.DATETIME_EQUALS, + FilterOperator.DATETIME_NOT_EQUALS, + FilterOperator.DATETIME_GREATER_THAN, + FilterOperator.DATETIME_GREATER_THAN_OR_EQUAL, + FilterOperator.DATETIME_LESS_THAN, + FilterOperator.DATETIME_LESS_THAN_OR_EQUAL, + FilterOperator.DATETIME_BETWEEN, + FilterOperator.DATETIME_NOT_BETWEEN, + ]: + condition = apply_datetime_filter(column, operator, parameter) + + elif operator in [ + FilterOperator.BOOLEAN_IS_TRUE, + FilterOperator.BOOLEAN_IS_FALSE, + ]: + condition = apply_boolean_filter(column, operator, parameter) + + elif operator in [ + FilterOperator.IS_NULL, + FilterOperator.IS_NOT_NULL, + ]: + condition = apply_null_filter(column, operator, parameter) + + elif filter_input.column_type == ColumnType.PROPERTY: + if not filter_input.property_definition_id: + continue + + field_type = property_field_types.get( + filter_input.property_definition_id, "FIELD_TYPE_TEXT" + ) + query, property_alias, value_column = get_property_join_alias( + query, model_class, filter_input.property_definition_id, field_type + ) + + operator = filter_input.operator + parameter = filter_input.parameter + + if operator in [ + FilterOperator.TEXT_EQUALS, + FilterOperator.TEXT_NOT_EQUALS, + FilterOperator.TEXT_NOT_WHITESPACE, + FilterOperator.TEXT_CONTAINS, + FilterOperator.TEXT_NOT_CONTAINS, + FilterOperator.TEXT_STARTS_WITH, + FilterOperator.TEXT_ENDS_WITH, + ]: + condition = apply_text_filter(value_column, operator, parameter) + + elif operator in [ + FilterOperator.NUMBER_EQUALS, + FilterOperator.NUMBER_NOT_EQUALS, + FilterOperator.NUMBER_GREATER_THAN, + FilterOperator.NUMBER_GREATER_THAN_OR_EQUAL, + FilterOperator.NUMBER_LESS_THAN, + FilterOperator.NUMBER_LESS_THAN_OR_EQUAL, + FilterOperator.NUMBER_BETWEEN, + FilterOperator.NUMBER_NOT_BETWEEN, + ]: + condition = apply_number_filter(value_column, operator, parameter) + + elif operator in [ + FilterOperator.DATE_EQUALS, + FilterOperator.DATE_NOT_EQUALS, + FilterOperator.DATE_GREATER_THAN, + FilterOperator.DATE_GREATER_THAN_OR_EQUAL, + FilterOperator.DATE_LESS_THAN, + FilterOperator.DATE_LESS_THAN_OR_EQUAL, + FilterOperator.DATE_BETWEEN, + FilterOperator.DATE_NOT_BETWEEN, + ]: + condition = apply_date_filter(value_column, operator, parameter) + + elif operator in [ + FilterOperator.DATETIME_EQUALS, + FilterOperator.DATETIME_NOT_EQUALS, + FilterOperator.DATETIME_GREATER_THAN, + FilterOperator.DATETIME_GREATER_THAN_OR_EQUAL, + FilterOperator.DATETIME_LESS_THAN, + FilterOperator.DATETIME_LESS_THAN_OR_EQUAL, + FilterOperator.DATETIME_BETWEEN, + FilterOperator.DATETIME_NOT_BETWEEN, + ]: + condition = apply_datetime_filter(value_column, operator, parameter) + + elif operator in [ + FilterOperator.BOOLEAN_IS_TRUE, + FilterOperator.BOOLEAN_IS_FALSE, + ]: + condition = apply_boolean_filter(value_column, operator, parameter) + + elif operator in [ + FilterOperator.TAGS_EQUALS, + FilterOperator.TAGS_NOT_EQUALS, + FilterOperator.TAGS_CONTAINS, + FilterOperator.TAGS_NOT_CONTAINS, + ]: + condition = apply_tags_filter(value_column, operator, parameter) + + elif operator in [ + FilterOperator.TAGS_SINGLE_EQUALS, + FilterOperator.TAGS_SINGLE_NOT_EQUALS, + FilterOperator.TAGS_SINGLE_CONTAINS, + FilterOperator.TAGS_SINGLE_NOT_CONTAINS, + ]: + condition = apply_tags_single_filter(value_column, operator, parameter) + + elif operator in [ + FilterOperator.IS_NULL, + FilterOperator.IS_NOT_NULL, + ]: + condition = apply_null_filter(value_column, operator, parameter) + + if condition is not None: + filter_conditions.append(condition) + + if filter_conditions: + query = query.where(and_(*filter_conditions)) + + return query + + +def filtered_and_sorted_query( + filtering_param: str = "filtering", + sorting_param: str = "sorting", + pagination_param: str = "pagination", +): + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + filtering: list[FilterInput] | None = kwargs.get(filtering_param) + sorting: list[SortInput] | None = kwargs.get(sorting_param) + pagination: PaginationInput | None = kwargs.get(pagination_param) + + result = await func(*args, **kwargs) + + if not isinstance(result, Select): + return result + + model_class = result.column_descriptions[0]["entity"] + if not model_class: + if isinstance(result, Select): + for arg in args: + if hasattr(arg, "context") and hasattr(arg.context, "db"): + db = arg.context.db + query_result = await db.execute(result) + return query_result.scalars().all() + else: + info = kwargs.get("info") + if info and hasattr(info, "context") and hasattr(info.context, "db"): + db = info.context.db + query_result = await db.execute(result) + return query_result.scalars().all() + return result + + property_field_types: dict[str, str] = {} + + if filtering or sorting: + property_def_ids = set() + if filtering: + for f in filtering: + if ( + f.column_type == ColumnType.PROPERTY + and f.property_definition_id + ): + property_def_ids.add(f.property_definition_id) + if sorting: + for s in sorting: + if ( + s.column_type == ColumnType.PROPERTY + and s.property_definition_id + ): + property_def_ids.add(s.property_definition_id) + + if property_def_ids: + for arg in args: + if hasattr(arg, "context") and hasattr(arg.context, "db"): + db = arg.context.db + prop_defs_result = await db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(property_def_ids) + ) + ) + prop_defs = prop_defs_result.scalars().all() + property_field_types = { + str(prop_def.id): prop_def.field_type for prop_def in prop_defs + } + break + else: + info = kwargs.get("info") + if info and hasattr(info, "context") and hasattr(info.context, "db"): + db = info.context.db + prop_defs_result = await db.execute( + select(models.PropertyDefinition).where( + models.PropertyDefinition.id.in_(property_def_ids) + ) + ) + prop_defs = prop_defs_result.scalars().all() + property_field_types = { + str(prop_def.id): prop_def.field_type for prop_def in prop_defs + } + + if filtering: + result = apply_filtering( + result, filtering, model_class, property_field_types + ) + + if sorting: + result = apply_sorting( + result, sorting, model_class, property_field_types + ) + + if pagination and pagination is not strawberry.UNSET: + page_index = pagination.page_index + page_size = pagination.page_size + if page_size: + offset = page_index * page_size + result = apply_pagination(result, limit=page_size, offset=offset) + + if isinstance(result, Select): + for arg in args: + if hasattr(arg, "context") and hasattr(arg.context, "db"): + db = arg.context.db + query_result = await db.execute(result) + return query_result.scalars().all() + else: + info = kwargs.get("info") + if info and hasattr(info, "context") and hasattr(info.context, "db"): + db = info.context.db + query_result = await db.execute(result) + return query_result.scalars().all() + + return result + + return wrapper + + return decorator diff --git a/backend/api/decorators/full_text_search.py b/backend/api/decorators/full_text_search.py new file mode 100644 index 00000000..e6136bdb --- /dev/null +++ b/backend/api/decorators/full_text_search.py @@ -0,0 +1,112 @@ +from functools import wraps +from typing import Any, Callable, TypeVar + +import strawberry +from api.inputs import FullTextSearchInput +from database import models +from database.models.base import Base +from sqlalchemy import Select, String, and_, inspect, or_ +from sqlalchemy.orm import aliased + +T = TypeVar("T") + + +def detect_entity_type(model_class: type[Base]) -> str | None: + if model_class == models.Patient: + return "patient" + if model_class == models.Task: + return "task" + return None + + +def get_text_columns_from_model(model_class: type[Base]) -> list[str]: + mapper = inspect(model_class) + text_columns = [] + for column in mapper.columns: + if isinstance(column.type, String): + text_columns.append(column.key) + return text_columns + + +def apply_full_text_search( + query: Select[Any], + search_input: FullTextSearchInput, + model_class: type[Base], +) -> Select[Any]: + if not search_input.search_text or not search_input.search_text.strip(): + return query + + search_text = search_input.search_text.strip() + search_pattern = f"%{search_text}%" + + search_conditions = [] + + columns_to_search = search_input.search_columns + if columns_to_search is None: + columns_to_search = get_text_columns_from_model(model_class) + + for column_name in columns_to_search: + try: + column = getattr(model_class, column_name) + search_conditions.append(column.ilike(search_pattern)) + except AttributeError: + continue + + if search_input.include_properties: + entity_type = detect_entity_type(model_class) + if entity_type: + property_alias = aliased(models.PropertyValue) + + if entity_type == "patient": + join_condition = property_alias.patient_id == model_class.id + else: + join_condition = property_alias.task_id == model_class.id + + if search_input.property_definition_ids: + property_filter = and_( + property_alias.text_value.ilike(search_pattern), + property_alias.definition_id.in_(search_input.property_definition_ids), + ) + else: + property_filter = property_alias.text_value.ilike(search_pattern) + + query = query.outerjoin(property_alias, join_condition) + search_conditions.append(property_filter) + + if not search_conditions: + return query + + combined_condition = or_(*search_conditions) + query = query.where(combined_condition) + + if search_input.include_properties: + query = query.distinct() + + return query + + +def full_text_search_query(search_param: str = "search"): + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + search_input: FullTextSearchInput | None = kwargs.get(search_param) + + result = await func(*args, **kwargs) + + if not isinstance(result, Select): + return result + + if not search_input or search_input is strawberry.UNSET: + return result + + model_class = result.column_descriptions[0]["entity"] + if not model_class: + return result + + result = apply_full_text_search(result, search_input, model_class) + + return result + + return wrapper + + return decorator diff --git a/backend/api/inputs.py b/backend/api/inputs.py index d5f326a3..e2854112 100644 --- a/backend/api/inputs.py +++ b/backend/api/inputs.py @@ -170,3 +170,116 @@ class UpdatePropertyDefinitionInput: @strawberry.input class UpdateProfilePictureInput: avatar_url: str + + +@strawberry.enum +class SortDirection(Enum): + ASC = "ASC" + DESC = "DESC" + + +@strawberry.enum +class FilterOperator(Enum): + TEXT_EQUALS = "TEXT_EQUALS" + TEXT_NOT_EQUALS = "TEXT_NOT_EQUALS" + TEXT_NOT_WHITESPACE = "TEXT_NOT_WHITESPACE" + TEXT_CONTAINS = "TEXT_CONTAINS" + TEXT_NOT_CONTAINS = "TEXT_NOT_CONTAINS" + TEXT_STARTS_WITH = "TEXT_STARTS_WITH" + TEXT_ENDS_WITH = "TEXT_ENDS_WITH" + NUMBER_EQUALS = "NUMBER_EQUALS" + NUMBER_NOT_EQUALS = "NUMBER_NOT_EQUALS" + NUMBER_GREATER_THAN = "NUMBER_GREATER_THAN" + NUMBER_GREATER_THAN_OR_EQUAL = "NUMBER_GREATER_THAN_OR_EQUAL" + NUMBER_LESS_THAN = "NUMBER_LESS_THAN" + NUMBER_LESS_THAN_OR_EQUAL = "NUMBER_LESS_THAN_OR_EQUAL" + NUMBER_BETWEEN = "NUMBER_BETWEEN" + NUMBER_NOT_BETWEEN = "NUMBER_NOT_BETWEEN" + DATE_EQUALS = "DATE_EQUALS" + DATE_NOT_EQUALS = "DATE_NOT_EQUALS" + DATE_GREATER_THAN = "DATE_GREATER_THAN" + DATE_GREATER_THAN_OR_EQUAL = "DATE_GREATER_THAN_OR_EQUAL" + DATE_LESS_THAN = "DATE_LESS_THAN" + DATE_LESS_THAN_OR_EQUAL = "DATE_LESS_THAN_OR_EQUAL" + DATE_BETWEEN = "DATE_BETWEEN" + DATE_NOT_BETWEEN = "DATE_NOT_BETWEEN" + DATETIME_EQUALS = "DATETIME_EQUALS" + DATETIME_NOT_EQUALS = "DATETIME_NOT_EQUALS" + DATETIME_GREATER_THAN = "DATETIME_GREATER_THAN" + DATETIME_GREATER_THAN_OR_EQUAL = "DATETIME_GREATER_THAN_OR_EQUAL" + DATETIME_LESS_THAN = "DATETIME_LESS_THAN" + DATETIME_LESS_THAN_OR_EQUAL = "DATETIME_LESS_THAN_OR_EQUAL" + DATETIME_BETWEEN = "DATETIME_BETWEEN" + DATETIME_NOT_BETWEEN = "DATETIME_NOT_BETWEEN" + BOOLEAN_IS_TRUE = "BOOLEAN_IS_TRUE" + BOOLEAN_IS_FALSE = "BOOLEAN_IS_FALSE" + TAGS_EQUALS = "TAGS_EQUALS" + TAGS_NOT_EQUALS = "TAGS_NOT_EQUALS" + TAGS_CONTAINS = "TAGS_CONTAINS" + TAGS_NOT_CONTAINS = "TAGS_NOT_CONTAINS" + TAGS_SINGLE_EQUALS = "TAGS_SINGLE_EQUALS" + TAGS_SINGLE_NOT_EQUALS = "TAGS_SINGLE_NOT_EQUALS" + TAGS_SINGLE_CONTAINS = "TAGS_SINGLE_CONTAINS" + TAGS_SINGLE_NOT_CONTAINS = "TAGS_SINGLE_NOT_CONTAINS" + IS_NULL = "IS_NULL" + IS_NOT_NULL = "IS_NOT_NULL" + + +@strawberry.enum +class ColumnType(Enum): + DIRECT_ATTRIBUTE = "DIRECT_ATTRIBUTE" + PROPERTY = "PROPERTY" + + +@strawberry.input +class FilterParameter: + search_text: str | None = None + compare_value: float | None = None + min: float | None = None + max: float | None = None + compare_date: date | None = None + min_date: date | None = None + max_date: date | None = None + compare_date_time: datetime | None = None + min_date_time: datetime | None = None + max_date_time: datetime | None = None + search_tags: list[str] | None = None + property_definition_id: str | None = None + + +@strawberry.input +class SortInput: + column: str + direction: SortDirection + column_type: ColumnType = ColumnType.DIRECT_ATTRIBUTE + property_definition_id: str | None = None + + +@strawberry.input +class FilterInput: + column: str + operator: FilterOperator + parameter: FilterParameter + column_type: ColumnType = ColumnType.DIRECT_ATTRIBUTE + property_definition_id: str | None = None + + +@strawberry.input +class PaginationInput: + page_index: int = 0 + page_size: int | None = None + + +@strawberry.input +class QueryOptionsInput: + sorting: list[SortInput] | None = None + filtering: list[FilterInput] | None = None + pagination: PaginationInput | None = None + + +@strawberry.input +class FullTextSearchInput: + search_text: str + search_columns: list[str] | None = None + include_properties: bool = False + property_definition_ids: list[str] | None = None diff --git a/backend/api/resolvers/patient.py b/backend/api/resolvers/patient.py index c8e9d404..d7354407 100644 --- a/backend/api/resolvers/patient.py +++ b/backend/api/resolvers/patient.py @@ -3,7 +3,14 @@ import strawberry from api.audit import audit_log from api.context import Info -from api.decorators.pagination import apply_pagination +from api.decorators.filter_sort import filtered_and_sorted_query +from api.decorators.full_text_search import full_text_search_query +from api.inputs import ( + FilterInput, + FullTextSearchInput, + PaginationInput, + SortInput, +) from api.inputs import CreatePatientInput, PatientState, UpdatePatientInput from api.resolvers.base import BaseMutationResolver, BaseSubscriptionResolver from api.services.authorization import AuthorizationService @@ -47,14 +54,18 @@ async def patient( return patient @strawberry.field + @filtered_and_sorted_query() + @full_text_search_query() async def patients( self, info: Info, location_node_id: strawberry.ID | None = None, root_location_ids: list[strawberry.ID] | None = None, states: list[PatientState] | None = None, - limit: int | None = None, - offset: int | None = None, + filtering: list[FilterInput] | None = None, + sorting: list[SortInput] | None = None, + pagination: PaginationInput | None = None, + search: FullTextSearchInput | None = None, ) -> list[PatientType]: query = select(models.Patient).options( selectinload(models.Patient.assigned_locations), @@ -142,10 +153,7 @@ async def patients( .distinct() ) - query = apply_pagination(query, limit=limit, offset=offset) - - result = await info.context.db.execute(query) - return result.scalars().all() + return query @strawberry.field async def recent_patients( diff --git a/backend/api/resolvers/task.py b/backend/api/resolvers/task.py index 03189a48..343b3473 100644 --- a/backend/api/resolvers/task.py +++ b/backend/api/resolvers/task.py @@ -3,7 +3,14 @@ import strawberry from api.audit import audit_log from api.context import Info -from api.decorators.pagination import apply_pagination +from api.decorators.filter_sort import filtered_and_sorted_query +from api.decorators.full_text_search import full_text_search_query +from api.inputs import ( + FilterInput, + FullTextSearchInput, + PaginationInput, + SortInput, +) from api.inputs import CreateTaskInput, UpdateTaskInput from api.resolvers.base import BaseMutationResolver, BaseSubscriptionResolver from api.services.authorization import AuthorizationService @@ -37,6 +44,8 @@ async def task(self, info: Info, id: strawberry.ID) -> TaskType | None: return task @strawberry.field + @filtered_and_sorted_query() + @full_text_search_query() async def tasks( self, info: Info, @@ -44,8 +53,10 @@ async def tasks( assignee_id: strawberry.ID | None = None, assignee_team_id: strawberry.ID | None = None, root_location_ids: list[strawberry.ID] | None = None, - limit: int | None = None, - offset: int | None = None, + filtering: list[FilterInput] | None = None, + sorting: list[SortInput] | None = None, + pagination: PaginationInput | None = None, + search: FullTextSearchInput | None = None, ) -> list[TaskType]: auth_service = AuthorizationService(info.context.db) @@ -65,10 +76,7 @@ async def tasks( if assignee_team_id: query = query.where(models.Task.assignee_team_id == assignee_team_id) - query = apply_pagination(query, limit=limit, offset=offset) - - result = await info.context.db.execute(query) - return result.scalars().all() + return query accessible_location_ids = await auth_service.get_user_accessible_location_ids( info.context.user, info.context @@ -164,10 +172,7 @@ async def tasks( models.Task.assignee_team_id.in_(select(team_location_cte.c.id)) ) - query = apply_pagination(query, limit=limit, offset=offset) - - result = await info.context.db.execute(query) - return result.scalars().all() + return query @strawberry.field async def recent_tasks( @@ -339,7 +344,11 @@ async def update_task( if data.estimated_time is not strawberry.UNSET: task.estimated_time = data.estimated_time - if data.assignee_id is not None and data.assignee_team_id is not strawberry.UNSET and data.assignee_team_id is not None: + if ( + data.assignee_id is not None + and data.assignee_team_id is not strawberry.UNSET + and data.assignee_team_id is not None + ): raise GraphQLError( "Cannot assign both a user and a team. Please assign either a user or a team.", extensions={"code": "BAD_REQUEST"}, diff --git a/backend/api/resolvers/user.py b/backend/api/resolvers/user.py index e9879676..e4d97525 100644 --- a/backend/api/resolvers/user.py +++ b/backend/api/resolvers/user.py @@ -1,6 +1,14 @@ import strawberry from api.context import Info -from api.inputs import UpdateProfilePictureInput +from api.decorators.filter_sort import filtered_and_sorted_query +from api.decorators.full_text_search import full_text_search_query +from api.inputs import ( + FilterInput, + FullTextSearchInput, + PaginationInput, + SortInput, + UpdateProfilePictureInput, +) from api.resolvers.base import BaseMutationResolver from api.types.user import UserType from database import models @@ -18,9 +26,18 @@ async def user(self, info: Info, id: strawberry.ID) -> UserType | None: return result.scalars().first() @strawberry.field - async def users(self, info: Info) -> list[UserType]: - result = await info.context.db.execute(select(models.User)) - return result.scalars().all() + @filtered_and_sorted_query() + @full_text_search_query() + async def users( + self, + info: Info, + filtering: list[FilterInput] | None = None, + sorting: list[SortInput] | None = None, + pagination: PaginationInput | None = None, + search: FullTextSearchInput | None = None, + ) -> list[UserType]: + query = select(models.User) + return query @strawberry.field def me(self, info: Info) -> UserType | None: From 1f1aed90a86aa909131d2110320c101785a67624 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Mon, 26 Jan 2026 15:42:41 +0100 Subject: [PATCH 2/2] add case sensitvity to filter parameters --- backend/api/decorators/filter_sort.py | 46 +++++++++++++++++++-------- backend/api/inputs.py | 1 + 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/backend/api/decorators/filter_sort.py b/backend/api/decorators/filter_sort.py index 2b843a2b..a7a82480 100644 --- a/backend/api/decorators/filter_sort.py +++ b/backend/api/decorators/filter_sort.py @@ -119,20 +119,38 @@ def apply_text_filter(column: Any, operator: FilterOperator, parameter: Any) -> if search_text is None: return None - if operator == FilterOperator.TEXT_EQUALS: - return column.ilike(search_text) - if operator == FilterOperator.TEXT_NOT_EQUALS: - return ~column.ilike(search_text) - if operator == FilterOperator.TEXT_NOT_WHITESPACE: - return func.trim(column) != "" - if operator == FilterOperator.TEXT_CONTAINS: - return column.ilike(f"%{search_text}%") - if operator == FilterOperator.TEXT_NOT_CONTAINS: - return ~column.ilike(f"%{search_text}%") - if operator == FilterOperator.TEXT_STARTS_WITH: - return column.ilike(f"{search_text}%") - if operator == FilterOperator.TEXT_ENDS_WITH: - return column.ilike(f"%{search_text}") + is_case_sensitive = parameter.is_case_sensitive + + if is_case_sensitive: + if operator == FilterOperator.TEXT_EQUALS: + return column.like(search_text) + if operator == FilterOperator.TEXT_NOT_EQUALS: + return ~column.like(search_text) + if operator == FilterOperator.TEXT_NOT_WHITESPACE: + return func.trim(column) != "" + if operator == FilterOperator.TEXT_CONTAINS: + return column.like(f"%{search_text}%") + if operator == FilterOperator.TEXT_NOT_CONTAINS: + return ~column.like(f"%{search_text}%") + if operator == FilterOperator.TEXT_STARTS_WITH: + return column.like(f"{search_text}%") + if operator == FilterOperator.TEXT_ENDS_WITH: + return column.like(f"%{search_text}") + else: + if operator == FilterOperator.TEXT_EQUALS: + return column.ilike(search_text) + if operator == FilterOperator.TEXT_NOT_EQUALS: + return ~column.ilike(search_text) + if operator == FilterOperator.TEXT_NOT_WHITESPACE: + return func.trim(column) != "" + if operator == FilterOperator.TEXT_CONTAINS: + return column.ilike(f"%{search_text}%") + if operator == FilterOperator.TEXT_NOT_CONTAINS: + return ~column.ilike(f"%{search_text}%") + if operator == FilterOperator.TEXT_STARTS_WITH: + return column.ilike(f"{search_text}%") + if operator == FilterOperator.TEXT_ENDS_WITH: + return column.ilike(f"%{search_text}") return None diff --git a/backend/api/inputs.py b/backend/api/inputs.py index e2854112..1962ae09 100644 --- a/backend/api/inputs.py +++ b/backend/api/inputs.py @@ -234,6 +234,7 @@ class ColumnType(Enum): @strawberry.input class FilterParameter: search_text: str | None = None + is_case_sensitive: bool = False compare_value: float | None = None min: float | None = None max: float | None = None