diff --git a/apps/backend/src/rhesis/backend/app/crud/__init__.py b/apps/backend/src/rhesis/backend/app/crud/__init__.py index 289e5ff700..d50195164c 100644 --- a/apps/backend/src/rhesis/backend/app/crud/__init__.py +++ b/apps/backend/src/rhesis/backend/app/crud/__init__.py @@ -321,124 +321,6 @@ def delete_category( ) -# Behavior CRUD -# read_behavior/read_behaviors return BehaviorWithMetricsSchema (tags, user, metrics with -# metric_type/backend_type/tags); status/organization/project are unused, excluded. metrics.tags -# is included explicitly since selectinload's default cascade skips many-to-many relations -# (would otherwise lazy-load tags per nested metric). -_BEHAVIOR_RELATED_FIELDS = ( - include(models.Behavior.user), - include(models.Behavior._tags_relationship, models.TaggedItem.tag), - include(models.Behavior.metrics), - include(models.Behavior.metrics, models.Metric.metric_type), - include(models.Behavior.metrics, models.Metric.backend_type), - include(models.Behavior.metrics, models.Metric._tags_relationship, models.TaggedItem.tag), -) - - -def get_behavior( - db: Session, behavior_id: uuid.UUID, organization_id: str = None, user_id: str = None -) -> Optional[models.Behavior]: - """Get behavior with relationships eagerly loaded.""" - from rhesis.backend.app.utils.crud_utils import _check_and_raise_if_deleted - from rhesis.backend.app.utils.query_utils import QueryBuilder - - item = ( - QueryBuilder(db, models.Behavior) - .with_deleted() - .with_related(*_BEHAVIOR_RELATED_FIELDS) - .with_organization_filter(organization_id) - .with_visibility_filter(user_id) - .filter_by_id(behavior_id) - ) - return _check_and_raise_if_deleted(item, models.Behavior, behavior_id, False) - - -def get_behaviors( - db: Session, - skip: int = 0, - limit: int = 20, - sort_by: str = "created_at", - sort_order: str = "desc", - filter: str | None = None, - organization_id: str = None, - user_id: str = None, -) -> List[models.Behavior]: - """Get behaviors.""" - return get_items( - db, models.Behavior, skip, limit, sort_by, sort_order, filter, organization_id, user_id - ) - - -def get_behaviors_detail( - db: Session, - skip: int = 0, - limit: int = 20, - sort_by: str = "created_at", - sort_order: str = "desc", - filter: str | None = None, - organization_id: str = None, - user_id: str = None, -) -> List[models.Behavior]: - """Get behaviors with related objects for BehaviorWithMetricsSchema, including metrics. - - Runs as two queries, mirroring get_metrics/crud_utils.get_items_detail: a joinless - query picks the page's IDs (filter + sort + LIMIT/OFFSET), then a second query - eager-loads _BEHAVIOR_RELATED_FIELDS scoped to just those IDs. Without this split, - Postgres would have to build every join for every matching row across the org before - it can sort and cut down to `limit`. - """ - ordered_ids = ( - QueryBuilder(db, models.Behavior) - .with_organization_filter(organization_id) - .with_visibility_filter(user_id) - .with_odata_filter(filter) - .with_sorting(sort_by, sort_order) - .with_pagination(skip, limit) - .ids() - ) - if not ordered_ids: - return [] - - items = ( - QueryBuilder(db, models.Behavior) - .with_related(*_BEHAVIOR_RELATED_FIELDS) - .with_organization_filter(organization_id) - .with_visibility_filter(user_id) - .query.filter(models.Behavior.id.in_(ordered_ids)) - .all() - ) - - # WHERE id IN (...) does not preserve order -- re-apply the phase-1 sort. - items_by_id = {item.id: item for item in items} - return [items_by_id[item_id] for item_id in ordered_ids if item_id in items_by_id] - - -def create_behavior( - db: Session, behavior: schemas.BehaviorCreate, organization_id: str = None, user_id: str = None -) -> models.Behavior: - """Create behavior.""" - return create_item(db, models.Behavior, behavior, organization_id, user_id) - - -def update_behavior( - db: Session, - behavior_id: uuid.UUID, - behavior: schemas.BehaviorUpdate, - organization_id: str = None, - user_id: str = None, -) -> Optional[models.Behavior]: - """Update behavior.""" - return update_item(db, models.Behavior, behavior_id, behavior, organization_id, user_id) - - -def delete_behavior( - db: Session, behavior_id: uuid.UUID, organization_id: str = None, user_id: str = None -) -> Optional[models.Behavior]: - """Delete behavior.""" - return delete_item(db, models.Behavior, behavior_id, organization_id, user_id) - - # TestSet CRUD def get_test_set( db: Session, test_set_id: uuid.UUID, organization_id: str = None, user_id: str = None diff --git a/apps/backend/src/rhesis/backend/app/crud/behavior.py b/apps/backend/src/rhesis/backend/app/crud/behavior.py new file mode 100644 index 0000000000..25a85a8d27 --- /dev/null +++ b/apps/backend/src/rhesis/backend/app/crud/behavior.py @@ -0,0 +1,141 @@ +"""CRUD operations for behaviors. + +Part of the incremental split of the ``crud`` monolith: ``crud/__init__.py`` still holds +the bulk of the functions, and per-entity modules like this one take over as the code +around them is touched. + +``_BEHAVIOR_RELATED_FIELDS`` covers exactly what ``BehaviorWithMetricsSchema`` in +``routers/behavior.py`` serializes -- tags, user, and each metric with its metric_type, +backend_type and tags. Status, organization and project are left out because nothing reads +them. The nested ``metrics.tags`` entry has to be listed on its own: ``selectinload``'s +default cascade skips many-to-many relations, so without it every metric in the response +lazy-loads its own tags. + +``get_behaviors`` is the plain list and loads none of that -- only ``get_behaviors_detail`` +eager-loads the relationships, which is why the list endpoint calls the detail variant. +``get_behaviors_detail`` runs as two queries, mirroring ``get_metrics`` and +``crud_utils.get_items_detail``: a joinless query picks the page's IDs (filter + sort + +LIMIT/OFFSET), then a second query eager-loads the relationships for just those IDs. +Without the split, Postgres would build every join for every matching row in the +organization before it could sort and cut down to ``limit``. The second query's +``WHERE id IN (...)`` does not preserve order, so the phase-1 sort is re-applied in Python +-- dropping that step silently returns the right page in the wrong order. +""" + +import uuid +from typing import List, Optional + +from sqlalchemy.orm import Session + +from rhesis.backend.app import models, schemas +from rhesis.backend.app.utils.crud_utils import ( + create_item, + delete_item, + get_items, + update_item, +) +from rhesis.backend.app.utils.query_utils import QueryBuilder, include + +_BEHAVIOR_RELATED_FIELDS = ( + include(models.Behavior.user), + include(models.Behavior._tags_relationship, models.TaggedItem.tag), + include(models.Behavior.metrics), + include(models.Behavior.metrics, models.Metric.metric_type), + include(models.Behavior.metrics, models.Metric.backend_type), + include(models.Behavior.metrics, models.Metric._tags_relationship, models.TaggedItem.tag), +) + + +def get_behavior( + db: Session, behavior_id: uuid.UUID, organization_id: str = None, user_id: str = None +) -> Optional[models.Behavior]: + """Get behavior with relationships eagerly loaded.""" + from rhesis.backend.app.utils.crud_utils import _check_and_raise_if_deleted + + item = ( + QueryBuilder(db, models.Behavior) + .with_deleted() + .with_related(*_BEHAVIOR_RELATED_FIELDS) + .with_organization_filter(organization_id) + .with_visibility_filter(user_id) + .filter_by_id(behavior_id) + ) + return _check_and_raise_if_deleted(item, models.Behavior, behavior_id, False) + + +def get_behaviors( + db: Session, + skip: int = 0, + limit: int = 20, + sort_by: str = "created_at", + sort_order: str = "desc", + filter: str | None = None, + organization_id: str = None, + user_id: str = None, +) -> List[models.Behavior]: + """Get behaviors.""" + return get_items( + db, models.Behavior, skip, limit, sort_by, sort_order, filter, organization_id, user_id + ) + + +def get_behaviors_detail( + db: Session, + skip: int = 0, + limit: int = 20, + sort_by: str = "created_at", + sort_order: str = "desc", + filter: str | None = None, + organization_id: str = None, + user_id: str = None, +) -> List[models.Behavior]: + """Get behaviors with related objects for BehaviorWithMetricsSchema, including metrics.""" + ordered_ids = ( + QueryBuilder(db, models.Behavior) + .with_organization_filter(organization_id) + .with_visibility_filter(user_id) + .with_odata_filter(filter) + .with_sorting(sort_by, sort_order) + .with_pagination(skip, limit) + .ids() + ) + if not ordered_ids: + return [] + + items = ( + QueryBuilder(db, models.Behavior) + .with_related(*_BEHAVIOR_RELATED_FIELDS) + .with_organization_filter(organization_id) + .with_visibility_filter(user_id) + .query.filter(models.Behavior.id.in_(ordered_ids)) + .all() + ) + + # WHERE id IN (...) does not preserve order -- re-apply the phase-1 sort. + items_by_id = {item.id: item for item in items} + return [items_by_id[item_id] for item_id in ordered_ids if item_id in items_by_id] + + +def create_behavior( + db: Session, behavior: schemas.BehaviorCreate, organization_id: str = None, user_id: str = None +) -> models.Behavior: + """Create behavior.""" + return create_item(db, models.Behavior, behavior, organization_id, user_id) + + +def update_behavior( + db: Session, + behavior_id: uuid.UUID, + behavior: schemas.BehaviorUpdate, + organization_id: str = None, + user_id: str = None, +) -> Optional[models.Behavior]: + """Update behavior.""" + return update_item(db, models.Behavior, behavior_id, behavior, organization_id, user_id) + + +def delete_behavior( + db: Session, behavior_id: uuid.UUID, organization_id: str = None, user_id: str = None +) -> Optional[models.Behavior]: + """Delete behavior.""" + return delete_item(db, models.Behavior, behavior_id, organization_id, user_id) diff --git a/apps/backend/src/rhesis/backend/app/routers/behavior.py b/apps/backend/src/rhesis/backend/app/routers/behavior.py index 80047ea177..d8782533cc 100644 --- a/apps/backend/src/rhesis/backend/app/routers/behavior.py +++ b/apps/backend/src/rhesis/backend/app/routers/behavior.py @@ -8,8 +8,9 @@ from pydantic import create_model from sqlalchemy.orm import Session -from rhesis.backend.app import crud, models, schemas +from rhesis.backend.app import models, schemas from rhesis.backend.app.auth.user_utils import require_current_user_or_token +from rhesis.backend.app.crud import behavior as behavior_crud from rhesis.backend.app.crud.metric import ( add_behavior_to_metric, get_behavior_metrics, @@ -70,7 +71,7 @@ def create_behavior( """Create behavior with automatic session variables for RLS.""" organization_id, user_id = tenant_context - return crud.create_behavior( + return behavior_crud.create_behavior( db=db, behavior=behavior, organization_id=organization_id, user_id=user_id ) @@ -96,7 +97,7 @@ def read_behaviors( """Get all behaviors with automatic session variables for RLS.""" organization_id, user_id = tenant_context - results = crud.get_behaviors_detail( + results = behavior_crud.get_behaviors_detail( db=db, skip=skip, limit=limit, @@ -121,7 +122,7 @@ def read_behavior( ): """Get behavior by ID with automatic session variables for RLS.""" organization_id, user_id = tenant_context - db_behavior = crud.get_behavior( + db_behavior = behavior_crud.get_behavior( db, behavior_id=behavior_id, organization_id=organization_id, user_id=user_id ) if db_behavior is None: @@ -138,7 +139,7 @@ def delete_behavior( ): """Delete behavior with automatic session variables for RLS.""" organization_id, user_id = tenant_context - db_behavior = crud.delete_behavior( + db_behavior = behavior_crud.delete_behavior( db, behavior_id=behavior_id, organization_id=organization_id, user_id=user_id ) if db_behavior is None: @@ -159,7 +160,7 @@ def update_behavior( ): """Update behavior with automatic session variables for RLS.""" organization_id, user_id = tenant_context - db_behavior = crud.update_behavior( + db_behavior = behavior_crud.update_behavior( db, behavior_id=behavior_id, behavior=behavior, diff --git a/apps/backend/src/rhesis/backend/app/services/test_config_generator.py b/apps/backend/src/rhesis/backend/app/services/test_config_generator.py index ae0dfa439a..ab3c2b9bd0 100644 --- a/apps/backend/src/rhesis/backend/app/services/test_config_generator.py +++ b/apps/backend/src/rhesis/backend/app/services/test_config_generator.py @@ -13,6 +13,7 @@ from rhesis.backend.app import crud from rhesis.backend.app.config.settings import get_model_settings +from rhesis.backend.app.crud import behavior as behavior_crud from rhesis.backend.app.crud.project import get_project from rhesis.backend.app.schemas.services import TestConfigResponse from rhesis.backend.app.utils.model_errors import ModelConfigurationError @@ -144,7 +145,7 @@ async def generate_config( raise ValueError("Database session and organization_id are required") # Fetch behaviors from database (limited to max 100 by validation) - behaviors = crud.get_behaviors( + behaviors = behavior_crud.get_behaviors( db=self.db, organization_id=organization_id, skip=0, diff --git a/apps/backend/src/rhesis/backend/app/services/test_generation_pipeline.py b/apps/backend/src/rhesis/backend/app/services/test_generation_pipeline.py index 055fb3e8da..f16518a61b 100644 --- a/apps/backend/src/rhesis/backend/app/services/test_generation_pipeline.py +++ b/apps/backend/src/rhesis/backend/app/services/test_generation_pipeline.py @@ -15,6 +15,7 @@ from rhesis.backend.app import crud from rhesis.backend.app.config.settings import get_model_settings from rhesis.backend.app.constants import TestSetType +from rhesis.backend.app.crud import behavior as behavior_crud from rhesis.backend.app.crud.project import get_project from rhesis.backend.app.models.user import User from rhesis.backend.app.schemas.services import ( @@ -93,7 +94,9 @@ def _fetch_db_context( previous_messages: Optional[list] = None, ) -> Dict[str, Any]: """Fetch all DB data needed for config prompts (called once upfront).""" - behaviors = crud.get_behaviors(db=db, organization_id=organization_id, skip=0, limit=100) + behaviors = behavior_crud.get_behaviors( + db=db, organization_id=organization_id, skip=0, limit=100 + ) behavior_list = [{"name": b.name, "description": b.description or ""} for b in behaviors] project_name = None diff --git a/tests/backend/services/test_test_generation_pipeline.py b/tests/backend/services/test_test_generation_pipeline.py index ff287e5e72..873a51b7ec 100644 --- a/tests/backend/services/test_test_generation_pipeline.py +++ b/tests/backend/services/test_test_generation_pipeline.py @@ -104,8 +104,10 @@ def test_returns_behaviors_and_prompt(self): behaviors = [_make_behavior("Accuracy", "Be accurate")] org_id = str(uuid.uuid4()) - with patch("rhesis.backend.app.services.test_generation_pipeline.crud") as crud: - crud.get_behaviors.return_value = behaviors + with patch( + "rhesis.backend.app.services.test_generation_pipeline.behavior_crud" + ) as behavior_crud: + behavior_crud.get_behaviors.return_value = behaviors ctx = _fetch_db_context( db=mock_db, organization_id=org_id, @@ -125,13 +127,15 @@ def test_fetches_project_when_id_provided(self): project = _make_project("MyProject", "A chatbot") with ( - patch("rhesis.backend.app.services.test_generation_pipeline.crud") as crud, + patch( + "rhesis.backend.app.services.test_generation_pipeline.behavior_crud" + ) as behavior_crud, patch( "rhesis.backend.app.services.test_generation_pipeline.get_project", return_value=project, ), ): - crud.get_behaviors.return_value = [] + behavior_crud.get_behaviors.return_value = [] ctx = _fetch_db_context( db=mock_db, organization_id=org_id, @@ -147,13 +151,15 @@ def test_raises_when_project_not_found(self): org_id = str(uuid.uuid4()) with ( - patch("rhesis.backend.app.services.test_generation_pipeline.crud") as crud, + patch( + "rhesis.backend.app.services.test_generation_pipeline.behavior_crud" + ) as behavior_crud, patch( "rhesis.backend.app.services.test_generation_pipeline.get_project", return_value=None, ), ): - crud.get_behaviors.return_value = [] + behavior_crud.get_behaviors.return_value = [] with pytest.raises(ValueError, match="not found"): _fetch_db_context( db=mock_db, @@ -166,8 +172,10 @@ def test_passes_previous_messages(self): mock_db = MagicMock() msgs = [{"content": "refine"}] - with patch("rhesis.backend.app.services.test_generation_pipeline.crud") as crud: - crud.get_behaviors.return_value = [] + with patch( + "rhesis.backend.app.services.test_generation_pipeline.behavior_crud" + ) as behavior_crud: + behavior_crud.get_behaviors.return_value = [] ctx = _fetch_db_context( db=mock_db, organization_id=str(uuid.uuid4()), diff --git a/tests/backend/utils/test_query_builder_load.py b/tests/backend/utils/test_query_builder_load.py index df9a31611c..8bc449109c 100644 --- a/tests/backend/utils/test_query_builder_load.py +++ b/tests/backend/utils/test_query_builder_load.py @@ -15,7 +15,8 @@ from sqlalchemy import inspect as sa_inspect from sqlalchemy.orm import Session -from rhesis.backend.app import crud, models, schemas +from rhesis.backend.app import models, schemas +from rhesis.backend.app.crud import behavior as behavior_crud from rhesis.backend.app.crud import tag as tag_crud from rhesis.backend.app.crud.metric import get_metrics from rhesis.backend.app.utils import crud_utils @@ -263,11 +264,13 @@ def _assert_nested_metric_loaded(behavior_result): assert len(nested_metric.tags) == 1 assert nested_metric.tags[0].name == "nested-metric-tag" - single = crud.get_behavior(db=test_db, behavior_id=behavior.id, organization_id=test_org_id) + single = behavior_crud.get_behavior( + db=test_db, behavior_id=behavior.id, organization_id=test_org_id + ) _assert_nested_metric_loaded(single) test_db.expire_all() - listed = crud.get_behaviors_detail( + listed = behavior_crud.get_behaviors_detail( db=test_db, skip=0, limit=100, organization_id=test_org_id ) _assert_nested_metric_loaded(next(b for b in listed if b.id == behavior.id))