Repository: igorbenav/fastcrud
Pull request #169 (2024-10-09 22:51:24)

Implement `select_schema` on `EndpointCreator` and `crud_router`
# Pull Request Template for FastCRUD

## Description
Adds an optional kwarg for the `EndpointCreator` and `crud_router` to specify a Pydantic schema for returning objects rather than dicts.

I've added documentation on the classes docstrings wrt to the new kwarg, but will need some guidance/help to add to the pages and on what to build examples.

Closes https://github.com/igorbenav/fastcrud/issues/113

## Changes
- add a `select_schema` optional kwarg to `EndpointCreator` and `crud_router`
- add a `SelectSchemaType`
- replaces `BaseModel` in the `schema_to_select` arguments and appropriate returns
- add if/else logic for `FastCRUD._read_item` and `FastCRUD._read_items`
- refactor of the logic around `is_paginated` and `has_offset_limit` in `FastCRUD._read_items`
- addition of client fixture with `select_schema` set to `read_schema` fixture and tests with model validation

## Tests
I copy pasted the "simple" client tests for `get` and `get_multi` items, replacing the client for the new client with `select_schema` and added `read_schema.model_validate(...)` in the assertions.

I've created them on the SQLModel side, happy to duplicate that on the SQLAlchemy side, but it's not part of my current cases so  I'll have to learn how.

I'm not certain tests with `read_schema.model_validate(...)` are quite enough, I would prefer a `isinstance` somewhere, but couldn't find the right set of tests to build upon.

## Checklist
- [x] I have read the [CONTRIBUTING](CONTRIBUTING.md) document.
- [x] My code follows the code style of this project.
- [x] I have added necessary documentation (if appropriate).  **NB. I'm happy to do more on this front**
- [x] I have added tests that cover my changes (if applicable).
- [x] All new and existing tests passed.

## Additional Notes
Fairly new to SQLModel and FastAPI, there might be things I missed.



Patch:
diff --git a/fastcrud/crud/fast_crud.py b/fastcrud/crud/fast_crud.py
index db2a47b..5f3a6dc 100644
--- a/fastcrud/crud/fast_crud.py
+++ b/fastcrud/crud/fast_crud.py
@@ -2,7 +2,7 @@ from typing import Any, Dict, Generic, Union, Optional, Callable
 from datetime import datetime, timezone
 import warnings
 
-from pydantic import BaseModel, ValidationError
+from pydantic import ValidationError
 from sqlalchemy import (
     Insert,
     Result,
@@ -30,6 +30,7 @@ from fastcrud.types import (
     CreateSchemaType,
     DeleteSchemaType,
     ModelType,
+    SelectSchemaType,
     UpdateSchemaInternalType,
     UpdateSchemaType,
 )
@@ -53,6 +54,7 @@ class FastCRUD(
         UpdateSchemaType,
         UpdateSchemaInternalType,
         DeleteSchemaType,
+        SelectSchemaType,
     ]
 ):
     """
@@ -650,7 +652,7 @@ class FastCRUD(
 
     async def select(
         self,
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         sort_columns: Optional[Union[str, list[str]]] = None,
         sort_orders: Optional[Union[str, list[str]]] = None,
         **kwargs: Any,
@@ -716,11 +718,11 @@ class FastCRUD(
     async def get(
         self,
         db: AsyncSession,
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         return_as_model: bool = False,
         one_or_none: bool = False,
         **kwargs: Any,
-    ) -> Optional[Union[dict, BaseModel]]:
+    ) -> Optional[Union[dict, SelectSchemaType]]:
         """
         Fetches a single record based on specified filters.
 
@@ -788,9 +790,9 @@ class FastCRUD(
         self,
         db: AsyncSession,
         instance: Union[UpdateSchemaType, CreateSchemaType],
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         return_as_model: bool = False,
-    ) -> Union[BaseModel, Dict[str, Any], None]:
+    ) -> Union[SelectSchemaType, Dict[str, Any], None]:
         """Update the instance or create it if it doesn't exists.
 
         Note: This method will perform two transactions to the database (get and create or update).
@@ -805,23 +807,23 @@ class FastCRUD(
             The created or updated instance
         """
         _pks = self._get_pk_dict(instance)
-        schema_to_select = schema_to_select or type(instance)
+        schema_to_select = schema_to_select or type(instance)  # type: ignore
         db_instance = await self.get(
             db,
-            schema_to_select=schema_to_select,
+            schema_to_select=schema_to_select,  # type: ignore
             return_as_model=return_as_model,
             **_pks,
         )
         if db_instance is None:
             db_instance = await self.create(db, instance)  # type: ignore
-            db_instance = schema_to_select.model_validate(
+            db_instance = schema_to_select.model_validate(  # type: ignore
                 db_instance, from_attributes=True
             )
         else:
             await self.update(db, instance)  # type: ignore
             db_instance = await self.get(
                 db,
-                schema_to_select=schema_to_select,
+                schema_to_select=schema_to_select,  # type: ignore
                 return_as_model=return_as_model,
                 **_pks,
             )
@@ -834,7 +836,7 @@ class FastCRUD(
         instances: list[Union[UpdateSchemaType, CreateSchemaType]],
         commit: bool = False,
         return_columns: Optional[list[str]] = None,
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         return_as_model: bool = False,
         update_override: Optional[dict[str, Any]] = None,
         **kwargs: Any,
@@ -1141,7 +1143,7 @@ class FastCRUD(
         db: AsyncSession,
         offset: int = 0,
         limit: Optional[int] = 100,
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         sort_columns: Optional[Union[str, list[str]]] = None,
         sort_orders: Optional[Union[str, list[str]]] = None,
         return_as_model: bool = False,
@@ -1284,11 +1286,11 @@ class FastCRUD(
     async def get_joined(
         self,
         db: AsyncSession,
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         join_model: Optional[ModelType] = None,
         join_on: Optional[Union[Join, BinaryExpression]] = None,
         join_prefix: Optional[str] = None,
-        join_schema_to_select: Optional[type[BaseModel]] = None,
+        join_schema_to_select: Optional[type[SelectSchemaType]] = None,
         join_type: str = "left",
         alias: Optional[AliasedClass] = None,
         join_filters: Optional[dict] = None,
@@ -1600,11 +1602,11 @@ class FastCRUD(
     async def get_multi_joined(
         self,
         db: AsyncSession,
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         join_model: Optional[type[ModelType]] = None,
         join_on: Optional[Any] = None,
         join_prefix: Optional[str] = None,
-        join_schema_to_select: Optional[type[BaseModel]] = None,
+        join_schema_to_select: Optional[type[SelectSchemaType]] = None,
         join_type: str = "left",
         alias: Optional[AliasedClass[Any]] = None,
         join_filters: Optional[dict] = None,
@@ -1948,7 +1950,7 @@ class FastCRUD(
             stmt = stmt.limit(limit)
 
         result = await db.execute(stmt)
-        data: list[Union[dict, BaseModel]] = []
+        data: list[Union[dict, SelectSchemaType]] = []
 
         for row in result.mappings().all():
             row_dict = dict(row)
@@ -1978,7 +1980,7 @@ class FastCRUD(
             join.relationship_type == "one-to-many" for join in join_definitions
         ):
             nested_data = _nest_multi_join_data(
-                base_primary_key=self._primary_keys[0].name,
+                base_primary_key=self._primary_keys[0].name,  # type: ignore[misc]
                 data=data,
                 joins_config=join_definitions,
                 return_as_model=return_as_model,
@@ -2011,7 +2013,7 @@ class FastCRUD(
         db: AsyncSession,
         cursor: Any = None,
         limit: int = 100,
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         sort_column: str = "id",
         sort_order: str = "asc",
         **kwargs: Any,
@@ -2087,10 +2089,7 @@ class FastCRUD(
         if limit == 0:
             return {"data": [], "next_cursor": None}
 
-        stmt = await self.select(
-            schema_to_select=schema_to_select,
-            **kwargs,
-        )
+        stmt = await self.select(schema_to_select=schema_to_select, **kwargs)
 
         if cursor:
             if sort_order == "asc":
@@ -2124,11 +2123,11 @@ class FastCRUD(
         allow_multiple: bool = False,
         commit: bool = True,
         return_columns: Optional[list[str]] = None,
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         return_as_model: bool = False,
         one_or_none: bool = False,
         **kwargs: Any,
-    ) -> Optional[Union[dict, BaseModel]]:
+    ) -> Optional[Union[dict, SelectSchemaType]]:
         """
         Updates an existing record or multiple records in the database based on specified filters. This method allows for precise targeting of records to update.
 
@@ -2265,10 +2264,10 @@ class FastCRUD(
     def _as_single_response(
         self,
         db_row: Result,
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         return_as_model: bool = False,
         one_or_none: bool = False,
-    ) -> Optional[Union[dict, BaseModel]]:
+    ) -> Optional[Union[dict, SelectSchemaType]]:
         result: Optional[Row] = db_row.one_or_none() if one_or_none else db_row.first()
         if result is None:  # pragma: no cover
             return None
@@ -2284,7 +2283,7 @@ class FastCRUD(
     def _as_multi_response(
         self,
         db_row: Result,
-        schema_to_select: Optional[type[BaseModel]] = None,
+        schema_to_select: Optional[type[SelectSchemaType]] = None,
         return_as_model: bool = False,
     ) -> dict:
         data = [dict(row) for row in db_row.mappings()]
diff --git a/fastcrud/crud/helper.py b/fastcrud/crud/helper.py
index ade1e49..536fe93 100644
--- a/fastcrud/crud/helper.py
+++ b/fastcrud/crud/helper.py
@@ -6,7 +6,7 @@ from sqlalchemy.sql import ColumnElement
 from pydantic import BaseModel, ConfigDict
 from pydantic.functional_validators import field_validator
 
-from fastcrud.types import ModelType
+from fastcrud.types import ModelType, SelectSchemaType
 
 from ..endpoint.helper import _get_primary_key
 
@@ -40,7 +40,7 @@ class JoinConfig(BaseModel):
 
 def _extract_matching_columns_from_schema(
     model: Union[ModelType, AliasedClass],
-    schema: Optional[type[BaseModel]],
+    schema: Optional[type[SelectSchemaType]],
     prefix: Optional[str] = None,
     alias: Optional[AliasedClass] = None,
     use_temporary_prefix: Optional[bool] = False,
@@ -443,12 +443,12 @@ def _nest_join_data(
 
 def _nest_multi_join_data(
     base_primary_key: str,
-    data: list[Union[dict, BaseModel]],
+    data: Sequence[Union[dict, BaseModel]],
     joins_config: Sequence[JoinConfig],
     return_as_model: bool = False,
-    schema_to_select: Optional[type[BaseModel]] = None,
-    nested_schema_to_select: Optional[dict[str, type[BaseModel]]] = None,
-) -> Sequence[Union[dict, BaseModel]]:
+    schema_to_select: Optional[type[SelectSchemaType]] = None,
+    nested_schema_to_select: Optional[dict[str, type[SelectSchemaType]]] = None,
+) -> Sequence[Union[dict, SelectSchemaType]]:
     """
     Nests joined data based on join definitions provided for multiple records. This function processes the input list of
     dictionaries, identifying keys that correspond to joined tables using the provided `joins_config`, and nests them
@@ -464,7 +464,7 @@ def _nest_multi_join_data(
         nested_schema_to_select: A dictionary mapping join prefixes to their corresponding Pydantic schemas.
 
     Returns:
-        Sequence[Union[dict, BaseModel]]: A list of dictionaries with nested structures for joined table data or Pydantic models.
+        Sequence[Union[dict, SelectSchemaType]]: A list of dictionaries with nested structures for joined table data or Pydantic models.
 
     Example:
 
@@ -616,8 +616,9 @@ def _nest_multi_join_data(
 
 
 def _handle_null_primary_key_multi_join(
-    data: list[Union[dict[str, Any], BaseModel]], join_definitions: list[JoinConfig]
-) -> list[Union[dict[str, Any], BaseModel]]:
+    data: list[Union[dict[str, Any], SelectSchemaType]],
+    join_definitions: list[JoinConfig],
+) -> list[Union[dict[str, Any], SelectSchemaType]]:
     for item in data:
         item_dict = item if isinstance(item, dict) else item.model_dump()
 
diff --git a/fastcrud/endpoint/crud_router.py b/fastcrud/endpoint/crud_router.py
index c8cc0cd..7d87e21 100644
--- a/fastcrud/endpoint/crud_router.py
+++ b/fastcrud/endpoint/crud_router.py
@@ -9,6 +9,7 @@ from fastcrud.types import (
     DeleteSchemaType,
     ModelType,
     UpdateSchemaType,
+    SelectSchemaType,
 )
 from .endpoint_creator import EndpointCreator
 from .helper import FilterConfig
@@ -38,6 +39,7 @@ def crud_router(
     updated_at_column: str = "updated_at",
     endpoint_names: Optional[dict[str, str]] = None,
     filter_config: Optional[Union[FilterConfig, dict]] = None,
+    select_schema: Optional[Type[SelectSchemaType]] = None,
 ) -> APIRouter:
     """
     Creates and configures a FastAPI router with CRUD endpoints for a given model.
@@ -72,6 +74,7 @@ def crud_router(
                         (`"create"`, `"read"`, `"update"`, `"delete"`, `"db_delete"`, `"read_multi"`), and
                         values are the custom names to use. Unspecified operations will use default names.
         filter_config: Optional `FilterConfig` instance or dictionary to configure filters for the `read_multi` endpoint.
+        select_schema: Optional Pydantic schema for selecting an item.
 
     Returns:
         Configured `APIRouter` instance with the CRUD endpoints.
@@ -541,6 +544,7 @@ def crud_router(
         updated_at_column=updated_at_column,
         endpoint_names=endpoint_names,
         filter_config=filter_config,
+        select_schema=select_schema,  # type: ignore
     )
 
     endpoint_creator_instance.add_routes_to_router(
diff --git a/fastcrud/endpoint/endpoint_creator.py b/fastcrud/endpoint/endpoint_creator.py
index d81948c..c9ebbfc 100644
--- a/fastcrud/endpoint/endpoint_creator.py
+++ b/fastcrud/endpoint/endpoint_creator.py
@@ -10,6 +10,7 @@ from fastcrud.types import (
     CreateSchemaType,
     DeleteSchemaType,
     ModelType,
+    SelectSchemaType,
     UpdateSchemaType,
 )
 from ..exceptions.http_exceptions import (
@@ -58,6 +59,7 @@ class EndpointCreator:
                         (`"create"`, `"read"`, `"update"`, `"delete"`, `"db_delete"`, `"read_multi"`), and
                         values are the custom names to use. Unspecified operations will use default names.
         filter_config: Optional `FilterConfig` instance or dictionary to configure filters for the `read_multi` endpoint.
+        select_schema: Optional Pydantic schema for selecting an item.
 
     Raises:
         ValueError: If both `included_methods` and `deleted_methods` are provided.
@@ -251,6 +253,7 @@ class EndpointCreator:
         updated_at_column: str = "updated_at",
         endpoint_names: Optional[dict[str, str]] = None,
         filter_config: Optional[Union[FilterConfig, dict]] = None,
+        select_schema: Optional[Type[SelectSchemaType]] = None,
     ) -> None:
         self._primary_keys = _get_primary_keys(model)
         self._primary_keys_types = {
@@ -268,6 +271,7 @@ class EndpointCreator:
         self.create_schema = create_schema
         self.update_schema = update_schema
         self.delete_schema = delete_schema
+        self.select_schema = select_schema
         self.include_in_schema = include_in_schema
         self.path = path
         self.tags = tags or []
@@ -327,7 +331,15 @@ class EndpointCreator:
 
         @_apply_model_pk(**self._primary_keys_types)
         async def endpoint(db: AsyncSession = Depends(self.session), **pkeys):
-            item = await self.crud.get(db, **pkeys)
+            if self.select_schema is not None:
+                item = await self.crud.get(
+                    db,
+                    schema_to_select=self.select_schema,
+                    return_as_model=True,
+                    **pkeys,
+                )
+            else:
+                item = await self.crud.get(db, **pkeys)
             if not item:  # pragma: no cover
                 raise NotFoundException(detail="Item not found")
             return item  # pragma: no cover
@@ -367,9 +379,28 @@ class EndpointCreator:
                     items_per_page = 10
                 offset = compute_offset(page=page, items_per_page=items_per_page)  # type: ignore
                 limit = items_per_page
+            elif not has_offset_limit:
+                offset = 0
+                limit = 100
+
+            if self.select_schema is not None:
                 crud_data = await self.crud.get_multi(
-                    db, offset=offset, limit=limit, **filters
+                    db,
+                    offset=offset,  # type: ignore
+                    limit=limit,  # type: ignore
+                    schema_to_select=self.select_schema,
+                    return_as_model=True,
+                    **filters,
                 )
+            else:
+                crud_data = await self.crud.get_multi(
+                    db,
+                    offset=offset,  # type: ignore
+                    limit=limit,  # type: ignore
+                    **filters,
+                )
+
+            if is_paginated:
                 return paginated_response(
                     crud_data=crud_data,
                     page=page,  # type: ignore
diff --git a/fastcrud/types.py b/fastcrud/types.py
index 2987e2e..548af9c 100644
--- a/fastcrud/types.py
+++ b/fastcrud/types.py
@@ -4,6 +4,7 @@ from pydantic import BaseModel
 
 ModelType = TypeVar("ModelType", bound=Any)
 
+SelectSchemaType = TypeVar("SelectSchemaType", bound=BaseModel)
 CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
 UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
 UpdateSchemaInternalType = TypeVar("UpdateSchemaInternalType", bound=BaseModel)


Test patch:
diff --git a/tests/sqlmodel/conftest.py b/tests/sqlmodel/conftest.py
index 95232a2..e53fabc 100644
--- a/tests/sqlmodel/conftest.py
+++ b/tests/sqlmodel/conftest.py
@@ -650,3 +650,36 @@ def endpoint_creator(test_model, async_session) -> EndpointCreator:
             "read_multi": "get_multi",
         },
     )
+
+
+@pytest.fixture
+def client_with_select_schema(
+    test_model,
+    create_schema,
+    update_schema,
+    read_schema,
+    async_session,
+):
+    app = FastAPI()
+
+    app.include_router(
+        crud_router(
+            session=lambda: async_session,
+            model=test_model,
+            select_schema=read_schema,
+            create_schema=create_schema,
+            update_schema=update_schema,
+            path="/test",
+            tags=["test"],
+            endpoint_names={
+                "create": "create",
+                "read": "get",
+                "update": "update",
+                "delete": "delete",
+                "db_delete": "db_delete",
+                "read_multi": "get_multi",
+            },
+        )
+    )
+
+    return TestClient(app)
diff --git a/tests/sqlmodel/endpoint/test_get_item.py b/tests/sqlmodel/endpoint/test_get_item.py
index b822c31..6d8b2e7 100644
--- a/tests/sqlmodel/endpoint/test_get_item.py
+++ b/tests/sqlmodel/endpoint/test_get_item.py
@@ -51,3 +51,26 @@ async def test_read_multi_primary_key_item_success(
     assert data["name"] == tester_data["name"]
     assert data["id"] == tester_data["id"]
     assert data["uuid"] == tester_data["uuid"]
+
+
+@pytest.mark.asyncio
+async def test_read_item_with_schema(
+    client_with_select_schema: TestClient,
+    async_session,
+    test_model,
+    test_data,
+    read_schema,
+):
+    tester_data = {"name": test_data[0]["name"], "tier_id": test_data[0]["tier_id"]}
+    new_item = test_model(**tester_data)
+    async_session.add(new_item)
+    await async_session.commit()
+    await async_session.refresh(new_item)
+
+    response = client_with_select_schema.get(f"/test/get/{new_item.id}")
+
+    assert response.status_code == 200
+    data = response.json()
+    assert read_schema.model_validate(data)
+    assert data["name"] == tester_data["name"]
+    assert data["tier_id"] == tester_data["tier_id"]
diff --git a/tests/sqlmodel/endpoint/test_get_items.py b/tests/sqlmodel/endpoint/test_get_items.py
index 8184a9a..eb42b52 100644
--- a/tests/sqlmodel/endpoint/test_get_items.py
+++ b/tests/sqlmodel/endpoint/test_get_items.py
@@ -92,3 +92,27 @@ async def test_read_items_with_dict_filter_config(
 @pytest.mark.asyncio
 async def test_invalid_filter_column(invalid_filtered_client):
     pass
+
+
+@pytest.mark.asyncio
+async def test_read_items_with_schema(
+    client_with_select_schema: TestClient,
+    async_session,
+    test_model,
+    test_data,
+    read_schema,
+):
+    for data in test_data:
+        new_item = test_model(**data)
+        async_session.add(new_item)
+    await async_session.commit()
+
+    response = client_with_select_schema.get("/test/get_multi")
+
+    assert response.status_code == 200
+    data = response.json()
+
+    assert "data" in data
+    assert len(data["data"]) > 0
+
+    assert all(read_schema.model_validate(item) for item in data["data"])