Repository: igorbenav/fastcrud
Pull request #54 (2024-04-16 06:40:58)

feat: :sparkles: add strict parameter to FastCRUD `get` method
## Description
This PR aims to add a parameter `strict` to FastCRUD `get` method. 
Indeed, when one want to retrieve some instance with some filters, and there are many results, the get method returns the first it encounters but doesn't warn the user of the other ones in the database. It can be misleading when you expect only one row in your database but there are a lot of duplicates in the table and you get no error.

So, if once needs it, it can use the `strict` parameter in get method to get one or none element.
```python
class Model(Base):
    id: int = Column(Integer)
    name: int = Column(String)

model1 = Model(id=1, name="Igor")
model2 = Model(id=2, name="Igor")

crud = FastCRUD(Model)

db.add(model1)
db.add(model2)
await db.commit()

await crud.get(db, id=1) # --> gives model1
await crud.get(db, id=3) # --> gives None
await crud.get(db, name="Igor") # --> gives model1 because first in db
await crud.get(db, name="Igor", strict=True) # --> raises a MultipleResultError because there are many results with the same name

```
`strict` is by default on False to not disturb current behaviour 😄 


## Changes
fast_crud.py: I added the strict parameter in the `get` method

## Tests
I added `test_get_strict_existing_record` in `test_get.py` for `sqlalchemy` and `SQLModel`

## 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).
- [x] I have added tests that cover my changes (if applicable).
- [x] All new and existing tests passed.

## Additional Notes




Patch:
diff --git a/fastcrud/crud/fast_crud.py b/fastcrud/crud/fast_crud.py
index 3b6d91e..68ff70e 100644
--- a/fastcrud/crud/fast_crud.py
+++ b/fastcrud/crud/fast_crud.py
@@ -357,6 +357,7 @@ class FastCRUD(
         db: AsyncSession,
         schema_to_select: Optional[type[BaseModel]] = None,
         return_as_model: bool = False,
+        one_or_none: bool = False,
         **kwargs: Any,
     ) -> Optional[Union[dict, BaseModel]]:
         """
@@ -372,10 +373,12 @@ class FastCRUD(
         Args:
             db: The database session to use for the operation.
             schema_to_select: Optional Pydantic schema for selecting specific columns.
+            one_or_none: Flag to get strictly one or no result. Multiple results are not allowed.
             **kwargs: Filters to apply to the query, using field names for direct matches or appending comparison operators for advanced queries.
 
         Raises:
             ValueError: If return_as_model is True but schema_to_select is not provided.
+            MultipleResultsFound: if `one_or_none` is False and many result correspond to the passed filter.
 
         Returns:
             A dictionary or a Pydantic model instance of the fetched database row, or None if no match is found.
@@ -408,18 +411,17 @@ class FastCRUD(
         stmt = select(*to_select).filter(*filters)
 
         db_row = await db.execute(stmt)
-        result: Optional[Row] = db_row.first()
-        if result is not None:
-            out: dict = dict(result._mapping)
-            if return_as_model:
-                if not schema_to_select:
-                    raise ValueError(
-                        "schema_to_select must be provided when return_as_model is True."
-                    )
-                return schema_to_select(**out)
+        result: Optional[Row] = db_row.one_or_none() if one_or_none else db_row.first()
+        if result is None:
+            return None
+        out: dict = dict(result._mapping)
+        if not return_as_model:
             return out
-
-        return None
+        if not schema_to_select:
+            raise ValueError(
+                "schema_to_select must be provided when return_as_model is True."
+            )
+        return schema_to_select(**out)
 
     async def exists(self, db: AsyncSession, **kwargs: Any) -> bool:
         """


Test patch:
diff --git a/tests/sqlalchemy/crud/test_get.py b/tests/sqlalchemy/crud/test_get.py
index 875962c..31e527b 100644
--- a/tests/sqlalchemy/crud/test_get.py
+++ b/tests/sqlalchemy/crud/test_get.py
@@ -3,6 +3,7 @@ from pydantic import BaseModel
 
 from fastcrud.crud.fast_crud import FastCRUD
 from ...sqlalchemy.conftest import ModelTest, CreateSchemaTest
+from sqlalchemy.exc import MultipleResultsFound
 
 
 @pytest.mark.asyncio
@@ -132,3 +133,20 @@ async def test_get_return_as_model_without_schema(async_session, test_data):
         str(exc_info.value)
         == "schema_to_select must be provided when return_as_model is True."
     )
+
+
+@pytest.mark.asyncio
+async def test_get_strict_existing_record(async_session, test_data):
+    test_record = ModelTest(**test_data[0])
+    async_session.add(test_record)
+    async_session.add(ModelTest(**test_data[1]))
+    await async_session.commit()
+
+    crud = FastCRUD(ModelTest)
+    with pytest.raises(MultipleResultsFound):
+        fetched_record = await crud.get(async_session, one_or_none=True, category_id=1)
+
+    fetched_record = await crud.get(async_session, **test_data[0])
+
+    assert fetched_record is not None
+    assert fetched_record["name"] == test_data[0]["name"]
diff --git a/tests/sqlmodel/crud/test_get.py b/tests/sqlmodel/crud/test_get.py
index 0ca9acb..7157b5f 100644
--- a/tests/sqlmodel/crud/test_get.py
+++ b/tests/sqlmodel/crud/test_get.py
@@ -1,9 +1,10 @@
 import pytest
 from pydantic import BaseModel
+from sqlalchemy.exc import MultipleResultsFound
 
 from fastcrud.crud.fast_crud import FastCRUD
-from ...sqlmodel.conftest import ModelTest
-from ...sqlmodel.conftest import CreateSchemaTest
+
+from ...sqlmodel.conftest import CreateSchemaTest, ModelTest
 
 
 @pytest.mark.asyncio
@@ -133,3 +134,20 @@ async def test_get_return_as_model_without_schema(async_session, test_data):
         str(exc_info.value)
         == "schema_to_select must be provided when return_as_model is True."
     )
+
+
+@pytest.mark.asyncio
+async def test_get_strict_existing_record(async_session, test_data):
+    test_record = ModelTest(**test_data[0])
+    async_session.add(test_record)
+    async_session.add(ModelTest(**test_data[1]))
+    await async_session.commit()
+
+    crud = FastCRUD(ModelTest)
+    with pytest.raises(MultipleResultsFound):
+        fetched_record = await crud.get(async_session, one_or_none=True, category_id=1)
+
+    fetched_record = await crud.get(async_session, **test_data[0])
+
+    assert fetched_record is not None
+    assert fetched_record["name"] == test_data[0]["name"]