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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 0 additions & 118 deletions apps/backend/src/rhesis/backend/app/crud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions apps/backend/src/rhesis/backend/app/crud/behavior.py
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 7 additions & 6 deletions apps/backend/src/rhesis/backend/app/routers/behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)

Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading