feat: add complex arbitrary filtering on all objects (#140)

* feat: add complex arbitrary filtering on all objects

* fix: safe numeric casting and application of comparators

* fix: add way more tests, fix bugs with filter parsing

* chore: pass model_class as arugment to apply_filter

* fix: default to not caring about is_active in get_sessions_for_peer

* fix: address coderabbit complaints (valid)

* fix: throw filter errors when necessary, validate inputs and handle edge cases with more tests

* fix: remove all type errors and most type warnings

* fix: don't use db in tests that don't need it
cheat: sprinkle in some pyright: ignore in filter.py

* fix: allowlist for filtering -- no filtering by content, message id, or anything internal

* fix: handle mixed types in metadata, add tests

* chore: refine types

* chore: rename fiter param everywhere
This commit is contained in:
doria 2025-06-26 12:25:30 -04:00 committed by GitHub
parent e1804b3ef1
commit 41bf5adc92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 3157 additions and 155 deletions

View File

@ -17,6 +17,7 @@ from . import models, schemas
from .exceptions import (
ResourceNotFoundException,
)
from .utils.filter import apply_filter
load_dotenv(override=True)
@ -85,18 +86,17 @@ async def get_or_create_workspace(
async def get_all_workspaces(
filter: dict[str, Any] | None = None,
filters: dict[str, Any] | None = None,
) -> Select[tuple[models.Workspace]]:
"""
Get all workspaces.
Args:
db: Database session
filter: Filter the workspaces by a dictionary of metadata
filters: Filter the workspaces by a dictionary of metadata
"""
stmt = select(models.Workspace)
if filter is not None:
stmt = stmt.where(models.Workspace.h_metadata.contains(filter))
stmt = apply_filter(stmt, models.Workspace, filters)
stmt: Select[tuple[models.Workspace]] = stmt.order_by(models.Workspace.created_at)
return stmt
@ -239,12 +239,11 @@ async def get_peer(
async def get_peers(
workspace_name: str,
filter: dict[str, str] | None = None,
filters: dict[str, str] | None = None,
) -> Select[tuple[models.Peer]]:
stmt = select(models.Peer).where(models.Peer.workspace_name == workspace_name)
if filter is not None:
stmt = stmt.where(models.Peer.h_metadata.contains(filter))
stmt = apply_filter(stmt, models.Peer, filters)
stmt = stmt.order_by(models.Peer.created_at)
@ -291,8 +290,7 @@ async def update_peer(
async def get_sessions_for_peer(
workspace_name: str,
peer_name: str,
is_active: bool | None = None,
filter: dict[str, Any] | None = None,
filters: dict[str, Any] | None = None,
) -> Select[tuple[models.Session]]:
"""
Get all sessions for a peer through the session_peers relationship.
@ -300,8 +298,7 @@ async def get_sessions_for_peer(
Args:
workspace_name: Name of the workspace
peer_name: Name of the peer
is_active: Filter by active status (True/False/None for all)
filter: Filter sessions by metadata
filters: Filter sessions by metadata
Returns:
SQLAlchemy Select statement
@ -317,11 +314,7 @@ async def get_sessions_for_peer(
.where(models.Session.workspace_name == workspace_name)
)
if is_active is not None:
stmt = stmt.where(models.Session.is_active == is_active)
if filter is not None:
stmt = stmt.where(models.Session.h_metadata.contains(filter))
stmt = apply_filter(stmt, models.Session, filters)
stmt: Select[tuple[models.Session]] = stmt.order_by(models.Session.created_at)
@ -335,19 +328,14 @@ async def get_sessions_for_peer(
async def get_sessions(
workspace_name: str,
is_active: bool | None = None,
filter: dict[str, Any] | None = None,
filters: dict[str, Any] | None = None,
) -> Select[tuple[models.Session]]:
"""
Get all sessions in a workspace.
"""
stmt = select(models.Session).where(models.Session.workspace_name == workspace_name)
if is_active:
stmt = stmt.where(models.Session.is_active.is_(True))
if filter is not None:
stmt = stmt.where(models.Session.h_metadata.contains(filter))
stmt = apply_filter(stmt, models.Session, filters)
stmt = stmt.order_by(models.Session.created_at)
@ -1261,7 +1249,7 @@ async def get_messages(
workspace_name: str,
session_name: str,
reverse: bool | None = False,
filter: dict[str, Any] | None = None,
filters: dict[str, Any] | None = None,
token_limit: int | None = None,
message_count_limit: int | None = None,
) -> Select[tuple[models.Message]]:
@ -1275,7 +1263,7 @@ async def get_messages(
workspace_name: Name of the workspace
session_name: Name of the session
reverse: Whether to reverse the order of messages
filter: Filter to apply to the messages
filters: Filter to apply to the messages
token_limit: Maximum number of tokens to include in the messages
message_count_limit: Maximum number of messages to include
@ -1288,13 +1276,10 @@ async def get_messages(
models.Message.session_name == session_name,
]
# Add metadata filter if provided
if filter is not None:
base_conditions.append(models.Message.h_metadata.contains(filter))
# Apply message count limit first (takes precedence over token limit)
if message_count_limit is not None:
stmt = select(models.Message).where(*base_conditions)
stmt = apply_filter(stmt, models.Message, filters)
# For message count limit, we want the most recent N messages
# So we order by id desc to get most recent, then apply limit
stmt = stmt.order_by(models.Message.id.desc()).limit(message_count_limit)
@ -1304,7 +1289,6 @@ async def get_messages(
stmt = stmt.order_by(models.Message.id.desc())
else:
stmt = stmt.order_by(models.Message.id.asc())
elif token_limit is not None:
# Apply token limit logic
# Create a subquery that calculates running sum of tokens for most recent messages
@ -1325,16 +1309,17 @@ async def get_messages(
.join(token_subquery, models.Message.id == token_subquery.c.id)
.where(token_subquery.c.running_token_sum <= token_limit)
)
stmt = apply_filter(stmt, models.Message, filters)
# Apply final ordering based on reverse parameter
if reverse:
stmt = stmt.order_by(models.Message.id.desc())
else:
stmt = stmt.order_by(models.Message.id.asc())
else:
# Default case - no limits applied
stmt = select(models.Message).where(*base_conditions)
stmt = apply_filter(stmt, models.Message, filters)
if reverse:
stmt = stmt.order_by(models.Message.id.desc())
else:
@ -1398,7 +1383,7 @@ async def get_messages_for_peer(
workspace_name: str,
peer_name: str,
reverse: bool | None = False,
filter: dict[str, Any] | None = None,
filters: dict[str, Any] | None = None,
) -> Select[tuple[models.Message]]:
stmt = (
select(models.Message)
@ -1407,8 +1392,7 @@ async def get_messages_for_peer(
.where(models.Message.session_name.is_(None))
)
if filter is not None:
stmt = stmt.where(models.Message.h_metadata.contains(filter))
stmt = apply_filter(stmt, models.Message, filters)
if reverse:
stmt = stmt.order_by(models.Message.id.desc())
@ -1531,7 +1515,7 @@ async def query_documents(
peer_name: str,
collection_name: str,
query: str,
filter: dict[str, Any] | None = None,
filters: dict[str, Any] | None = None,
max_distance: float | None = None,
top_k: int = 5,
) -> Sequence[models.Document]:
@ -1548,8 +1532,7 @@ async def query_documents(
stmt = stmt.where(
models.Document.embedding.cosine_distance(embedding_query) < max_distance
)
if filter is not None:
stmt = stmt.where(models.Document.internal_metadata.contains(filter))
stmt = apply_filter(stmt, models.Document, filters)
stmt = stmt.limit(top_k).order_by(
models.Document.embedding.cosine_distance(embedding_query)
)

View File

@ -63,3 +63,11 @@ class DisabledException(HonchoException):
status_code = 405
detail = "Feature is disabled"
@final
class FilterError(HonchoException):
"""Exception raised when a filter is misconfigured or invalid."""
status_code = 422
detail = "Invalid filter configuration"

View File

@ -3,7 +3,7 @@ from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Path, Query
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import insert
@ -321,20 +321,20 @@ async def get_messages(
):
"""Get all messages for a session"""
try:
filter = None
filters = None
if options and hasattr(options, "filter"):
filter = options.filter
if filter == {}:
filter = None
filters = options.filter
if filters == {}:
filters = None
messages_query = await crud.get_messages(
workspace_name=workspace_id,
session_name=session_id,
filter=filter,
filters=filters,
reverse=reverse,
)
return await paginate(db, messages_query)
return await apaginate(db, messages_query)
except ValueError as e:
logger.warning(f"Failed to get messages for session {session_id}: {str(e)}")
raise ResourceNotFoundException("Session not found") from e

View File

@ -11,7 +11,7 @@ from fastapi import (
from fastapi.exceptions import HTTPException
from fastapi.responses import StreamingResponse
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from fastapi_pagination.ext.sqlalchemy import apaginate
from mirascope.llm import Stream
from sqlalchemy.ext.asyncio import AsyncSession
@ -51,9 +51,9 @@ async def get_peers(
if filter_param == {}:
filter_param = None
return await paginate(
return await apaginate(
db,
await crud.get_peers(workspace_name=workspace_id, filter=filter_param),
await crud.get_peers(workspace_name=workspace_id, filters=filter_param),
)
@ -128,23 +128,18 @@ async def get_sessions_for_peer(
):
"""Get All Sessions for a Peer"""
filter_param = None
is_active = True
if options:
if hasattr(options, "filter"):
filter_param = options.filter
if filter_param == {}:
filter_param = None
if hasattr(options, "is_active"):
is_active = options.is_active
if options and hasattr(options, "filter"):
filter_param = options.filter
if filter_param == {}:
filter_param = None
return await paginate(
return await apaginate(
db,
await crud.get_sessions_for_peer(
workspace_name=workspace_id,
peer_name=peer_id,
is_active=is_active,
filter=filter_param,
filters=filter_param,
),
)
@ -274,20 +269,20 @@ async def get_messages_for_peer(
):
"""Get all messages for a peer"""
try:
filter = None
filters = None
if options and hasattr(options, "filter"):
filter = options.filter
if filter == {}:
filter = None
filters = options.filter
if filters == {}:
filters = None
messages_query = await crud.get_messages_for_peer(
workspace_name=workspace_id,
peer_name=peer_id,
filter=filter,
filters=filters,
reverse=reverse,
)
return await paginate(db, messages_query)
return await apaginate(db, messages_query)
except ValueError as e:
logger.warning(f"Failed to get messages for peer {peer_id}: {str(e)}")
raise ResourceNotFoundException("Peer not found") from e
@ -337,4 +332,4 @@ async def search_peer(
"""Search a Peer"""
stmt = await crud.search(query, workspace_name=workspace_id, peer_name=peer_id)
return await paginate(db, stmt)
return await apaginate(db, stmt)

View File

@ -2,7 +2,7 @@ import logging
from fastapi import APIRouter, Body, Depends, Path, Query, Response
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, schemas
@ -84,23 +84,14 @@ async def get_sessions(
):
"""Get All Sessions in a Workspace"""
filter_param = None
is_active_param = False # Default from schema
if options:
if hasattr(options, "filter") and options.filter:
filter_param = options.filter
if filter_param == {}: # Explicitly check for empty dict
filter_param = None
if hasattr(options, "is_active"): # Check if is_active is present
is_active_param = options.is_active
if options and hasattr(options, "filter") and options.filter:
filter_param = options.filter
if filter_param == {}: # Explicitly check for empty dict
filter_param = None
return await paginate(
db,
await crud.get_sessions(
workspace_name=workspace_id,
is_active=is_active_param,
filter=filter_param,
),
return await apaginate(
db, await crud.get_sessions(workspace_name=workspace_id, filters=filter_param)
)
@ -361,7 +352,7 @@ async def get_session_peers(
peers_query = await crud.get_peers_from_session(
workspace_name=workspace_id, session_name=session_id
)
return await paginate(db, peers_query)
return await apaginate(db, peers_query)
except ValueError as e:
logger.warning(f"Failed to get peers from session {session_id}: {str(e)}")
raise ResourceNotFoundException("Session not found") from e
@ -468,4 +459,4 @@ async def search_session(
query, workspace_name=workspace_id, session_name=session_id
)
return await paginate(db, stmt)
return await apaginate(db, stmt)

View File

@ -2,7 +2,7 @@ import logging
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from fastapi_pagination.ext.sqlalchemy import apaginate
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, schemas
@ -65,9 +65,9 @@ async def get_all_workspaces(
if filter_param == {}:
filter_param = None
return await paginate(
return await apaginate(
db,
await crud.get_all_workspaces(filter=filter_param),
await crud.get_all_workspaces(filters=filter_param),
)
@ -104,7 +104,7 @@ async def search_workspace(
"""Search a Workspace"""
stmt = await crud.search(query, workspace_name=workspace_id)
return await paginate(db, stmt)
return await apaginate(db, stmt)
@router.get(

View File

@ -169,7 +169,6 @@ class SessionCreate(SessionBase):
class SessionGet(SessionBase):
filter: dict[str, Any] | None = None
is_active: bool = False
class SessionUpdate(SessionBase):
@ -248,6 +247,7 @@ class DialecticResponse(BaseModel):
class SessionCounts(BaseModel):
"""Counts for a specific session in queue processing."""
completed: int
in_progress: int
pending: int
@ -255,6 +255,7 @@ class SessionCounts(BaseModel):
class QueueCounts(BaseModel):
"""Aggregated counts for queue processing status."""
total: int
completed: int
in_progress: int
@ -264,6 +265,7 @@ class QueueCounts(BaseModel):
class QueueStatusRow(BaseModel):
"""Represents a row from the queue status SQL query result."""
session_id: str | None
total: int
completed: int
@ -277,6 +279,7 @@ class QueueStatusRow(BaseModel):
class PeerConfigResult(BaseModel):
"""Result from querying peer configuration data."""
peer_name: str
peer_configuration: dict[str, Any]
session_peer_configuration: dict[str, Any]
@ -284,11 +287,13 @@ class PeerConfigResult(BaseModel):
class SessionPeerData(BaseModel):
"""Data for managing session peer relationships."""
peer_names: dict[str, SessionPeerConfig]
class MessageBulkData(BaseModel):
"""Data for bulk message operations."""
messages: list[MessageCreate]
session_name: str
workspace_name: str

568
src/utils/filter.py Normal file
View File

@ -0,0 +1,568 @@
import datetime
from collections.abc import Callable
from logging import getLogger
from typing import Any, TypeVar
from sqlalchemy import ColumnElement, Select, and_, case, cast, literal, not_, or_
from sqlalchemy.types import Numeric
from ..exceptions import FilterError
logger = getLogger(__name__)
# Type variable for SQLAlchemy model classes
T = TypeVar("T")
# Module-level constants for comparison operators
COMPARISON_OPERATORS = {
"gte",
"lte",
"gt",
"lt",
"ne",
"in",
"contains",
"icontains",
}
NUMERIC_OPERATORS = {"gte", "lte", "gt", "lt", "ne"}
ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING = {
"id": "name",
"created_at": "created_at",
"is_active": "is_active",
"workspace_id": "workspace_name",
"session_id": "session_name",
"peer_id": "peer_name",
"metadata": "h_metadata",
}
ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES = {
"workspace_id": "workspace_name",
"session_id": "session_name",
"peer_id": "peer_name",
"token_count": "token_count",
"created_at": "created_at",
"metadata": "h_metadata",
}
def apply_filter(
stmt: Select[tuple[T]], model_class: type[T], filters: dict[str, Any] | None = None
) -> Select[tuple[T]]:
"""
Apply advanced filter to a SQL statement based on filter dictionary.
Supports logical operators (AND, OR, NOT), comparison operators
(gte, lte, gt, lt, ne, contains, icontains, in), and wildcard character (*).
Note that the filter refers to column names from the user perspective:
that means all `*_name` fields are actually `*_id` fields and `h_metadata`
is actually `metadata`.
Examples:
# Simple filters (backward compatible)
{"peer_id": "alice", "metadata": {"type": "user"}}
# Logical operators
{"AND": [{"peer_id": "alice"}, {"created_at": {"gte": "2024-01-01"}}]}
{"OR": [{"peer_id": "alice"}, {"peer_id": "bob"}]}
{"NOT": [{"peer_id": "alice"}]}
# Comparison operators
{"created_at": {"gte": "2024-01-01", "lte": "2024-12-31"}}
{"peer_id": {"in": ["alice", "bob"]}}
# Wildcards (matches everything for that field)
{"peer_id": "*"}
Args:
stmt: SQLAlchemy Select statement to modify
model_class: SQLAlchemy model class for column access
filters: Optional filter dictionary
Returns:
Modified Select statement with filter applied if provided
Raises:
FilterError: When the filter contains invalid configuration or values
"""
if filters is None:
return stmt
conditions = _build_filter_conditions(filters, model_class)
if conditions is not None:
stmt = stmt.where(conditions)
return stmt
def _build_filter_conditions(
filter_dict: dict[str, Any], model_class: type[Any]
) -> ColumnElement[bool] | None:
"""
Recursively build filter conditions from a filter dictionary.
Args:
filter_dict: Filter dictionary that may contain logical operators
model_class: SQLAlchemy model class for column access
Returns:
SQLAlchemy condition object or None
"""
conditions: list[ColumnElement[bool]] = []
# Handle logical operators
if "AND" in filter_dict:
if not isinstance(filter_dict["AND"], list):
raise FilterError(
f"AND operator must contain a list, got {type(filter_dict['AND']).__name__}"
)
and_conditions: list[ColumnElement[bool]] = []
for sub_filter in filter_dict["AND"]: # pyright: ignore
sub_condition = _build_filter_conditions(sub_filter, model_class) # pyright: ignore
if sub_condition is not None:
and_conditions.append(sub_condition)
if and_conditions:
conditions.append(and_(*and_conditions))
if "OR" in filter_dict:
if not isinstance(filter_dict["OR"], list):
raise FilterError(
f"OR operator must contain a list, got {type(filter_dict['OR']).__name__}"
)
or_conditions: list[ColumnElement[bool]] = []
for sub_filter in filter_dict["OR"]: # pyright: ignore
sub_condition = _build_filter_conditions(sub_filter, model_class) # pyright: ignore
if sub_condition is not None:
or_conditions.append(sub_condition)
if or_conditions:
conditions.append(or_(*or_conditions))
if "NOT" in filter_dict:
if filter_dict["NOT"] is None:
raise FilterError("NOT operator cannot be None")
if not isinstance(filter_dict["NOT"], list):
raise FilterError(
f"NOT operator must contain a list, got {type(filter_dict['NOT']).__name__}"
)
not_conditions: list[ColumnElement[bool]] = []
for sub_filter in filter_dict["NOT"]: # pyright: ignore
sub_condition = _build_filter_conditions(sub_filter, model_class) # pyright: ignore
if sub_condition is not None:
not_conditions.append(
not_(sub_condition)
) # Apply NOT to each condition individually
if not_conditions:
conditions.append(and_(*not_conditions)) # Then AND them together
# Handle field-level conditions (skip logical operator keys)
logical_keys = {"AND", "OR", "NOT"}
for key, value in filter_dict.items():
if key in logical_keys:
continue
condition = _build_field_condition(key, value, model_class)
if condition is not None:
conditions.append(condition)
# Combine all conditions with AND
if len(conditions) == 0:
return None
elif len(conditions) == 1:
return conditions[0]
else:
return and_(*conditions)
def _build_field_condition(
key: str, value: Any, model_class: type[Any]
) -> ColumnElement[bool] | None:
"""
Build a condition for a single field.
Args:
key: Field name
value: Field value or comparison dict
model_class: SQLAlchemy model class
Returns:
SQLAlchemy condition object or None
"""
if model_class.__name__ == "Message":
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING_MESSAGES.get(key)
else:
column_name = ALLOWED_EXTERNAL_TO_INTERNAL_COLUMN_MAPPING.get(key)
if column_name is None:
raise FilterError(
f"Column '{key}' is not allowed to be filtered on or does not exist on {model_class.__name__}"
)
# Check if the column exists on the model
if not hasattr(model_class, column_name):
raise FilterError(f"Column '{key}' does not exist on {model_class.__name__}")
column = getattr(model_class, column_name)
# Handle wildcard - matches everything, so no condition needed
if value == "*":
return None
# Handle comparison operators vs regular values
if isinstance(value, dict):
# Check if this is a comparison operators dict by looking for known operators
is_comparison_dict = any(op_key in COMPARISON_OPERATORS for op_key in value) # pyright: ignore
if is_comparison_dict:
return _build_comparison_conditions(column, column_name, value) # pyright: ignore
else:
# This is a regular value that happens to be a dict
# For JSONB fields (metadata, configuration), check if it contains nested comparison operators
if column_name in ("h_metadata", "configuration"):
return _build_nested_metadata_conditions(column, value) # pyright: ignore
else:
return column == value
else:
if column_name in ("h_metadata", "configuration"):
return column.contains(value)
else:
return column == value
def _safe_numeric_cast(
column_accessor: ColumnElement[Any], op_value: Any
) -> tuple[ColumnElement[Any], Any]:
"""
Safely cast JSONB column accessor to appropriate type for comparison.
Args:
column_accessor: SQLAlchemy JSONB column accessor (.astext)
op_value: The value to compare against
Returns:
Tuple of (cast_column_accessor, cast_op_value) for typed comparison
or (column_accessor, str_op_value) for string comparison
"""
try:
if isinstance(op_value, bool):
# For boolean values, compare with the string representation
# PostgreSQL JSONB stores booleans as "true"/"false" strings when extracted with ->>
return column_accessor, str(op_value).lower()
# For numeric values, use a safer cast that handles empty strings and invalid values
# We use CASE WHEN to handle empty strings and non-numeric values gracefully
safe_cast = case(
(column_accessor == "", literal(None)), # Empty string -> NULL
(column_accessor.is_(None), literal(None)), # NULL -> NULL
else_=cast(column_accessor, Numeric()),
)
if isinstance(op_value, int | float):
return safe_cast, op_value
else:
# Try to parse as numeric (handles both strings and other types)
try:
# Try int first, then float
parsed_value = int(op_value)
return safe_cast, parsed_value
except (ValueError, TypeError):
try:
parsed_value = float(op_value)
return safe_cast, parsed_value
except (ValueError, TypeError):
if isinstance(op_value, str):
# If it's not numeric, treat as string comparison (e.g., dates, text)
# This allows date strings like "2024-02-01" to be compared lexicographically
return column_accessor, str(op_value)
else:
raise FilterError(
f"Invalid value for numeric operator: {op_value}. Expected a number, got {type(op_value).__name__}"
) from None
except Exception as e:
raise FilterError(
f"Failed to process numeric cast for value '{op_value}': {str(e)}"
) from e
def _build_comparison_condition(
column: Any, field_name: str, operator: str, op_value: Any
) -> ColumnElement[bool] | None:
"""
Build a single comparison condition for a JSONB field.
Args:
column: SQLAlchemy JSONB column object
field_name: Name of the field in the JSONB column
operator: Comparison operator
op_value: Value to compare against
Returns:
SQLAlchemy condition object or None
"""
# Validate that the operator is supported
if operator not in COMPARISON_OPERATORS:
raise FilterError(f"Unsupported comparison operator: {operator}")
# Handle wildcard - matches everything, so no condition needed
if op_value == "*":
return None
field_accessor = column[field_name].astext
# Mapping of operators to their SQLAlchemy methods
if operator in NUMERIC_OPERATORS:
try:
safe_accessor, safe_value = _safe_numeric_cast(field_accessor, op_value)
operator_map: dict[str, Callable[[Any, Any], ColumnElement[bool]]] = {
"gte": lambda a, v: a >= v,
"lte": lambda a, v: a <= v,
"gt": lambda a, v: a > v,
"lt": lambda a, v: a < v,
"ne": lambda a, v: a != v,
}
return operator_map[operator](safe_accessor, safe_value)
except Exception as e:
raise FilterError(
f"Failed to build numeric comparison condition for operator '{operator}' with value '{op_value}': {str(e)}"
) from e
elif operator == "in":
if hasattr(op_value, "__iter__") and not isinstance(op_value, str | bytes):
# Handle wildcard in iterable - if present, matches everything, so no condition needed
if "*" in op_value:
return None
return field_accessor.in_([str(v) for v in op_value])
else:
raise FilterError(
f"Invalid value for 'in' operator: {op_value}. Expected an iterable (list, tuple, set), got {type(op_value).__name__}"
)
elif operator in ("contains", "icontains"):
return field_accessor.ilike(f"%{op_value}%")
return None
def _build_nested_metadata_conditions(
column: Any, metadata_dict: dict[str, Any]
) -> ColumnElement[bool] | None:
"""
Build conditions for nested metadata fields with comparison operators.
Args:
column: SQLAlchemy JSONB column object
metadata_dict: Dictionary containing nested field conditions
Returns:
Combined SQLAlchemy condition object or None
"""
conditions: list[ColumnElement[bool]] = []
for field_name, field_value in metadata_dict.items():
if isinstance(field_value, dict) and any(
op in COMPARISON_OPERATORS
for op in field_value # pyright: ignore
):
# This field has comparison operators
field_conditions: list[ColumnElement[bool]] = []
for operator, op_value in field_value.items(): # pyright: ignore
condition = _build_comparison_condition(
column,
field_name,
operator, # pyright: ignore
op_value,
)
if condition is not None:
field_conditions.append(condition)
if field_conditions:
conditions.append(
field_conditions[0]
if len(field_conditions) == 1
else and_(*field_conditions)
)
else:
# Handle wildcard - matches everything, so no condition needed
if field_value == "*":
continue
# Regular field equality - use JSONB contains for nested object matching
conditions.append(column.contains({field_name: field_value}))
# Combine all field conditions with AND
return _combine_conditions_with_and(conditions)
def _combine_conditions_with_and(
conditions: list[ColumnElement[bool]],
) -> ColumnElement[bool] | None:
"""
Combine a list of conditions with AND logic.
Args:
conditions: List of SQLAlchemy condition objects
Returns:
Combined condition object or None if no conditions
"""
if not conditions:
return None
elif len(conditions) == 1:
return conditions[0]
else:
return and_(*conditions)
def _build_comparison_conditions(
column: Any, column_name: str, comparisons: dict[str, Any]
) -> ColumnElement[bool] | None:
"""
Build comparison conditions for a single column.
Args:
column: SQLAlchemy column object
column_name: Name of the column
comparisons: Dictionary of comparison operators and values
Returns:
Combined SQLAlchemy condition object or None
"""
conditions: list[ColumnElement[bool]] = []
# Check if this is a datetime column
is_datetime_column = hasattr(column.type, "python_type") and issubclass(
column.type.python_type, datetime.datetime
)
for operator, op_value in comparisons.items():
# Validate that the operator is supported
if operator not in COMPARISON_OPERATORS:
raise FilterError(f"Unsupported comparison operator: {operator}")
# Handle wildcard - matches everything, so no condition needed
if op_value == "*":
continue
condition = None
# For datetime columns, cast string values to timestamp
if is_datetime_column and isinstance(op_value, str):
# Validate datetime string to prevent SQL injection
validated_datetime = _validate_datetime_string(op_value)
if validated_datetime is None:
# Raise error if datetime validation fails
raise FilterError(f"Invalid datetime value: {op_value}")
# Use the validated datetime object directly instead of string interpolation
casted_value = validated_datetime
else:
# if the operator is a numeric operator, the value must cast to a number
if operator in NUMERIC_OPERATORS:
try:
casted_value = float(op_value)
except ValueError:
raise FilterError(
f"Invalid numeric value: {op_value}. Expected a number, got {type(op_value).__name__}"
) from None
else:
casted_value = op_value
if operator == "gte":
condition = column >= casted_value
elif operator == "lte":
condition = column <= casted_value
elif operator == "gt":
condition = column > casted_value
elif operator == "lt":
condition = column < casted_value
elif operator == "ne":
condition = column != casted_value
elif operator == "in":
if hasattr(op_value, "__iter__") and not isinstance(op_value, str | bytes):
# Handle wildcard in iterable - if present, matches everything, so no condition needed
if "*" in op_value:
continue
else:
if is_datetime_column:
# Validate and cast each datetime string value
casted_values: list[str | datetime.datetime] = []
for val in op_value:
if isinstance(val, str):
validated_datetime = _validate_datetime_string(val)
if validated_datetime is None:
raise FilterError(
f"Invalid datetime value in list: {val}"
)
casted_values.append(validated_datetime)
else:
casted_values.append(val)
if casted_values:
condition = column.in_(casted_values)
else:
condition = column.in_(list(op_value))
else:
raise FilterError(
f"Invalid value for 'in' operator: {op_value}. Expected an iterable (list, tuple, set), got {type(op_value).__name__}"
)
elif operator == "contains":
if column_name == "h_metadata":
# For JSONB columns, use JSONB contains
condition = column.contains(op_value)
else:
# For text columns, use ILIKE
condition = column.ilike(f"%{op_value}%")
elif operator == "icontains":
# Case-insensitive contains for text columns
condition = column.ilike(f"%{op_value}%")
if condition is not None:
conditions.append(condition)
# Combine all conditions for this field with AND
if len(conditions) == 0:
return None
elif len(conditions) == 1:
return conditions[0]
else:
return and_(*conditions)
def _validate_datetime_string(value: str) -> datetime.datetime | None:
"""
Safely validate and parse a datetime string to prevent SQL injection.
This function attempts to parse the datetime string using multiple common formats
to ensure it's a valid datetime before allowing it to be used in SQL queries.
Args:
value: String value to validate as datetime
Returns:
Parsed datetime object if valid, None if invalid
"""
# Strip whitespace
value = value.strip()
# Try to parse with various common datetime formats
datetime_formats = [
"%Y-%m-%d %H:%M:%S", # 2024-01-01 12:00:00
"%Y-%m-%d %H:%M:%S.%f", # 2024-01-01 12:00:00.123456
"%Y-%m-%dT%H:%M:%S", # 2024-01-01T12:00:00 (ISO format)
"%Y-%m-%dT%H:%M:%S.%f", # 2024-01-01T12:00:00.123456
"%Y-%m-%dT%H:%M:%SZ", # 2024-01-01T12:00:00Z (UTC)
"%Y-%m-%dT%H:%M:%S.%fZ", # 2024-01-01T12:00:00.123456Z
"%Y-%m-%d", # 2024-01-01
]
for fmt in datetime_formats:
try:
return datetime.datetime.strptime(value, fmt)
except ValueError:
continue
# Try fromisoformat as a fallback (Python 3.7+)
try:
return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
pass
# Return None for invalid datetime - let the caller handle the error
return None

View File

@ -375,12 +375,12 @@ async def test_get_filtered_messages(
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list",
json={"filter": {"key": "value2"}},
json={"filter": {"metadata": {"key": "value2"}}},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) > 0
assert len(data["items"]) == 1
assert data["items"][0]["content"] == "Test message 2"
assert data["items"][0]["peer_id"] == test_peer.name
assert data["items"][0]["session_id"] == test_session.name
@ -427,10 +427,10 @@ async def test_get_filtered_messages_with_complex_filter(
db_session.add(test_message3)
await db_session.commit()
# Filter by multiple criteria
# Test old-style filter (backward compatibility)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list",
json={"filter": {"priority": "high", "category": "technical"}},
json={"filter": {"metadata": {"priority": "high", "category": "technical"}}},
)
assert response.status_code == 200
data = response.json()
@ -438,6 +438,40 @@ async def test_get_filtered_messages_with_complex_filter(
# Should return messages 1 and 2 (both have high priority and technical category)
assert len(data["items"]) >= 2
# Test new-style filter with AND operator
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list",
json={
"filter": {
"AND": [
{"metadata": {"priority": "high"}},
{"metadata": {"category": "technical"}},
]
}
},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) == 2
# Test OR filter to get high priority OR question type
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/{test_session.name}/messages/list",
json={
"filter": {
"OR": [
{"metadata": {"priority": "high"}},
{"metadata": {"type": "question"}},
]
}
},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) == 3 # All messages should match
@pytest.mark.asyncio
async def test_update_message(

View File

@ -110,17 +110,29 @@ def test_get_peers(client: TestClient, sample_data: tuple[Workspace, Peer]):
assert "items" in data
assert len(data["items"]) > 0
# Get peers with filter
# Get peers with simple filter (backward compatibility)
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/list",
json={"filter": {"peer_key": "peer_value"}},
json={"filter": {"metadata": {"peer_key": "peer_value"}}},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) >= 2
assert len(data["items"]) == 2
assert data["items"][0]["metadata"]["peer_key"] == "peer_value"
# Test new filter with NOT operator
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/list",
json={"filter": {"NOT": [{"metadata": {"peer_key": "peer_value2"}}]}},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
# Should find peers that don't have peer_key = "peer_value2"
# This includes the 2 peers with "peer_value" + the sample peer with empty metadata
assert len(data["items"]) == 3
def test_get_peers_with_empty_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
@ -275,33 +287,6 @@ def test_get_sessions_for_peer(client: TestClient, sample_data: tuple[Workspace,
assert len(data["items"]) == 1
def test_get_sessions_for_peer_with_is_active_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test getting sessions for peer with is_active parameter"""
test_workspace, test_peer = sample_data
# Create and then delete a session to have inactive session
session_name = str(generate_nanoid())
client.post(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_name, "peer_names": {test_peer.name: {}}},
)
client.delete(f"/v2/workspaces/{test_workspace.name}/sessions/{session_name}")
# Test getting inactive sessions
response = client.post(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}/sessions",
json={"is_active": False},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
# Should find at least our deleted session
inactive_sessions = [s for s in data["items"] if not s["is_active"]]
assert len(inactive_sessions) > 0
def test_get_sessions_for_peer_with_empty_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):

View File

@ -168,7 +168,7 @@ def test_get_peer_by_name_with_auth(
# Use POST /list endpoint to get peers
response = auth_client.post(
f"/v2/workspaces/{test_workspace.name}/peers/list",
json={"filter": {"name": test_peer.name}},
json={"filter": {"id": test_peer.name}},
)
# Admin JWT or JWT with matching workspace should be allowed

View File

@ -146,7 +146,7 @@ def test_create_session_with_too_many_peers(
session_response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/list",
json={"filter": {"name": "test_session"}},
json={"filter": {"id": "test_session"}},
)
assert session_response.status_code == 200
assert len(session_response.json()["items"]) == 0
@ -186,42 +186,16 @@ def test_get_sessions(client: TestClient, sample_data: tuple[Workspace, Peer]):
assert data["workspace_id"] == test_workspace.name
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/list",
json={"filter": {"test_key": "test_value"}},
json={"filter": {"metadata": {"test_key": "test_value"}}},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) > 0
assert len(data["items"]) == 1
assert data["items"][0]["metadata"] == {"test_key": "test_value"}
assert data["items"][0]["workspace_id"] == test_workspace.name
def test_get_sessions_with_is_active_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session listing with is_active parameter"""
test_workspace, test_peer = sample_data
# Create and then delete a session to have inactive session
session_id = str(generate_nanoid())
client.post(
f"/v2/workspaces/{test_workspace.name}/sessions",
json={"id": session_id, "peer_names": {test_peer.name: {}}},
)
client.delete(f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}")
# Test getting inactive sessions
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/list", json={"is_active": False}
)
assert response.status_code == 200
data = response.json()
assert "items" in data
# Should find at least our deleted session
inactive_sessions = [s for s in data["items"] if not s["is_active"]]
assert len(inactive_sessions) > 0
def test_get_sessions_with_empty_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
@ -382,7 +356,7 @@ def test_delete_session(client: TestClient, sample_data: tuple[Workspace, Peer])
# Check that session is marked as inactive
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/list",
json={"is_active": False},
json={"filter": {"is_active": False}},
)
data = response.json()
# Find our session in the inactive sessions

View File

@ -91,12 +91,12 @@ async def test_get_all_workspaces(client: TestClient):
response = client.post(
"/v2/workspaces/list",
json={"filter": {"test_key": "test_value"}},
json={"filter": {"metadata": {"test_key": "test_value"}}},
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) > 0
assert len(data["items"]) == 1
assert data["items"][0]["metadata"] == {"test_key": "test_value"}

File diff suppressed because it is too large Load Diff