diff --git a/spoolman/api/v1/spool.py b/spoolman/api/v1/spool.py index db7b91c..fb66c03 100644 --- a/spoolman/api/v1/spool.py +++ b/spoolman/api/v1/spool.py @@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from spoolman.api.v1.models import Message, Spool from spoolman.database import spool from spoolman.database.database import get_db_session +from spoolman.database.utils import SortOrder from spoolman.exceptions import ItemCreateError logger = logging.getLogger(__name__) @@ -110,7 +111,21 @@ async def find( title="Allow Archived", description="Whether to include archived spools in the search results.", ), + sort: Optional[str] = Query( + default=None, + title="Sort", + description=( + 'Sort the results by the given field. Should be a comma-separate string with "field:direction" items.' + ), + example="filament.name:asc,location:desc", + ), ) -> list[Spool]: + sort_by: dict[str, SortOrder] = {} + if sort is not None: + for sort_item in sort.split(","): + field, direction = sort_item.split(":") + sort_by[field] = SortOrder[direction.upper()] + db_items = await spool.find( db=db, filament_name=filament_name, @@ -121,6 +136,7 @@ async def find( location=location, lot_nr=lot_nr, allow_archived=allow_archived, + sort_by=sort_by, ) return [Spool.from_db(db_item) for db_item in db_items] diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 660c71c..96aa349 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import contains_eager, joinedload from spoolman.database import filament, models +from spoolman.database.utils import SortOrder from spoolman.exceptions import ItemCreateError, ItemNotFoundError from spoolman.math import weight_from_length @@ -75,7 +76,29 @@ async def get_by_id(db: AsyncSession, spool_id: int) -> models.Spool: return spool -async def find( +def parse_nested_field(base_obj: type[models.Base], field: str) -> sqlalchemy.Column: + """Parse a nested field string into a sqlalchemy field object.""" + fields = field.split(".") + if not hasattr(base_obj, fields[0]): + raise ValueError(f"Invalid field name '{field}'") + + if fields[0] == "filament" and len(fields) == 1: + raise ValueError("No field specified for filament") + if fields[0] == "filament": + return parse_nested_field(models.Filament, ".".join(fields[1:])) + + if fields[0] == "vendor" and len(fields) == 1: + raise ValueError("No field specified for vendor") + if fields[0] == "vendor": + return parse_nested_field(models.Vendor, ".".join(fields[1:])) + + if len(fields) > 1: + raise ValueError(f"Field '{fields[0]}' does not have any nested fields") + + return getattr(base_obj, fields[0]) + + +async def find( # noqa: C901 *, db: AsyncSession, filament_name: Optional[str] = None, @@ -86,8 +109,13 @@ async def find( location: Optional[str] = None, lot_nr: Optional[str] = None, allow_archived: bool = False, + sort_by: Optional[dict[str, SortOrder]] = None, ) -> list[models.Spool]: - """Find a list of spool objects by search criteria.""" + """Find a list of spool objects by search criteria. + + Sort by a field by passing a dict with the field name as key and the sort order as value. + The field name can contain nested fields, e.g. filament.name. + """ stmt = ( sqlalchemy.select(models.Spool) .join(models.Spool.filament, isouter=True) @@ -117,6 +145,14 @@ async def find( ), ) + if sort_by is not None: + for fieldstr, order in sort_by.items(): + field = parse_nested_field(models.Spool, fieldstr) + if order == SortOrder.ASC: + stmt = stmt.order_by(field.asc()) + elif order == SortOrder.DESC: + stmt = stmt.order_by(field.desc()) + rows = await db.execute(stmt) return list(rows.scalars().all()) diff --git a/spoolman/database/utils.py b/spoolman/database/utils.py new file mode 100644 index 0000000..eb3e15b --- /dev/null +++ b/spoolman/database/utils.py @@ -0,0 +1,8 @@ +"""Utility functions for the database module.""" + +from enum import Enum + + +class SortOrder(Enum): + ASC = 1 + DESC = 2