From 7d0d1120b486f83205b9f9ce31dac00e4026f18a Mon Sep 17 00:00:00 2001 From: Lukas Karlsson Date: Fri, 28 Feb 2025 02:22:29 -0500 Subject: [PATCH 01/11] Added support for Firestore transactions. --- README.md | 60 ++++++++++ firedantic/_async/helpers.py | 3 +- firedantic/_async/model.py | 127 +++++++++++++++------ firedantic/_sync/helpers.py | 3 +- firedantic/_sync/model.py | 127 +++++++++++++++------ firedantic/tests/tests_async/conftest.py | 19 +-- firedantic/tests/tests_async/test_model.py | 120 ++++++++++++++++++- firedantic/tests/tests_sync/conftest.py | 19 +-- firedantic/tests/tests_sync/test_model.py | 114 +++++++++++++++++- unasync.py | 6 + 10 files changed, 508 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index feda0fd..987afa4 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,66 @@ if __name__ == "__main__": asyncio.run(main()) ``` +## Transactions + +Firedantic basic support for +[Firestore Transactions](https://firebase.google.com/docs/firestore/manage-data/transactions). +The following methods can be used in a transaction: + +- `Model.delete(transaction=transaction)` +- `Model.find_one(transaction=transaction)` +- `Model.find(transaction=transaction)` +- `Model.get_by_doc_id(transaction=transaction)` +- `Model.get_by_id(transaction=transaction)` +- `Model.reload(transaction=transaction)` +- `Model.save(transaction=transaction)` +- `SubModel.get_by_id(transaction=transaction)` + +When using transactions, note that read operations must come before write operations. + +### Transactions Example + +In this example, we are creating a `Profile` model in a transaction that verifies the +email is unique and raises an error if there is a conflict. + +```python +from firedantic import configure +from google.cloud.firestore import transactional +from google.cloud.firestore import Client + +client = Client() +configure(client) + + +class Profile(AsyncModel): + __collection__ = "profiles" + email: str + + +@async_transactional +async def create_in_transaction(transaction, email) -> Profile: + """Creates a Profile in a transaction""" + try: + await Profile.find_one({"email": email}, transaction=transaction) + raise ValueError(f"Profile already exists with email: {email}") + except ModelNotFoundError: + p = Profile(email=email) + await p.save(transaction=transaction) + return p + + +transaction = client.transaction() +p = await create_in_transaction(transaction, "test@example.com") +assert isinstance(p, Profile) +assert p.id + +transaction2 = client.transaction() +try: + await create_in_transaction(transaction2, "test@example.com") +except ValueError as e: + assert str(e) == "Profile already exists with email: test@example.com" +``` + ## Development PRs are welcome! diff --git a/firedantic/_async/helpers.py b/firedantic/_async/helpers.py index 10bd5ef..c77b650 100644 --- a/firedantic/_async/helpers.py +++ b/firedantic/_async/helpers.py @@ -4,7 +4,8 @@ async def truncate_collection( col_ref: AsyncCollectionReference, batch_size: int = 128 ) -> int: - """Removes all documents inside a collection. + """ + Removes all documents inside a collection. :param col_ref: A collection reference to the collection to be truncated. :param batch_size: Batch size for listing documents. diff --git a/firedantic/_async/model.py b/firedantic/_async/model.py index 9c6af1c..52f85ff 100644 --- a/firedantic/_async/model.py +++ b/firedantic/_async/model.py @@ -10,6 +10,7 @@ FieldFilter, ) from google.cloud.firestore_v1.async_query import AsyncQuery +from google.cloud.firestore_v1.async_transaction import AsyncTransaction import firedantic.operators as op from firedantic import async_truncate_collection @@ -41,6 +42,11 @@ def get_collection_name(cls, name: Optional[str]) -> str: + """ + Returns the collection name. + + :raise CollectionNotDefined: If the collection name is not defined. + """ if not name: raise CollectionNotDefined(f"Missing collection name for {cls.__name__}") @@ -55,7 +61,8 @@ def _get_col_ref(cls, name: Optional[str]) -> AsyncCollectionReference: class AsyncBareModel(pydantic.BaseModel, ABC): - """Base model class. + """ + Base model class. Implements basic functionality for Pydantic models, such as save, delete, find etc. """ @@ -66,13 +73,18 @@ class AsyncBareModel(pydantic.BaseModel, ABC): __composite_indexes__: Optional[Iterable[IndexDefinition]] = None async def save( - self, *, exclude_unset: bool = False, exclude_none: bool = False + self, + *, + exclude_unset: bool = False, + exclude_none: bool = False, + transaction: Optional[AsyncTransaction] = None, ) -> None: """ Saves this model in the database. :param exclude_unset: Whether to exclude fields that have not been explicitly set. :param exclude_none: Whether to exclude fields that have a value of `None`. + :param transaction: Optional Transaction to use. :raise DocumentIDError: If the document ID is not valid. """ data = self.model_dump( @@ -82,28 +94,35 @@ async def save( del data[self.__document_id__] doc_ref = self._get_doc_ref() - await doc_ref.set(data) + if transaction is not None: + transaction.set(doc_ref, data) + else: + await doc_ref.set(data) setattr(self, self.__document_id__, doc_ref.id) - async def delete(self) -> None: + async def delete(self, transaction: Optional[AsyncTransaction] = None) -> None: """ Deletes this model from the database. :raise DocumentIDError: If the ID is not valid. """ - await self._get_doc_ref().delete() + if transaction is not None: + transaction.delete(self._get_doc_ref()) + else: + await self._get_doc_ref().delete() - async def reload(self) -> None: + async def reload(self, transaction: Optional[AsyncTransaction] = None) -> None: """ Reloads this model from the database. + :param transaction: Optional Transaction to use. :raise ModelNotFoundError: If the document ID is missing in the model. """ doc_id = self.__dict__.get(self.__document_id__) if doc_id is None: raise ModelNotFoundError("Can not reload unsaved model") - updated_model = await self.get_by_doc_id(doc_id) + updated_model = await self.get_by_doc_id(doc_id, transaction=transaction) updated_model_doc_id = updated_model.__dict__[self.__document_id__] assert doc_id == updated_model_doc_id @@ -111,7 +130,7 @@ async def reload(self) -> None: def get_document_id(self): """ - Get the document ID for this model instance + Returns the document ID for this model instance. :raise DocumentIDError: If the ID is not valid. """ @@ -123,14 +142,17 @@ def get_document_id(self): _OrderBy = List[Tuple[str, OrderDirection]] @classmethod - async def find( + async def find( # pylint: disable=too-many-arguments cls: Type[TAsyncBareModel], filter_: Optional[Dict[str, Union[str, dict]]] = None, order_by: Optional[_OrderBy] = None, limit: Optional[int] = None, offset: Optional[int] = None, + transaction: Optional[AsyncTransaction] = None, ) -> List[TAsyncBareModel]: - """Returns a list of models from the database based on a filter. + """ + Returns a list of models from the database based on a filter. + The list can be sorted with the order_by parameter, limits and offets can also be applied. Example: `Company.find({"company_id": "1234567-8"})`. @@ -142,6 +164,7 @@ async def find( :param order_by: List of columns and direction to order results by. :param limit: Maximum results to return. :param offset: Skip the first n results. + :param transaction: Optional Transaction to use. :return: List of found models. """ query: Union[AsyncQuery, AsyncCollectionReference] = cls._get_col_ref() @@ -175,7 +198,7 @@ def _cls(doc_id: str, data: Dict[str, Any]) -> TAsyncBareModel: return [ _cls(doc_id, doc_dict) async for doc_id, doc_dict in ( - (doc.id, doc.to_dict()) async for doc in query.stream() # type: ignore + (doc.id, doc.to_dict()) async for doc in query.stream(transaction=transaction) # type: ignore ) if doc_dict is not None ] @@ -184,7 +207,7 @@ def _cls(doc_id: str, data: Dict[str, Any]) -> TAsyncBareModel: def _add_filter( cls, query: Union[AsyncQuery, AsyncCollectionReference], field: str, value: Any ) -> Union[AsyncQuery, AsyncCollectionReference]: - if type(value) is dict: + if isinstance(value, dict): for f_type in value: if f_type not in FIND_TYPES: raise ValueError( @@ -193,33 +216,41 @@ def _add_filter( _filter = FieldFilter(field, f_type, value[f_type]) query: AsyncQuery = query.where(filter=_filter) # type: ignore return query - else: - _filter = FieldFilter(field, "==", value) - query: AsyncQuery = query.where(filter=_filter) # type: ignore - return query + _filter = FieldFilter(field, "==", value) + query: AsyncQuery = query.where(filter=_filter) # type: ignore + return query @classmethod async def find_one( cls: Type[TAsyncBareModel], filter_: Optional[Dict[str, Union[str, dict]]] = None, order_by: Optional[_OrderBy] = None, + transaction: Optional[AsyncTransaction] = None, ) -> TAsyncBareModel: - """Returns one model from the DB based on a filter. + """ + Returns one model from the DB based on a filter. :param filter_: The filter criteria. :param order_by: List of columns and direction to order results by. :return: The model instance. :raise ModelNotFoundError: If the entry is not found. """ - model = await cls.find(filter_, limit=1, order_by=order_by) + model = await cls.find( + filter_, limit=1, order_by=order_by, transaction=transaction + ) try: return model[0] - except IndexError: - raise ModelNotFoundError(f"No '{cls.__name__}' found") + except IndexError as e: + raise ModelNotFoundError(f"No '{cls.__name__}' found") from e @classmethod - async def get_by_doc_id(cls: Type[TAsyncBareModel], doc_id: str) -> TAsyncBareModel: - """Returns a model based on the document ID. + async def get_by_doc_id( + cls: Type[TAsyncBareModel], + doc_id: str, + transaction: Optional[AsyncTransaction] = None, + ) -> TAsyncBareModel: + """ + Returns a model based on the document ID. :param doc_id: The document ID of the entry. :return: The model. @@ -228,7 +259,7 @@ async def get_by_doc_id(cls: Type[TAsyncBareModel], doc_id: str) -> TAsyncBareMo try: cls._validate_document_id(doc_id) - except InvalidDocumentID: + except InvalidDocumentID as e: # Getting a document with doc_id set to an empty string would raise a # google.api_core.exceptions.InvalidArgument exception and a doc_id # containing an uneven number of slashes would raise a @@ -236,10 +267,10 @@ async def get_by_doc_id(cls: Type[TAsyncBareModel], doc_id: str) -> TAsyncBareMo # could even load data from a sub collection instead of the desired one. raise ModelNotFoundError( f"No '{cls.__name__}' found with {cls.__document_id__} '{doc_id}'" - ) + ) from e document: DocumentSnapshot = ( - await cls._get_col_ref().document(doc_id).get() + await cls._get_col_ref().document(doc_id).get(transaction=transaction) ) # type: ignore data = document.to_dict() if data is None: @@ -253,7 +284,8 @@ async def get_by_doc_id(cls: Type[TAsyncBareModel], doc_id: str) -> TAsyncBareMo @classmethod async def truncate_collection(cls, batch_size: int = 128) -> int: - """Removes all documents inside a collection. + """ + Removes all documents inside a collection. :param batch_size: Batch size for listing documents. :return: Number of removed documents. @@ -265,11 +297,16 @@ async def truncate_collection(cls, batch_size: int = 128) -> int: @classmethod def _get_col_ref(cls) -> AsyncCollectionReference: - """Returns the collection reference.""" + """ + Returns the collection reference. + """ return _get_col_ref(cls, cls.__collection__) @classmethod def get_collection_name(cls) -> str: + """ + Returns the collection name. + """ return get_collection_name(cls, cls.__collection__) def _get_doc_ref(self) -> AsyncDocumentReference: @@ -319,8 +356,19 @@ class AsyncModel(AsyncBareModel): id: Optional[str] = None @classmethod - async def get_by_id(cls: Type[TAsyncBareModel], id_: str) -> TAsyncBareModel: - return await cls.get_by_doc_id(id_) + async def get_by_id( + cls: Type[TAsyncBareModel], + id_: str, + transaction: Optional[AsyncTransaction] = None, + ) -> TAsyncBareModel: + """ + Get single model by document ID. + + :param id_: Document ID. + :param transaction: Optional Transaction to use. + :raises ModelNotFoundError: + """ + return await cls.get_by_doc_id(id_, transaction=transaction) class AsyncBareSubCollection(ABC): @@ -329,6 +377,9 @@ class AsyncBareSubCollection(ABC): @classmethod def model_for(cls, parent, model_class): + """ + Returns the model for this subcollection. + """ parent_props = parent.model_dump(by_alias=True) name = model_class.__name__ @@ -356,7 +407,9 @@ def _create(cls: Type[TAsyncBareSubModel], **kwargs) -> TAsyncBareSubModel: @classmethod def _get_col_ref(cls) -> AsyncCollectionReference: - """Returns the collection reference.""" + """ + Returns the collection reference. + """ if cls.__collection__ is None or "{" in cls.__collection__: raise CollectionNotDefined( f"{cls.__name__} is not properly prepared. " @@ -366,6 +419,9 @@ def _get_col_ref(cls) -> AsyncCollectionReference: @classmethod def model_for(cls, parent): + """ + Returns the model for this submodel. + """ return cls.Collection.model_for(parent, cls) @@ -373,12 +429,19 @@ class AsyncSubModel(AsyncBareSubModel): id: Optional[str] = None @classmethod - async def get_by_id(cls: Type[TAsyncBareModel], id_: str) -> TAsyncBareModel: + async def get_by_id( + cls: Type[TAsyncBareModel], + id_: str, + transaction: Optional[AsyncTransaction] = None, + ) -> TAsyncBareModel: """ Get single item by document ID + + :param id_: Document ID. + :param transaction: Optional Transaction to use. :raises ModelNotFoundError: """ - return await cls.get_by_doc_id(id_) + return await cls.get_by_doc_id(id_, transaction=transaction) class AsyncSubCollection(AsyncBareSubCollection, ABC): diff --git a/firedantic/_sync/helpers.py b/firedantic/_sync/helpers.py index 3feb0d5..57dfe99 100644 --- a/firedantic/_sync/helpers.py +++ b/firedantic/_sync/helpers.py @@ -2,7 +2,8 @@ def truncate_collection(col_ref: CollectionReference, batch_size: int = 128) -> int: - """Removes all documents inside a collection. + """ + Removes all documents inside a collection. :param col_ref: A collection reference to the collection to be truncated. :param batch_size: Batch size for listing documents. diff --git a/firedantic/_sync/model.py b/firedantic/_sync/model.py index 4fa7d6d..01948d4 100644 --- a/firedantic/_sync/model.py +++ b/firedantic/_sync/model.py @@ -10,6 +10,7 @@ FieldFilter, ) from google.cloud.firestore_v1.base_query import BaseQuery +from google.cloud.firestore_v1.transaction import Transaction import firedantic.operators as op from firedantic import truncate_collection @@ -41,6 +42,11 @@ def get_collection_name(cls, name: Optional[str]) -> str: + """ + Returns the collection name. + + :raise CollectionNotDefined: If the collection name is not defined. + """ if not name: raise CollectionNotDefined(f"Missing collection name for {cls.__name__}") @@ -55,7 +61,8 @@ def _get_col_ref(cls, name: Optional[str]) -> CollectionReference: class BareModel(pydantic.BaseModel, ABC): - """Base model class. + """ + Base model class. Implements basic functionality for Pydantic models, such as save, delete, find etc. """ @@ -65,12 +72,19 @@ class BareModel(pydantic.BaseModel, ABC): __ttl_field__: Optional[str] = None __composite_indexes__: Optional[Iterable[IndexDefinition]] = None - def save(self, *, exclude_unset: bool = False, exclude_none: bool = False) -> None: + def save( + self, + *, + exclude_unset: bool = False, + exclude_none: bool = False, + transaction: Optional[Transaction] = None, + ) -> None: """ Saves this model in the database. :param exclude_unset: Whether to exclude fields that have not been explicitly set. :param exclude_none: Whether to exclude fields that have a value of `None`. + :param transaction: Optional Transaction to use. :raise DocumentIDError: If the document ID is not valid. """ data = self.model_dump( @@ -80,28 +94,35 @@ def save(self, *, exclude_unset: bool = False, exclude_none: bool = False) -> No del data[self.__document_id__] doc_ref = self._get_doc_ref() - doc_ref.set(data) + if transaction is not None: + transaction.set(doc_ref, data) + else: + doc_ref.set(data) setattr(self, self.__document_id__, doc_ref.id) - def delete(self) -> None: + def delete(self, transaction: Optional[Transaction] = None) -> None: """ Deletes this model from the database. :raise DocumentIDError: If the ID is not valid. """ - self._get_doc_ref().delete() + if transaction is not None: + transaction.delete(self._get_doc_ref()) + else: + self._get_doc_ref().delete() - def reload(self) -> None: + def reload(self, transaction: Optional[Transaction] = None) -> None: """ Reloads this model from the database. + :param transaction: Optional Transaction to use. :raise ModelNotFoundError: If the document ID is missing in the model. """ doc_id = self.__dict__.get(self.__document_id__) if doc_id is None: raise ModelNotFoundError("Can not reload unsaved model") - updated_model = self.get_by_doc_id(doc_id) + updated_model = self.get_by_doc_id(doc_id, transaction=transaction) updated_model_doc_id = updated_model.__dict__[self.__document_id__] assert doc_id == updated_model_doc_id @@ -109,7 +130,7 @@ def reload(self) -> None: def get_document_id(self): """ - Get the document ID for this model instance + Returns the document ID for this model instance. :raise DocumentIDError: If the ID is not valid. """ @@ -121,14 +142,17 @@ def get_document_id(self): _OrderBy = List[Tuple[str, OrderDirection]] @classmethod - def find( + def find( # pylint: disable=too-many-arguments cls: Type[TBareModel], filter_: Optional[Dict[str, Union[str, dict]]] = None, order_by: Optional[_OrderBy] = None, limit: Optional[int] = None, offset: Optional[int] = None, + transaction: Optional[Transaction] = None, ) -> List[TBareModel]: - """Returns a list of models from the database based on a filter. + """ + Returns a list of models from the database based on a filter. + The list can be sorted with the order_by parameter, limits and offets can also be applied. Example: `Company.find({"company_id": "1234567-8"})`. @@ -140,6 +164,7 @@ def find( :param order_by: List of columns and direction to order results by. :param limit: Maximum results to return. :param offset: Skip the first n results. + :param transaction: Optional Transaction to use. :return: List of found models. """ query: Union[BaseQuery, CollectionReference] = cls._get_col_ref() @@ -173,7 +198,7 @@ def _cls(doc_id: str, data: Dict[str, Any]) -> TBareModel: return [ _cls(doc_id, doc_dict) for doc_id, doc_dict in ( - (doc.id, doc.to_dict()) for doc in query.stream() # type: ignore + (doc.id, doc.to_dict()) for doc in query.stream(transaction=transaction) # type: ignore ) if doc_dict is not None ] @@ -182,7 +207,7 @@ def _cls(doc_id: str, data: Dict[str, Any]) -> TBareModel: def _add_filter( cls, query: Union[BaseQuery, CollectionReference], field: str, value: Any ) -> Union[BaseQuery, CollectionReference]: - if type(value) is dict: + if isinstance(value, dict): for f_type in value: if f_type not in FIND_TYPES: raise ValueError( @@ -191,33 +216,39 @@ def _add_filter( _filter = FieldFilter(field, f_type, value[f_type]) query: BaseQuery = query.where(filter=_filter) # type: ignore return query - else: - _filter = FieldFilter(field, "==", value) - query: BaseQuery = query.where(filter=_filter) # type: ignore - return query + _filter = FieldFilter(field, "==", value) + query: BaseQuery = query.where(filter=_filter) # type: ignore + return query @classmethod def find_one( cls: Type[TBareModel], filter_: Optional[Dict[str, Union[str, dict]]] = None, order_by: Optional[_OrderBy] = None, + transaction: Optional[Transaction] = None, ) -> TBareModel: - """Returns one model from the DB based on a filter. + """ + Returns one model from the DB based on a filter. :param filter_: The filter criteria. :param order_by: List of columns and direction to order results by. :return: The model instance. :raise ModelNotFoundError: If the entry is not found. """ - model = cls.find(filter_, limit=1, order_by=order_by) + model = cls.find(filter_, limit=1, order_by=order_by, transaction=transaction) try: return model[0] - except IndexError: - raise ModelNotFoundError(f"No '{cls.__name__}' found") + except IndexError as e: + raise ModelNotFoundError(f"No '{cls.__name__}' found") from e @classmethod - def get_by_doc_id(cls: Type[TBareModel], doc_id: str) -> TBareModel: - """Returns a model based on the document ID. + def get_by_doc_id( + cls: Type[TBareModel], + doc_id: str, + transaction: Optional[Transaction] = None, + ) -> TBareModel: + """ + Returns a model based on the document ID. :param doc_id: The document ID of the entry. :return: The model. @@ -226,7 +257,7 @@ def get_by_doc_id(cls: Type[TBareModel], doc_id: str) -> TBareModel: try: cls._validate_document_id(doc_id) - except InvalidDocumentID: + except InvalidDocumentID as e: # Getting a document with doc_id set to an empty string would raise a # google.api_core.exceptions.InvalidArgument exception and a doc_id # containing an uneven number of slashes would raise a @@ -234,10 +265,10 @@ def get_by_doc_id(cls: Type[TBareModel], doc_id: str) -> TBareModel: # could even load data from a sub collection instead of the desired one. raise ModelNotFoundError( f"No '{cls.__name__}' found with {cls.__document_id__} '{doc_id}'" - ) + ) from e document: DocumentSnapshot = ( - cls._get_col_ref().document(doc_id).get() + cls._get_col_ref().document(doc_id).get(transaction=transaction) ) # type: ignore data = document.to_dict() if data is None: @@ -251,7 +282,8 @@ def get_by_doc_id(cls: Type[TBareModel], doc_id: str) -> TBareModel: @classmethod def truncate_collection(cls, batch_size: int = 128) -> int: - """Removes all documents inside a collection. + """ + Removes all documents inside a collection. :param batch_size: Batch size for listing documents. :return: Number of removed documents. @@ -263,11 +295,16 @@ def truncate_collection(cls, batch_size: int = 128) -> int: @classmethod def _get_col_ref(cls) -> CollectionReference: - """Returns the collection reference.""" + """ + Returns the collection reference. + """ return _get_col_ref(cls, cls.__collection__) @classmethod def get_collection_name(cls) -> str: + """ + Returns the collection name. + """ return get_collection_name(cls, cls.__collection__) def _get_doc_ref(self) -> DocumentReference: @@ -317,8 +354,19 @@ class Model(BareModel): id: Optional[str] = None @classmethod - def get_by_id(cls: Type[TBareModel], id_: str) -> TBareModel: - return cls.get_by_doc_id(id_) + def get_by_id( + cls: Type[TBareModel], + id_: str, + transaction: Optional[Transaction] = None, + ) -> TBareModel: + """ + Get single model by document ID. + + :param id_: Document ID. + :param transaction: Optional Transaction to use. + :raises ModelNotFoundError: + """ + return cls.get_by_doc_id(id_, transaction=transaction) class BareSubCollection(ABC): @@ -327,6 +375,9 @@ class BareSubCollection(ABC): @classmethod def model_for(cls, parent, model_class): + """ + Returns the model for this subcollection. + """ parent_props = parent.model_dump(by_alias=True) name = model_class.__name__ @@ -354,7 +405,9 @@ def _create(cls: Type[TBareSubModel], **kwargs) -> TBareSubModel: @classmethod def _get_col_ref(cls) -> CollectionReference: - """Returns the collection reference.""" + """ + Returns the collection reference. + """ if cls.__collection__ is None or "{" in cls.__collection__: raise CollectionNotDefined( f"{cls.__name__} is not properly prepared. " @@ -364,6 +417,9 @@ def _get_col_ref(cls) -> CollectionReference: @classmethod def model_for(cls, parent): + """ + Returns the model for this submodel. + """ return cls.Collection.model_for(parent, cls) @@ -371,12 +427,19 @@ class SubModel(BareSubModel): id: Optional[str] = None @classmethod - def get_by_id(cls: Type[TBareModel], id_: str) -> TBareModel: + def get_by_id( + cls: Type[TBareModel], + id_: str, + transaction: Optional[Transaction] = None, + ) -> TBareModel: """ Get single item by document ID + + :param id_: Document ID. + :param transaction: Optional Transaction to use. :raises ModelNotFoundError: """ - return cls.get_by_doc_id(id_) + return cls.get_by_doc_id(id_, transaction=transaction) class SubCollection(BareSubCollection, ABC): diff --git a/firedantic/tests/tests_async/conftest.py b/firedantic/tests/tests_async/conftest.py index ec1aaeb..96e6308 100644 --- a/firedantic/tests/tests_async/conftest.py +++ b/firedantic/tests/tests_async/conftest.py @@ -6,7 +6,7 @@ import pytest from google.cloud.firestore_admin_v1 import Field, FirestoreAdminClient from google.cloud.firestore_v1 import AsyncClient -from pydantic import BaseModel, Extra, PrivateAttr +from pydantic import BaseModel, PrivateAttr from firedantic import ( AsyncBareModel, @@ -30,7 +30,7 @@ class CustomIDModel(AsyncBareModel): bar: str class Config: - extra = Extra.forbid + extra = "forbid" class CustomIDModelExtra(AsyncBareModel): @@ -42,7 +42,7 @@ class CustomIDModelExtra(AsyncBareModel): baz: str class Config: - extra = Extra.forbid + extra = "forbid" class CustomIDConflictModel(AsyncModel): @@ -52,7 +52,7 @@ class CustomIDConflictModel(AsyncModel): bar: str class Config: - extra = Extra.forbid + extra = "forbid" class Owner(BaseModel): @@ -62,7 +62,7 @@ class Owner(BaseModel): last_name: str class Config: - extra = Extra.forbid + extra = "forbid" class CompanyStats(AsyncBareSubModel): @@ -99,7 +99,7 @@ class Company(AsyncModel): owner: Owner class Config: - extra = Extra.forbid + extra = "forbid" def stats(self) -> Type[CompanyStats]: return CompanyStats.model_for(self) # type: ignore @@ -115,7 +115,7 @@ class Product(AsyncModel): stock: int class Config: - extra = Extra.forbid + extra = "forbid" class Profile(AsyncModel): @@ -124,10 +124,11 @@ class Profile(AsyncModel): __collection__ = "profiles" name: Optional[str] = "" + email: Optional[str] = None photo_url: Optional[str] = None class Config: - extra = Extra.forbid + extra = "forbid" class TodoList(AsyncModel): @@ -139,7 +140,7 @@ class TodoList(AsyncModel): items: List[str] class Config: - extra = Extra.forbid + extra = "forbid" class ExpiringModel(AsyncModel): diff --git a/firedantic/tests/tests_async/test_model.py b/firedantic/tests/tests_async/test_model.py index 501dad7..0bcfb9a 100644 --- a/firedantic/tests/tests_async/test_model.py +++ b/firedantic/tests/tests_async/test_model.py @@ -2,11 +2,13 @@ from uuid import uuid4 import pytest -from google.cloud.firestore import Query +from google.cloud.firestore import Query, async_transactional +from google.cloud.firestore_v1.async_transaction import AsyncTransaction from pydantic import Field, ValidationError import firedantic.operators as op from firedantic import AsyncModel +from firedantic.configurations import CONFIGURATIONS from firedantic.exceptions import ( CollectionNotDefined, InvalidDocumentID, @@ -512,7 +514,7 @@ async def test_save_with_exclude_none(configure_db) -> None: document = await Profile._get_col_ref().document(document_id).get() data = document.to_dict() - assert data == {"name": "Foo", "photo_url": None} + assert data == {"name": "Foo", "email": None, "photo_url": None} @pytest.mark.asyncio @@ -534,4 +536,116 @@ async def test_save_with_exclude_unset(configure_db) -> None: document = await Profile._get_col_ref().document(document_id).get() data = document.to_dict() - assert data == {"name": "", "photo_url": None} + assert data == {"name": "", "email": None, "photo_url": None} + + +@pytest.mark.asyncio +async def test_create_in_transaction(configure_db) -> None: + """Test creating a model in a transaction. Test case from README.""" + + @async_transactional # type: ignore + async def create_in_transaction(transaction, email) -> Profile: + """Creates a Profile in a transaction""" + try: + await Profile.find_one({"email": email}, transaction=transaction) + raise ValueError(f"Profile already exists with email: {email}") + except ModelNotFoundError: + p = Profile(email=email) + await p.save(transaction=transaction) + return p + + transaction = CONFIGURATIONS["db"].transaction() + p = await create_in_transaction(transaction, "test@example.com") + assert isinstance(p, Profile) + assert p.id + + transaction2 = CONFIGURATIONS["db"].transaction() + with pytest.raises(ValueError) as excinfo: + await create_in_transaction(transaction2, "test@example.com") + assert str(excinfo.value) == "Profile already exists with email: test@example.com" + + +@pytest.mark.asyncio +async def test_delete_in_transaction(configure_db) -> None: + """Test deleting a model in a transaction.""" + + @async_transactional # type: ignore + async def delete_in_transaction( + transaction: AsyncTransaction, profile_id: str + ) -> dict: + """Deletes a Profile in a transaction.""" + try: + p = await Profile.get_by_id(profile_id, transaction=transaction) + await p.delete(transaction=transaction) + return {"id": profile_id} + except ModelNotFoundError: + raise ValueError(f"Profile not found: {profile_id}") + + p = Profile(name="Foo") + await p.save() + assert p.id + + transaction = CONFIGURATIONS["db"].transaction() + result = await delete_in_transaction(transaction, p.id) + assert isinstance(result, dict) + assert result == {"id": p.id} + + transaction2 = CONFIGURATIONS["db"].transaction() + with pytest.raises(ValueError) as excinfo: + await delete_in_transaction(transaction2, p.id) + assert str(excinfo.value) == f"Profile not found: {p.id}" + + +@pytest.mark.asyncio +async def test_update_model_in_transaction(configure_db) -> None: + """Test updating a model in a transaction.""" + + @async_transactional # type: ignore + async def update_in_transaction( + transaction: AsyncTransaction, profile_id: str, name: str + ) -> Profile: + """Updates a Profile in a transaction.""" + p = Profile(id=profile_id) + await p.reload(transaction=transaction) + p.name = name + await p.save(transaction=transaction) + return p + + p = Profile(name="Foo") + await p.save() + assert p.id + + transaction = CONFIGURATIONS["db"].transaction() + result = await update_in_transaction(transaction, p.id, "Bar") + assert isinstance(result, Profile) + assert result.id == p.id + assert result.name == "Bar" + + +@pytest.mark.asyncio +async def test_update_submodel_in_transaction(configure_db) -> None: + """Test Updating a submodel in a transaction.""" + + @async_transactional # type: ignore + async def update_submodel_in_transaction( + transaction: AsyncTransaction, user_id: str, period: str, purchases: int + ) -> UserStats: + """Updates a UserStats in a transaction.""" + u = await User.get_by_id(user_id) + us = UserStats.model_for(u) + user_stats: UserStats = await us.get_by_id(period) # pylint: disable=no-member + user_stats.purchases = purchases + await user_stats.save(transaction=transaction) + return user_stats + + u = User(name="Foo") + await u.save() + assert u.id + us = UserStats.model_for(u) + await us(id="2021", purchases=42).save() # pylint: disable=no-member + + transaction = CONFIGURATIONS["db"].transaction() + user_stats = await update_submodel_in_transaction(transaction, u.id, "2021", 43) + assert isinstance(user_stats, UserStats) + assert user_stats.purchases == 43 + assert await get_user_purchases(u.id) == 43 diff --git a/firedantic/tests/tests_sync/conftest.py b/firedantic/tests/tests_sync/conftest.py index ea7d4c2..d9856cd 100644 --- a/firedantic/tests/tests_sync/conftest.py +++ b/firedantic/tests/tests_sync/conftest.py @@ -6,7 +6,7 @@ import pytest from google.cloud.firestore_admin_v1 import Field, FirestoreAdminClient from google.cloud.firestore_v1 import Client -from pydantic import BaseModel, Extra, PrivateAttr +from pydantic import BaseModel, PrivateAttr from firedantic import ( BareModel, @@ -30,7 +30,7 @@ class CustomIDModel(BareModel): bar: str class Config: - extra = Extra.forbid + extra = "forbid" class CustomIDModelExtra(BareModel): @@ -42,7 +42,7 @@ class CustomIDModelExtra(BareModel): baz: str class Config: - extra = Extra.forbid + extra = "forbid" class CustomIDConflictModel(Model): @@ -52,7 +52,7 @@ class CustomIDConflictModel(Model): bar: str class Config: - extra = Extra.forbid + extra = "forbid" class Owner(BaseModel): @@ -62,7 +62,7 @@ class Owner(BaseModel): last_name: str class Config: - extra = Extra.forbid + extra = "forbid" class CompanyStats(BareSubModel): @@ -99,7 +99,7 @@ class Company(Model): owner: Owner class Config: - extra = Extra.forbid + extra = "forbid" def stats(self) -> Type[CompanyStats]: return CompanyStats.model_for(self) # type: ignore @@ -115,7 +115,7 @@ class Product(Model): stock: int class Config: - extra = Extra.forbid + extra = "forbid" class Profile(Model): @@ -124,10 +124,11 @@ class Profile(Model): __collection__ = "profiles" name: Optional[str] = "" + email: Optional[str] = None photo_url: Optional[str] = None class Config: - extra = Extra.forbid + extra = "forbid" class TodoList(Model): @@ -139,7 +140,7 @@ class TodoList(Model): items: List[str] class Config: - extra = Extra.forbid + extra = "forbid" class ExpiringModel(Model): diff --git a/firedantic/tests/tests_sync/test_model.py b/firedantic/tests/tests_sync/test_model.py index 0f62a5b..0a5f9bb 100644 --- a/firedantic/tests/tests_sync/test_model.py +++ b/firedantic/tests/tests_sync/test_model.py @@ -2,11 +2,13 @@ from uuid import uuid4 import pytest -from google.cloud.firestore import Query +from google.cloud.firestore import Query, transactional +from google.cloud.firestore_v1.transaction import Transaction from pydantic import Field, ValidationError import firedantic.operators as op from firedantic import Model +from firedantic.configurations import CONFIGURATIONS from firedantic.exceptions import ( CollectionNotDefined, InvalidDocumentID, @@ -478,7 +480,7 @@ def test_save_with_exclude_none(configure_db) -> None: document = Profile._get_col_ref().document(document_id).get() data = document.to_dict() - assert data == {"name": "Foo", "photo_url": None} + assert data == {"name": "Foo", "email": None, "photo_url": None} def test_save_with_exclude_unset(configure_db) -> None: @@ -499,4 +501,110 @@ def test_save_with_exclude_unset(configure_db) -> None: document = Profile._get_col_ref().document(document_id).get() data = document.to_dict() - assert data == {"name": "", "photo_url": None} + assert data == {"name": "", "email": None, "photo_url": None} + + +def test_create_in_transaction(configure_db) -> None: + """Test creating a model in a transaction. Test case from README.""" + + @transactional # type: ignore + def create_in_transaction(transaction, email) -> Profile: + """Creates a Profile in a transaction""" + try: + Profile.find_one({"email": email}, transaction=transaction) + raise ValueError(f"Profile already exists with email: {email}") + except ModelNotFoundError: + p = Profile(email=email) + p.save(transaction=transaction) + return p + + transaction = CONFIGURATIONS["db"].transaction() + p = create_in_transaction(transaction, "test@example.com") + assert isinstance(p, Profile) + assert p.id + + transaction2 = CONFIGURATIONS["db"].transaction() + with pytest.raises(ValueError) as excinfo: + create_in_transaction(transaction2, "test@example.com") + assert str(excinfo.value) == "Profile already exists with email: test@example.com" + + +def test_delete_in_transaction(configure_db) -> None: + """Test deleting a model in a transaction.""" + + @transactional # type: ignore + def delete_in_transaction(transaction: Transaction, profile_id: str) -> dict: + """Deletes a Profile in a transaction.""" + try: + p = Profile.get_by_id(profile_id, transaction=transaction) + p.delete(transaction=transaction) + return {"id": profile_id} + except ModelNotFoundError: + raise ValueError(f"Profile not found: {profile_id}") + + p = Profile(name="Foo") + p.save() + assert p.id + + transaction = CONFIGURATIONS["db"].transaction() + result = delete_in_transaction(transaction, p.id) + assert isinstance(result, dict) + assert result == {"id": p.id} + + transaction2 = CONFIGURATIONS["db"].transaction() + with pytest.raises(ValueError) as excinfo: + delete_in_transaction(transaction2, p.id) + assert str(excinfo.value) == f"Profile not found: {p.id}" + + +def test_update_model_in_transaction(configure_db) -> None: + """Test updating a model in a transaction.""" + + @transactional # type: ignore + def update_in_transaction( + transaction: Transaction, profile_id: str, name: str + ) -> Profile: + """Updates a Profile in a transaction.""" + p = Profile(id=profile_id) + p.reload(transaction=transaction) + p.name = name + p.save(transaction=transaction) + return p + + p = Profile(name="Foo") + p.save() + assert p.id + + transaction = CONFIGURATIONS["db"].transaction() + result = update_in_transaction(transaction, p.id, "Bar") + assert isinstance(result, Profile) + assert result.id == p.id + assert result.name == "Bar" + + +def test_update_submodel_in_transaction(configure_db) -> None: + """Test Updating a submodel in a transaction.""" + + @transactional # type: ignore + def update_submodel_in_transaction( + transaction: Transaction, user_id: str, period: str, purchases: int + ) -> UserStats: + """Updates a UserStats in a transaction.""" + u = User.get_by_id(user_id) + us = UserStats.model_for(u) + user_stats: UserStats = us.get_by_id(period) # pylint: disable=no-member + user_stats.purchases = purchases + user_stats.save(transaction=transaction) + return user_stats + + u = User(name="Foo") + u.save() + assert u.id + us = UserStats.model_for(u) + us(id="2021", purchases=42).save() # pylint: disable=no-member + + transaction = CONFIGURATIONS["db"].transaction() + user_stats = update_submodel_in_transaction(transaction, u.id, "2021", 43) + assert isinstance(user_stats, UserStats) + assert user_stats.purchases == 43 + assert get_user_purchases(u.id) == 43 diff --git a/unasync.py b/unasync.py index e6ac175..f7ca343 100644 --- a/unasync.py +++ b/unasync.py @@ -5,6 +5,12 @@ from pathlib import Path SUBS = [ + ( + "from google.cloud.firestore_v1.async_transaction", + "from google.cloud.firestore_v1.transaction", + ), + ("async_transactional", "transactional"), + ("AsyncTransaction", "Transaction"), ("google.cloud.firestore_v1.async_query", "google.cloud.firestore_v1.base_query"), ("AsyncQuery", "BaseQuery"), ("AsyncCollectionReference", "CollectionReference"), From 19a91abcf7261faabf539eb10068a73548fe93fb Mon Sep 17 00:00:00 2001 From: Lukas Karlsson Date: Mon, 26 May 2025 20:49:09 -0400 Subject: [PATCH 02/11] Updated for initial support for transactions. --- README.md | 52 +++++------ firedantic/_async/model.py | 29 ++++-- firedantic/_sync/model.py | 29 ++++-- firedantic/configurations.py | 53 +++++++++-- firedantic/exceptions.py | 15 ++- firedantic/tests/tests_async/conftest.py | 7 ++ firedantic/tests/tests_async/test_model.py | 102 ++++++++++++--------- firedantic/tests/tests_sync/conftest.py | 7 ++ firedantic/tests/tests_sync/test_model.py | 102 ++++++++++++--------- 9 files changed, 257 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index 987afa4..102861c 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ if __name__ == "__main__": ## Transactions -Firedantic basic support for +Firedantic has basic support for [Firestore Transactions](https://firebase.google.com/docs/firestore/manage-data/transactions). The following methods can be used in a transaction: @@ -337,47 +337,43 @@ The following methods can be used in a transaction: When using transactions, note that read operations must come before write operations. -### Transactions Example +### Transaction example -In this example, we are creating a `Profile` model in a transaction that verifies the -email is unique and raises an error if there is a conflict. +In this example, we are updating a `City` to increase the population by 1. ```python from firedantic import configure -from google.cloud.firestore import transactional -from google.cloud.firestore import Client +from google.cloud.firestore import async_transactional +from google.cloud.firestore import AyncClient -client = Client() +client = AsyncClient() configure(client) -class Profile(AsyncModel): - __collection__ = "profiles" - email: str +class City(AsyncModel): + __collection__ = "cities" + population: int @async_transactional -async def create_in_transaction(transaction, email) -> Profile: - """Creates a Profile in a transaction""" - try: - await Profile.find_one({"email": email}, transaction=transaction) - raise ValueError(f"Profile already exists with email: {email}") - except ModelNotFoundError: - p = Profile(email=email) - await p.save(transaction=transaction) - return p +async def update_in_transaction(transaction, city_ref) -> City: + """ + Updates a City in a transaction + + :param transaction: Firestore Transaction + :param city_ref: City reference + :return: City + """ + city = await City.get_by_id(city_ref, transaction=transaction) + city.population += 1 + await city.save(transaction=transaction) + return city transaction = client.transaction() -p = await create_in_transaction(transaction, "test@example.com") -assert isinstance(p, Profile) -assert p.id - -transaction2 = client.transaction() -try: - await create_in_transaction(transaction2, "test@example.com") -except ValueError as e: - assert str(e) == "Profile already exists with email: test@example.com" +city = await update_in_transaction(transaction, "SF") +assert isinstance(city, City) +assert city.id == "SF" ``` ## Development diff --git a/firedantic/_async/model.py b/firedantic/_async/model.py index 52f85ff..fed09d2 100644 --- a/firedantic/_async/model.py +++ b/firedantic/_async/model.py @@ -15,9 +15,10 @@ import firedantic.operators as op from firedantic import async_truncate_collection from firedantic.common import IndexDefinition, OrderDirection -from firedantic.configurations import CONFIGURATIONS +from firedantic.configurations import CONFIGURATIONS, Configuration from firedantic.exceptions import ( CollectionNotDefined, + ConfigurationNotFoundError, InvalidDocumentID, ModelNotFoundError, ) @@ -41,20 +42,35 @@ } +def get_configuration(name: str = "default") -> Configuration: + """ + Returns the configuration with the given name. + + :params name: The configuration name. + :return: The configuration with the given name. + :raise ConfigurationNotFoundError: If the configuration is not found. + """ + if name in CONFIGURATIONS: + return CONFIGURATIONS[name] + raise ConfigurationNotFoundError(f"Configuration `{name}` not found") + + def get_collection_name(cls, name: Optional[str]) -> str: """ - Returns the collection name. + Returns the collection name with the optional prefix. + :params cls: The model class. + :params name: The collection name. + :return: The collection name with the optional prefix. :raise CollectionNotDefined: If the collection name is not defined. """ if not name: raise CollectionNotDefined(f"Missing collection name for {cls.__name__}") - - return f"{CONFIGURATIONS['prefix']}{name}" + return f"{get_configuration().prefix}{name}" def _get_col_ref(cls, name: Optional[str]) -> AsyncCollectionReference: - collection: AsyncCollectionReference = CONFIGURATIONS["db"].collection( + collection: AsyncCollectionReference = get_configuration().db.collection( get_collection_name(cls, name) ) return collection @@ -253,6 +269,7 @@ async def get_by_doc_id( Returns a model based on the document ID. :param doc_id: The document ID of the entry. + :param transaction: Optional Transaction to use. :return: The model. :raise ModelNotFoundError: Raised if no matching document is found. """ @@ -366,7 +383,7 @@ async def get_by_id( :param id_: Document ID. :param transaction: Optional Transaction to use. - :raises ModelNotFoundError: + :raises ModelNotFoundError: if no model was found by given id """ return await cls.get_by_doc_id(id_, transaction=transaction) diff --git a/firedantic/_sync/model.py b/firedantic/_sync/model.py index 01948d4..7909589 100644 --- a/firedantic/_sync/model.py +++ b/firedantic/_sync/model.py @@ -15,9 +15,10 @@ import firedantic.operators as op from firedantic import truncate_collection from firedantic.common import IndexDefinition, OrderDirection -from firedantic.configurations import CONFIGURATIONS +from firedantic.configurations import CONFIGURATIONS, Configuration from firedantic.exceptions import ( CollectionNotDefined, + ConfigurationNotFoundError, InvalidDocumentID, ModelNotFoundError, ) @@ -41,20 +42,35 @@ } +def get_configuration(name: str = "default") -> Configuration: + """ + Returns the configuration with the given name. + + :params name: The configuration name. + :return: The configuration with the given name. + :raise ConfigurationNotFoundError: If the configuration is not found. + """ + if name in CONFIGURATIONS: + return CONFIGURATIONS[name] + raise ConfigurationNotFoundError(f"Configuration `{name}` not found") + + def get_collection_name(cls, name: Optional[str]) -> str: """ - Returns the collection name. + Returns the collection name with the optional prefix. + :params cls: The model class. + :params name: The collection name. + :return: The collection name with the optional prefix. :raise CollectionNotDefined: If the collection name is not defined. """ if not name: raise CollectionNotDefined(f"Missing collection name for {cls.__name__}") - - return f"{CONFIGURATIONS['prefix']}{name}" + return f"{get_configuration().prefix}{name}" def _get_col_ref(cls, name: Optional[str]) -> CollectionReference: - collection: CollectionReference = CONFIGURATIONS["db"].collection( + collection: CollectionReference = get_configuration().db.collection( get_collection_name(cls, name) ) return collection @@ -251,6 +267,7 @@ def get_by_doc_id( Returns a model based on the document ID. :param doc_id: The document ID of the entry. + :param transaction: Optional Transaction to use. :return: The model. :raise ModelNotFoundError: Raised if no matching document is found. """ @@ -364,7 +381,7 @@ def get_by_id( :param id_: Document ID. :param transaction: Optional Transaction to use. - :raises ModelNotFoundError: + :raises ModelNotFoundError: if no model was found by given id """ return cls.get_by_doc_id(id_, transaction=transaction) diff --git a/firedantic/configurations.py b/firedantic/configurations.py index 321a4af..9328ac0 100644 --- a/firedantic/configurations.py +++ b/firedantic/configurations.py @@ -1,17 +1,58 @@ -from typing import Any, Dict, Union +from typing import Dict, Union from google.cloud.firestore_v1 import AsyncClient, Client +from pydantic import BaseModel -CONFIGURATIONS: Dict[str, Any] = {} + +class Configuration(BaseModel): + """ + Defines a single configuration. + """ + + db: Union[Client, AsyncClient] + prefix: str = "" + + +class ConfigurationDict(dict): + """ + A dictionary-like object to handle multiple configurations with backward compatibility. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __setitem__(self, key, value): + if key in ("db", "prefix"): + raise ValueError(f"Cannot create configuration named '{key}'.") + super().__setitem__(key, value) + + def __getitem__(self, key): + if "default" not in self or not self["default"] is None: + raise ValueError( + "No default configuration found. Run `configure` to get started." + ) + if key == "db": + return self["default"] + if key == "prefix": + return self["default"].prefix + return super().__getitem__(key) + + +CONFIGURATIONS: Dict[str, Configuration] = ConfigurationDict() def configure(db: Union[Client, AsyncClient], prefix: str = "") -> None: - """Configures the prefix and DB. + """ + Configures the prefix and DB. :param db: The firestore client instance. :param prefix: The prefix to use for collection names. """ - global CONFIGURATIONS + global CONFIGURATIONS # pylint: disable=global-statement,global-variable-not-assigned + + # CONFIGURATIONS["db"] = db + # CONFIGURATIONS["prefix"] = prefix + + configuration = Configuration(db=db, prefix=prefix) - CONFIGURATIONS["db"] = db - CONFIGURATIONS["prefix"] = prefix + CONFIGURATIONS["default"] = configuration diff --git a/firedantic/exceptions.py b/firedantic/exceptions.py index af36989..753c5b0 100644 --- a/firedantic/exceptions.py +++ b/firedantic/exceptions.py @@ -1,16 +1,21 @@ +"""Firedantic Exceptions module.""" + + +class ConfigurationNotFoundError(Exception): + """Raised when a configuration is not found.""" + + class ModelError(Exception): """Generic model error class.""" - pass - class InvalidDocumentID(ModelError): - pass + """Raised when a document ID is invalid.""" class ModelNotFoundError(ModelError): - pass + """Raised when a model is not found.""" class CollectionNotDefined(ModelError): - pass + """Raised when the model collection is not defined.""" diff --git a/firedantic/tests/tests_async/conftest.py b/firedantic/tests/tests_async/conftest.py index 96e6308..cc7a7ae 100644 --- a/firedantic/tests/tests_async/conftest.py +++ b/firedantic/tests/tests_async/conftest.py @@ -55,6 +55,13 @@ class Config: extra = "forbid" +class City(AsyncModel): + """Dummy city Firedantic model.""" + + __collection__ = "cities" + population: int + + class Owner(BaseModel): """Dummy owner Pydantic model.""" diff --git a/firedantic/tests/tests_async/test_model.py b/firedantic/tests/tests_async/test_model.py index 0bcfb9a..f80d330 100644 --- a/firedantic/tests/tests_async/test_model.py +++ b/firedantic/tests/tests_async/test_model.py @@ -15,6 +15,7 @@ ModelNotFoundError, ) from firedantic.tests.tests_async.conftest import ( + City, Company, CustomIDConflictModel, CustomIDModel, @@ -540,46 +541,52 @@ async def test_save_with_exclude_unset(configure_db) -> None: @pytest.mark.asyncio -async def test_create_in_transaction(configure_db) -> None: - """Test creating a model in a transaction. Test case from README.""" +async def test_update_city_in_transaction(configure_db) -> None: + """ + Test updating a model in a transaction. Test case from README. - @async_transactional # type: ignore - async def create_in_transaction(transaction, email) -> Profile: - """Creates a Profile in a transaction""" - try: - await Profile.find_one({"email": email}, transaction=transaction) - raise ValueError(f"Profile already exists with email: {email}") - except ModelNotFoundError: - p = Profile(email=email) - await p.save(transaction=transaction) - return p + :param: configure_db: pytest fixture + """ - transaction = CONFIGURATIONS["db"].transaction() - p = await create_in_transaction(transaction, "test@example.com") - assert isinstance(p, Profile) - assert p.id + @async_transactional + async def update_in_transaction(transaction, city_ref) -> City: + """ + Updates a City in a transaction - transaction2 = CONFIGURATIONS["db"].transaction() - with pytest.raises(ValueError) as excinfo: - await create_in_transaction(transaction2, "test@example.com") - assert str(excinfo.value) == "Profile already exists with email: test@example.com" + :param transaction: Firestore Transaction + :param city_ref: City reference + :return: City + """ + city = await City.get_by_id(city_ref, transaction=transaction) + city.population += 1 + await city.save(transaction=transaction) + return city + + c = City(id="SF", population=1) + + transaction = CONFIGURATIONS["db"].transaction() + city = await update_in_transaction(transaction, c.id) + assert isinstance(city, City) + assert city.id == "SF" + assert city.population == 2 @pytest.mark.asyncio async def test_delete_in_transaction(configure_db) -> None: - """Test deleting a model in a transaction.""" + """ + Test deleting a model in a transaction. + + :param: configure_db: pytest fixture + """ @async_transactional # type: ignore async def delete_in_transaction( transaction: AsyncTransaction, profile_id: str - ) -> dict: + ) -> str: """Deletes a Profile in a transaction.""" - try: - p = await Profile.get_by_id(profile_id, transaction=transaction) - await p.delete(transaction=transaction) - return {"id": profile_id} - except ModelNotFoundError: - raise ValueError(f"Profile not found: {profile_id}") + profile = await Profile.get_by_id(profile_id, transaction=transaction) + await profile.delete(transaction=transaction) + return profile_id p = Profile(name="Foo") await p.save() @@ -588,28 +595,30 @@ async def delete_in_transaction( transaction = CONFIGURATIONS["db"].transaction() result = await delete_in_transaction(transaction, p.id) assert isinstance(result, dict) - assert result == {"id": p.id} + assert result == p.id - transaction2 = CONFIGURATIONS["db"].transaction() - with pytest.raises(ValueError) as excinfo: - await delete_in_transaction(transaction2, p.id) - assert str(excinfo.value) == f"Profile not found: {p.id}" + with pytest.raises(ModelNotFoundError): + await Profile.get_by_id(p.id) @pytest.mark.asyncio async def test_update_model_in_transaction(configure_db) -> None: - """Test updating a model in a transaction.""" + """ + Test updating a model in a transaction. + + :param: configure_db: pytest fixture + """ @async_transactional # type: ignore async def update_in_transaction( transaction: AsyncTransaction, profile_id: str, name: str ) -> Profile: """Updates a Profile in a transaction.""" - p = Profile(id=profile_id) - await p.reload(transaction=transaction) - p.name = name - await p.save(transaction=transaction) - return p + profile = Profile(id=profile_id) + await profile.reload(transaction=transaction) + profile.name = name + await profile.save(transaction=transaction) + return profile p = Profile(name="Foo") await p.save() @@ -618,23 +627,28 @@ async def update_in_transaction( transaction = CONFIGURATIONS["db"].transaction() result = await update_in_transaction(transaction, p.id, "Bar") assert isinstance(result, Profile) + await result.reload() assert result.id == p.id assert result.name == "Bar" @pytest.mark.asyncio async def test_update_submodel_in_transaction(configure_db) -> None: - """Test Updating a submodel in a transaction.""" + """ + Test Updating a submodel in a transaction. + + :param: configure_db: pytest fixture + """ @async_transactional # type: ignore async def update_submodel_in_transaction( - transaction: AsyncTransaction, user_id: str, period: str, purchases: int + transaction: AsyncTransaction, user_id: str, period: str ) -> UserStats: """Updates a UserStats in a transaction.""" - u = await User.get_by_id(user_id) + u = await User.get_by_id(user_id, transaction=transaction) us = UserStats.model_for(u) user_stats: UserStats = await us.get_by_id(period) # pylint: disable=no-member - user_stats.purchases = purchases + user_stats.purchases += 1 await user_stats.save(transaction=transaction) return user_stats @@ -645,7 +659,7 @@ async def update_submodel_in_transaction( await us(id="2021", purchases=42).save() # pylint: disable=no-member transaction = CONFIGURATIONS["db"].transaction() - user_stats = await update_submodel_in_transaction(transaction, u.id, "2021", 43) + user_stats = await update_submodel_in_transaction(transaction, u.id, "2021") assert isinstance(user_stats, UserStats) assert user_stats.purchases == 43 assert await get_user_purchases(u.id) == 43 diff --git a/firedantic/tests/tests_sync/conftest.py b/firedantic/tests/tests_sync/conftest.py index d9856cd..84b8731 100644 --- a/firedantic/tests/tests_sync/conftest.py +++ b/firedantic/tests/tests_sync/conftest.py @@ -55,6 +55,13 @@ class Config: extra = "forbid" +class City(Model): + """Dummy city Firedantic model.""" + + __collection__ = "cities" + population: int + + class Owner(BaseModel): """Dummy owner Pydantic model.""" diff --git a/firedantic/tests/tests_sync/test_model.py b/firedantic/tests/tests_sync/test_model.py index 0a5f9bb..d4c45e1 100644 --- a/firedantic/tests/tests_sync/test_model.py +++ b/firedantic/tests/tests_sync/test_model.py @@ -15,6 +15,7 @@ ModelNotFoundError, ) from firedantic.tests.tests_sync.conftest import ( + City, Company, CustomIDConflictModel, CustomIDModel, @@ -504,43 +505,49 @@ def test_save_with_exclude_unset(configure_db) -> None: assert data == {"name": "", "email": None, "photo_url": None} -def test_create_in_transaction(configure_db) -> None: - """Test creating a model in a transaction. Test case from README.""" +def test_update_city_in_transaction(configure_db) -> None: + """ + Test updating a model in a transaction. Test case from README. - @transactional # type: ignore - def create_in_transaction(transaction, email) -> Profile: - """Creates a Profile in a transaction""" - try: - Profile.find_one({"email": email}, transaction=transaction) - raise ValueError(f"Profile already exists with email: {email}") - except ModelNotFoundError: - p = Profile(email=email) - p.save(transaction=transaction) - return p + :param: configure_db: pytest fixture + """ - transaction = CONFIGURATIONS["db"].transaction() - p = create_in_transaction(transaction, "test@example.com") - assert isinstance(p, Profile) - assert p.id + @transactional + def update_in_transaction(transaction, city_ref) -> City: + """ + Updates a City in a transaction - transaction2 = CONFIGURATIONS["db"].transaction() - with pytest.raises(ValueError) as excinfo: - create_in_transaction(transaction2, "test@example.com") - assert str(excinfo.value) == "Profile already exists with email: test@example.com" + :param transaction: Firestore Transaction + :param city_ref: City reference + :return: City + """ + city = City.get_by_id(city_ref, transaction=transaction) + city.population += 1 + city.save(transaction=transaction) + return city + + c = City(id="SF", population=1) + + transaction = CONFIGURATIONS["db"].transaction() + city = update_in_transaction(transaction, c.id) + assert isinstance(city, City) + assert city.id == "SF" + assert city.population == 2 def test_delete_in_transaction(configure_db) -> None: - """Test deleting a model in a transaction.""" + """ + Test deleting a model in a transaction. + + :param: configure_db: pytest fixture + """ @transactional # type: ignore - def delete_in_transaction(transaction: Transaction, profile_id: str) -> dict: + def delete_in_transaction(transaction: Transaction, profile_id: str) -> str: """Deletes a Profile in a transaction.""" - try: - p = Profile.get_by_id(profile_id, transaction=transaction) - p.delete(transaction=transaction) - return {"id": profile_id} - except ModelNotFoundError: - raise ValueError(f"Profile not found: {profile_id}") + profile = Profile.get_by_id(profile_id, transaction=transaction) + profile.delete(transaction=transaction) + return profile_id p = Profile(name="Foo") p.save() @@ -549,27 +556,29 @@ def delete_in_transaction(transaction: Transaction, profile_id: str) -> dict: transaction = CONFIGURATIONS["db"].transaction() result = delete_in_transaction(transaction, p.id) assert isinstance(result, dict) - assert result == {"id": p.id} + assert result == p.id - transaction2 = CONFIGURATIONS["db"].transaction() - with pytest.raises(ValueError) as excinfo: - delete_in_transaction(transaction2, p.id) - assert str(excinfo.value) == f"Profile not found: {p.id}" + with pytest.raises(ModelNotFoundError): + Profile.get_by_id(p.id) def test_update_model_in_transaction(configure_db) -> None: - """Test updating a model in a transaction.""" + """ + Test updating a model in a transaction. + + :param: configure_db: pytest fixture + """ @transactional # type: ignore def update_in_transaction( transaction: Transaction, profile_id: str, name: str ) -> Profile: """Updates a Profile in a transaction.""" - p = Profile(id=profile_id) - p.reload(transaction=transaction) - p.name = name - p.save(transaction=transaction) - return p + profile = Profile(id=profile_id) + profile.reload(transaction=transaction) + profile.name = name + profile.save(transaction=transaction) + return profile p = Profile(name="Foo") p.save() @@ -578,22 +587,27 @@ def update_in_transaction( transaction = CONFIGURATIONS["db"].transaction() result = update_in_transaction(transaction, p.id, "Bar") assert isinstance(result, Profile) + result.reload() assert result.id == p.id assert result.name == "Bar" def test_update_submodel_in_transaction(configure_db) -> None: - """Test Updating a submodel in a transaction.""" + """ + Test Updating a submodel in a transaction. + + :param: configure_db: pytest fixture + """ @transactional # type: ignore def update_submodel_in_transaction( - transaction: Transaction, user_id: str, period: str, purchases: int + transaction: Transaction, user_id: str, period: str ) -> UserStats: """Updates a UserStats in a transaction.""" - u = User.get_by_id(user_id) + u = User.get_by_id(user_id, transaction=transaction) us = UserStats.model_for(u) user_stats: UserStats = us.get_by_id(period) # pylint: disable=no-member - user_stats.purchases = purchases + user_stats.purchases += 1 user_stats.save(transaction=transaction) return user_stats @@ -604,7 +618,7 @@ def update_submodel_in_transaction( us(id="2021", purchases=42).save() # pylint: disable=no-member transaction = CONFIGURATIONS["db"].transaction() - user_stats = update_submodel_in_transaction(transaction, u.id, "2021", 43) + user_stats = update_submodel_in_transaction(transaction, u.id, "2021") assert isinstance(user_stats, UserStats) assert user_stats.purchases == 43 assert get_user_purchases(u.id) == 43 From 16408598c61b4800e5d10366831aa6fa3e8726ef Mon Sep 17 00:00:00 2001 From: Joakim Nordling Date: Thu, 5 Jun 2025 12:58:52 +0300 Subject: [PATCH 03/11] Revert changes to configuration --- firedantic/_async/model.py | 26 ++++-------------- firedantic/_sync/model.py | 26 ++++-------------- firedantic/configurations.py | 53 ++++-------------------------------- firedantic/exceptions.py | 7 ----- 4 files changed, 16 insertions(+), 96 deletions(-) diff --git a/firedantic/_async/model.py b/firedantic/_async/model.py index fed09d2..d50b93e 100644 --- a/firedantic/_async/model.py +++ b/firedantic/_async/model.py @@ -15,10 +15,9 @@ import firedantic.operators as op from firedantic import async_truncate_collection from firedantic.common import IndexDefinition, OrderDirection -from firedantic.configurations import CONFIGURATIONS, Configuration +from firedantic.configurations import CONFIGURATIONS from firedantic.exceptions import ( CollectionNotDefined, - ConfigurationNotFoundError, InvalidDocumentID, ModelNotFoundError, ) @@ -42,35 +41,20 @@ } -def get_configuration(name: str = "default") -> Configuration: - """ - Returns the configuration with the given name. - - :params name: The configuration name. - :return: The configuration with the given name. - :raise ConfigurationNotFoundError: If the configuration is not found. - """ - if name in CONFIGURATIONS: - return CONFIGURATIONS[name] - raise ConfigurationNotFoundError(f"Configuration `{name}` not found") - - def get_collection_name(cls, name: Optional[str]) -> str: """ - Returns the collection name with the optional prefix. + Returns the collection name. - :params cls: The model class. - :params name: The collection name. - :return: The collection name with the optional prefix. :raise CollectionNotDefined: If the collection name is not defined. """ if not name: raise CollectionNotDefined(f"Missing collection name for {cls.__name__}") - return f"{get_configuration().prefix}{name}" + + return f"{CONFIGURATIONS['prefix']}{name}" def _get_col_ref(cls, name: Optional[str]) -> AsyncCollectionReference: - collection: AsyncCollectionReference = get_configuration().db.collection( + collection: AsyncCollectionReference = CONFIGURATIONS["db"].collection( get_collection_name(cls, name) ) return collection diff --git a/firedantic/_sync/model.py b/firedantic/_sync/model.py index 7909589..9dabc33 100644 --- a/firedantic/_sync/model.py +++ b/firedantic/_sync/model.py @@ -15,10 +15,9 @@ import firedantic.operators as op from firedantic import truncate_collection from firedantic.common import IndexDefinition, OrderDirection -from firedantic.configurations import CONFIGURATIONS, Configuration +from firedantic.configurations import CONFIGURATIONS from firedantic.exceptions import ( CollectionNotDefined, - ConfigurationNotFoundError, InvalidDocumentID, ModelNotFoundError, ) @@ -42,35 +41,20 @@ } -def get_configuration(name: str = "default") -> Configuration: - """ - Returns the configuration with the given name. - - :params name: The configuration name. - :return: The configuration with the given name. - :raise ConfigurationNotFoundError: If the configuration is not found. - """ - if name in CONFIGURATIONS: - return CONFIGURATIONS[name] - raise ConfigurationNotFoundError(f"Configuration `{name}` not found") - - def get_collection_name(cls, name: Optional[str]) -> str: """ - Returns the collection name with the optional prefix. + Returns the collection name. - :params cls: The model class. - :params name: The collection name. - :return: The collection name with the optional prefix. :raise CollectionNotDefined: If the collection name is not defined. """ if not name: raise CollectionNotDefined(f"Missing collection name for {cls.__name__}") - return f"{get_configuration().prefix}{name}" + + return f"{CONFIGURATIONS['prefix']}{name}" def _get_col_ref(cls, name: Optional[str]) -> CollectionReference: - collection: CollectionReference = get_configuration().db.collection( + collection: CollectionReference = CONFIGURATIONS["db"].collection( get_collection_name(cls, name) ) return collection diff --git a/firedantic/configurations.py b/firedantic/configurations.py index 9328ac0..321a4af 100644 --- a/firedantic/configurations.py +++ b/firedantic/configurations.py @@ -1,58 +1,17 @@ -from typing import Dict, Union +from typing import Any, Dict, Union from google.cloud.firestore_v1 import AsyncClient, Client -from pydantic import BaseModel - -class Configuration(BaseModel): - """ - Defines a single configuration. - """ - - db: Union[Client, AsyncClient] - prefix: str = "" - - -class ConfigurationDict(dict): - """ - A dictionary-like object to handle multiple configurations with backward compatibility. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - def __setitem__(self, key, value): - if key in ("db", "prefix"): - raise ValueError(f"Cannot create configuration named '{key}'.") - super().__setitem__(key, value) - - def __getitem__(self, key): - if "default" not in self or not self["default"] is None: - raise ValueError( - "No default configuration found. Run `configure` to get started." - ) - if key == "db": - return self["default"] - if key == "prefix": - return self["default"].prefix - return super().__getitem__(key) - - -CONFIGURATIONS: Dict[str, Configuration] = ConfigurationDict() +CONFIGURATIONS: Dict[str, Any] = {} def configure(db: Union[Client, AsyncClient], prefix: str = "") -> None: - """ - Configures the prefix and DB. + """Configures the prefix and DB. :param db: The firestore client instance. :param prefix: The prefix to use for collection names. """ - global CONFIGURATIONS # pylint: disable=global-statement,global-variable-not-assigned - - # CONFIGURATIONS["db"] = db - # CONFIGURATIONS["prefix"] = prefix - - configuration = Configuration(db=db, prefix=prefix) + global CONFIGURATIONS - CONFIGURATIONS["default"] = configuration + CONFIGURATIONS["db"] = db + CONFIGURATIONS["prefix"] = prefix diff --git a/firedantic/exceptions.py b/firedantic/exceptions.py index 753c5b0..5f8dbd4 100644 --- a/firedantic/exceptions.py +++ b/firedantic/exceptions.py @@ -1,10 +1,3 @@ -"""Firedantic Exceptions module.""" - - -class ConfigurationNotFoundError(Exception): - """Raised when a configuration is not found.""" - - class ModelError(Exception): """Generic model error class.""" From 13b4e1c0cb1d24d8e56f32ed04956cb9f8f2f47d Mon Sep 17 00:00:00 2001 From: Joakim Nordling Date: Thu, 5 Jun 2025 14:51:02 +0300 Subject: [PATCH 04/11] Refactoring tests and how you get the transaction Still need to update the example in the README and consider if there should be some more advanced test also. The main test case and the City model now also show how you can work with an instance method and transactions. Seems like you need to wrap the transactional function in order to make it work. The `@async_transactional` decorator expects the first argument to be `transaction` and not for example `self`. --- firedantic/__init__.py | 7 ++- firedantic/configurations.py | 20 +++++++- firedantic/tests/tests_async/conftest.py | 16 ++++-- firedantic/tests/tests_async/test_model.py | 57 +++++++++------------- firedantic/tests/tests_sync/conftest.py | 16 ++++-- firedantic/tests/tests_sync/test_model.py | 55 ++++++++------------- unasync.py | 1 + 7 files changed, 94 insertions(+), 78 deletions(-) diff --git a/firedantic/__init__.py b/firedantic/__init__.py index 7b0e16b..e4f79d2 100644 --- a/firedantic/__init__.py +++ b/firedantic/__init__.py @@ -32,6 +32,11 @@ ) from firedantic._sync.ttl_policy import set_up_ttl_policies from firedantic.common import collection_group_index, collection_index -from firedantic.configurations import CONFIGURATIONS, configure +from firedantic.configurations import ( + CONFIGURATIONS, + configure, + get_async_transaction, + get_transaction, +) from firedantic.exceptions import * from firedantic.utils import get_all_subclasses diff --git a/firedantic/configurations.py b/firedantic/configurations.py index 321a4af..2f9e277 100644 --- a/firedantic/configurations.py +++ b/firedantic/configurations.py @@ -1,6 +1,6 @@ from typing import Any, Dict, Union -from google.cloud.firestore_v1 import AsyncClient, Client +from google.cloud.firestore_v1 import AsyncClient, AsyncTransaction, Client, Transaction CONFIGURATIONS: Dict[str, Any] = {} @@ -15,3 +15,21 @@ def configure(db: Union[Client, AsyncClient], prefix: str = "") -> None: CONFIGURATIONS["db"] = db CONFIGURATIONS["prefix"] = prefix + + +def get_transaction() -> Transaction: + """ + Get a new Firestore transaction for the configured DB. + """ + transaction = CONFIGURATIONS["db"].transaction() + assert isinstance(transaction, Transaction) + return transaction + + +def get_async_transaction() -> AsyncTransaction: + """ + Get a new Firestore transaction for the configured DB. + """ + transaction = CONFIGURATIONS["db"].transaction() + assert isinstance(transaction, AsyncTransaction) + return transaction diff --git a/firedantic/tests/tests_async/conftest.py b/firedantic/tests/tests_async/conftest.py index cc7a7ae..b51c69a 100644 --- a/firedantic/tests/tests_async/conftest.py +++ b/firedantic/tests/tests_async/conftest.py @@ -5,7 +5,7 @@ import google.auth.credentials import pytest from google.cloud.firestore_admin_v1 import Field, FirestoreAdminClient -from google.cloud.firestore_v1 import AsyncClient +from google.cloud.firestore_v1 import AsyncClient, AsyncTransaction, async_transactional from pydantic import BaseModel, PrivateAttr from firedantic import ( @@ -16,7 +16,7 @@ AsyncSubCollection, AsyncSubModel, ) -from firedantic.configurations import configure +from firedantic.configurations import configure, get_async_transaction from firedantic.exceptions import ModelNotFoundError from unittest.mock import AsyncMock, Mock # noqa isort: skip @@ -56,11 +56,21 @@ class Config: class City(AsyncModel): - """Dummy city Firedantic model.""" + """City model used for test with transactions""" __collection__ = "cities" population: int + async def increment_population(self, increment: int = 1): + @async_transactional + async def _increment_population(transaction: AsyncTransaction) -> None: + await self.reload(transaction=transaction) + self.population += increment + await self.save(transaction=transaction) + + t = get_async_transaction() + await _increment_population(transaction=t) + class Owner(BaseModel): """Dummy owner Pydantic model.""" diff --git a/firedantic/tests/tests_async/test_model.py b/firedantic/tests/tests_async/test_model.py index f80d330..2796043 100644 --- a/firedantic/tests/tests_async/test_model.py +++ b/firedantic/tests/tests_async/test_model.py @@ -7,8 +7,7 @@ from pydantic import Field, ValidationError import firedantic.operators as op -from firedantic import AsyncModel -from firedantic.configurations import CONFIGURATIONS +from firedantic import AsyncModel, get_async_transaction from firedantic.exceptions import ( CollectionNotDefined, InvalidDocumentID, @@ -549,26 +548,21 @@ async def test_update_city_in_transaction(configure_db) -> None: """ @async_transactional - async def update_in_transaction(transaction, city_ref) -> City: - """ - Updates a City in a transaction - - :param transaction: Firestore Transaction - :param city_ref: City reference - :return: City - """ - city = await City.get_by_id(city_ref, transaction=transaction) - city.population += 1 + async def decrement_population( + transaction: AsyncTransaction, city: City, decrement: int = 1 + ): + await city.reload(transaction=transaction) + city.population = max(0, city.population - decrement) await city.save(transaction=transaction) - return city c = City(id="SF", population=1) + await c.save() + await c.increment_population(increment=1) + assert c.population == 2 - transaction = CONFIGURATIONS["db"].transaction() - city = await update_in_transaction(transaction, c.id) - assert isinstance(city, City) - assert city.id == "SF" - assert city.population == 2 + t = get_async_transaction() + await decrement_population(transaction=t, city=c, decrement=5) + assert c.population == 0 @pytest.mark.asyncio @@ -582,20 +576,17 @@ async def test_delete_in_transaction(configure_db) -> None: @async_transactional # type: ignore async def delete_in_transaction( transaction: AsyncTransaction, profile_id: str - ) -> str: + ) -> None: """Deletes a Profile in a transaction.""" profile = await Profile.get_by_id(profile_id, transaction=transaction) await profile.delete(transaction=transaction) - return profile_id p = Profile(name="Foo") await p.save() assert p.id - transaction = CONFIGURATIONS["db"].transaction() - result = await delete_in_transaction(transaction, p.id) - assert isinstance(result, dict) - assert result == p.id + t = get_async_transaction() + await delete_in_transaction(t, p.id) with pytest.raises(ModelNotFoundError): await Profile.get_by_id(p.id) @@ -612,24 +603,20 @@ async def test_update_model_in_transaction(configure_db) -> None: @async_transactional # type: ignore async def update_in_transaction( transaction: AsyncTransaction, profile_id: str, name: str - ) -> Profile: + ) -> None: """Updates a Profile in a transaction.""" profile = Profile(id=profile_id) await profile.reload(transaction=transaction) profile.name = name await profile.save(transaction=transaction) - return profile p = Profile(name="Foo") await p.save() - assert p.id - transaction = CONFIGURATIONS["db"].transaction() - result = await update_in_transaction(transaction, p.id, "Bar") - assert isinstance(result, Profile) - await result.reload() - assert result.id == p.id - assert result.name == "Bar" + t = get_async_transaction() + await update_in_transaction(t, p.id, name="Bar") + await p.reload() + assert p.name == "Bar" @pytest.mark.asyncio @@ -658,8 +645,8 @@ async def update_submodel_in_transaction( us = UserStats.model_for(u) await us(id="2021", purchases=42).save() # pylint: disable=no-member - transaction = CONFIGURATIONS["db"].transaction() - user_stats = await update_submodel_in_transaction(transaction, u.id, "2021") + t = get_async_transaction() + user_stats = await update_submodel_in_transaction(t, u.id, "2021") assert isinstance(user_stats, UserStats) assert user_stats.purchases == 43 assert await get_user_purchases(u.id) == 43 diff --git a/firedantic/tests/tests_sync/conftest.py b/firedantic/tests/tests_sync/conftest.py index 84b8731..33dd44e 100644 --- a/firedantic/tests/tests_sync/conftest.py +++ b/firedantic/tests/tests_sync/conftest.py @@ -5,7 +5,7 @@ import google.auth.credentials import pytest from google.cloud.firestore_admin_v1 import Field, FirestoreAdminClient -from google.cloud.firestore_v1 import Client +from google.cloud.firestore_v1 import Client, Transaction, transactional from pydantic import BaseModel, PrivateAttr from firedantic import ( @@ -16,7 +16,7 @@ SubCollection, SubModel, ) -from firedantic.configurations import configure +from firedantic.configurations import configure, get_transaction from firedantic.exceptions import ModelNotFoundError from unittest.mock import Mock, Mock # noqa isort: skip @@ -56,11 +56,21 @@ class Config: class City(Model): - """Dummy city Firedantic model.""" + """City model used for test with transactions""" __collection__ = "cities" population: int + def increment_population(self, increment: int = 1): + @transactional + def _increment_population(transaction: Transaction) -> None: + self.reload(transaction=transaction) + self.population += increment + self.save(transaction=transaction) + + t = get_transaction() + _increment_population(transaction=t) + class Owner(BaseModel): """Dummy owner Pydantic model.""" diff --git a/firedantic/tests/tests_sync/test_model.py b/firedantic/tests/tests_sync/test_model.py index d4c45e1..fbf9eba 100644 --- a/firedantic/tests/tests_sync/test_model.py +++ b/firedantic/tests/tests_sync/test_model.py @@ -7,8 +7,7 @@ from pydantic import Field, ValidationError import firedantic.operators as op -from firedantic import Model -from firedantic.configurations import CONFIGURATIONS +from firedantic import Model, get_transaction from firedantic.exceptions import ( CollectionNotDefined, InvalidDocumentID, @@ -513,26 +512,19 @@ def test_update_city_in_transaction(configure_db) -> None: """ @transactional - def update_in_transaction(transaction, city_ref) -> City: - """ - Updates a City in a transaction - - :param transaction: Firestore Transaction - :param city_ref: City reference - :return: City - """ - city = City.get_by_id(city_ref, transaction=transaction) - city.population += 1 + def decrement_population(transaction: Transaction, city: City, decrement: int = 1): + city.reload(transaction=transaction) + city.population = max(0, city.population - decrement) city.save(transaction=transaction) - return city c = City(id="SF", population=1) + c.save() + c.increment_population(increment=1) + assert c.population == 2 - transaction = CONFIGURATIONS["db"].transaction() - city = update_in_transaction(transaction, c.id) - assert isinstance(city, City) - assert city.id == "SF" - assert city.population == 2 + t = get_transaction() + decrement_population(transaction=t, city=c, decrement=5) + assert c.population == 0 def test_delete_in_transaction(configure_db) -> None: @@ -543,20 +535,17 @@ def test_delete_in_transaction(configure_db) -> None: """ @transactional # type: ignore - def delete_in_transaction(transaction: Transaction, profile_id: str) -> str: + def delete_in_transaction(transaction: Transaction, profile_id: str) -> None: """Deletes a Profile in a transaction.""" profile = Profile.get_by_id(profile_id, transaction=transaction) profile.delete(transaction=transaction) - return profile_id p = Profile(name="Foo") p.save() assert p.id - transaction = CONFIGURATIONS["db"].transaction() - result = delete_in_transaction(transaction, p.id) - assert isinstance(result, dict) - assert result == p.id + t = get_transaction() + delete_in_transaction(t, p.id) with pytest.raises(ModelNotFoundError): Profile.get_by_id(p.id) @@ -572,24 +561,20 @@ def test_update_model_in_transaction(configure_db) -> None: @transactional # type: ignore def update_in_transaction( transaction: Transaction, profile_id: str, name: str - ) -> Profile: + ) -> None: """Updates a Profile in a transaction.""" profile = Profile(id=profile_id) profile.reload(transaction=transaction) profile.name = name profile.save(transaction=transaction) - return profile p = Profile(name="Foo") p.save() - assert p.id - transaction = CONFIGURATIONS["db"].transaction() - result = update_in_transaction(transaction, p.id, "Bar") - assert isinstance(result, Profile) - result.reload() - assert result.id == p.id - assert result.name == "Bar" + t = get_transaction() + update_in_transaction(t, p.id, name="Bar") + p.reload() + assert p.name == "Bar" def test_update_submodel_in_transaction(configure_db) -> None: @@ -617,8 +602,8 @@ def update_submodel_in_transaction( us = UserStats.model_for(u) us(id="2021", purchases=42).save() # pylint: disable=no-member - transaction = CONFIGURATIONS["db"].transaction() - user_stats = update_submodel_in_transaction(transaction, u.id, "2021") + t = get_transaction() + user_stats = update_submodel_in_transaction(t, u.id, "2021") assert isinstance(user_stats, UserStats) assert user_stats.purchases == 43 assert get_user_purchases(u.id) == 43 diff --git a/unasync.py b/unasync.py index f7ca343..ffa7887 100644 --- a/unasync.py +++ b/unasync.py @@ -5,6 +5,7 @@ from pathlib import Path SUBS = [ + ("get_async_transaction", "get_transaction"), ( "from google.cloud.firestore_v1.async_transaction", "from google.cloud.firestore_v1.transaction", From 29f792c998ff01c53f2b6af0ac98c31667706851 Mon Sep 17 00:00:00 2001 From: Joakim Nordling Date: Thu, 5 Jun 2025 16:03:53 +0300 Subject: [PATCH 05/11] Update README --- README.md | 141 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 118 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 102861c..e80d025 100644 --- a/README.md +++ b/README.md @@ -337,43 +337,138 @@ The following methods can be used in a transaction: When using transactions, note that read operations must come before write operations. -### Transaction example +### Transaction examples -In this example, we are updating a `City` to increase the population by 1. +In this example (async and sync version of it below), we are updating a `City` to +increment and decrement the population of it, both using an instance method and a +standalone function. Please note that the `@async_transactional` and `@transactional` +decorators always expect the first argument of the wrapped function to be `transaction`; +i.e. you can not directly wrap an instance method that has `self` as the first argument +or a class method that has `cls` as the first argument. + +#### Transaction example (async) ```python -from firedantic import configure -from google.cloud.firestore import async_transactional -from google.cloud.firestore import AyncClient +import asyncio +from os import environ +from unittest.mock import Mock + +import google.auth.credentials +from google.cloud.firestore import AsyncClient +from google.cloud.firestore_v1 import async_transactional, AsyncTransaction -client = AsyncClient() -configure(client) +from firedantic import AsyncModel, configure, get_async_transaction + +# Firestore emulator must be running if using locally. +if environ.get("FIRESTORE_EMULATOR_HOST"): + client = AsyncClient( + project="firedantic-test", + credentials=Mock(spec=google.auth.credentials.Credentials), + ) +else: + client = AsyncClient() + +configure(client, prefix="firedantic-test-") class City(AsyncModel): __collection__ = "cities" population: int + async def increment_population(self, increment: int = 1): + @async_transactional + async def _increment_population(transaction: AsyncTransaction) -> None: + await self.reload(transaction=transaction) + self.population += increment + await self.save(transaction=transaction) + + t = get_async_transaction() + await _increment_population(transaction=t) -@async_transactional -async def update_in_transaction(transaction, city_ref) -> City: - """ - Updates a City in a transaction - :param transaction: Firestore Transaction - :param city_ref: City reference - :return: City - """ - city = await City.get_by_id(city_ref, transaction=transaction) - city.population += 1 - await city.save(transaction=transaction) - return city +async def main(): + @async_transactional + async def decrement_population( + transaction: AsyncTransaction, city: City, decrement: int = 1 + ): + await city.reload(transaction=transaction) + city.population = max(0, city.population - decrement) + await city.save(transaction=transaction) + + c = City(id="SF", population=1) + await c.save() + await c.increment_population(increment=1) + assert c.population == 2 + t = get_async_transaction() + await decrement_population(transaction=t, city=c, decrement=5) + assert c.population == 0 -transaction = client.transaction() -city = await update_in_transaction(transaction, "SF") -assert isinstance(city, City) -assert city.id == "SF" + +if __name__ == "__main__": + asyncio.run(main()) +``` + +#### Transaction example (sync) + +```python +from os import environ +from unittest.mock import Mock + +import google.auth.credentials +from google.cloud.firestore import Client +from google.cloud.firestore_v1 import transactional, Transaction + +from firedantic import Model, configure, get_transaction + +# Firestore emulator must be running if using locally. +if environ.get("FIRESTORE_EMULATOR_HOST"): + client = Client( + project="firedantic-test", + credentials=Mock(spec=google.auth.credentials.Credentials), + ) +else: + client = Client() + +configure(client, prefix="firedantic-test-") + + +class City(Model): + __collection__ = "cities" + population: int + + def increment_population(self, increment: int = 1): + @transactional + def _increment_population(transaction: Transaction) -> None: + self.reload(transaction=transaction) + self.population += increment + self.save(transaction=transaction) + + t = get_transaction() + _increment_population(transaction=t) + + +def main(): + @transactional + def decrement_population( + transaction: Transaction, city: City, decrement: int = 1 + ): + city.reload(transaction=transaction) + city.population = max(0, city.population - decrement) + city.save(transaction=transaction) + + c = City(id="SF", population=1) + c.save() + c.increment_population(increment=1) + assert c.population == 2 + + t = get_transaction() + decrement_population(transaction=t, city=c, decrement=5) + assert c.population == 0 + + +if __name__ == "__main__": + main() ``` ## Development From e0b380869e846d7aca2040fdc26aa94ba248a294 Mon Sep 17 00:00:00 2001 From: Joakim Nordling Date: Thu, 5 Jun 2025 16:11:53 +0300 Subject: [PATCH 06/11] Minor docstring changes --- firedantic/_async/model.py | 12 ++++++------ firedantic/_sync/model.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/firedantic/_async/model.py b/firedantic/_async/model.py index d50b93e..cc73b5a 100644 --- a/firedantic/_async/model.py +++ b/firedantic/_async/model.py @@ -84,7 +84,7 @@ async def save( :param exclude_unset: Whether to exclude fields that have not been explicitly set. :param exclude_none: Whether to exclude fields that have a value of `None`. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :raise DocumentIDError: If the document ID is not valid. """ data = self.model_dump( @@ -115,7 +115,7 @@ async def reload(self, transaction: Optional[AsyncTransaction] = None) -> None: """ Reloads this model from the database. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :raise ModelNotFoundError: If the document ID is missing in the model. """ doc_id = self.__dict__.get(self.__document_id__) @@ -164,7 +164,7 @@ async def find( # pylint: disable=too-many-arguments :param order_by: List of columns and direction to order results by. :param limit: Maximum results to return. :param offset: Skip the first n results. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :return: List of found models. """ query: Union[AsyncQuery, AsyncCollectionReference] = cls._get_col_ref() @@ -253,7 +253,7 @@ async def get_by_doc_id( Returns a model based on the document ID. :param doc_id: The document ID of the entry. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :return: The model. :raise ModelNotFoundError: Raised if no matching document is found. """ @@ -366,7 +366,7 @@ async def get_by_id( Get single model by document ID. :param id_: Document ID. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :raises ModelNotFoundError: if no model was found by given id """ return await cls.get_by_doc_id(id_, transaction=transaction) @@ -439,7 +439,7 @@ async def get_by_id( Get single item by document ID :param id_: Document ID. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :raises ModelNotFoundError: """ return await cls.get_by_doc_id(id_, transaction=transaction) diff --git a/firedantic/_sync/model.py b/firedantic/_sync/model.py index 9dabc33..ebb211a 100644 --- a/firedantic/_sync/model.py +++ b/firedantic/_sync/model.py @@ -84,7 +84,7 @@ def save( :param exclude_unset: Whether to exclude fields that have not been explicitly set. :param exclude_none: Whether to exclude fields that have a value of `None`. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :raise DocumentIDError: If the document ID is not valid. """ data = self.model_dump( @@ -115,7 +115,7 @@ def reload(self, transaction: Optional[Transaction] = None) -> None: """ Reloads this model from the database. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :raise ModelNotFoundError: If the document ID is missing in the model. """ doc_id = self.__dict__.get(self.__document_id__) @@ -164,7 +164,7 @@ def find( # pylint: disable=too-many-arguments :param order_by: List of columns and direction to order results by. :param limit: Maximum results to return. :param offset: Skip the first n results. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :return: List of found models. """ query: Union[BaseQuery, CollectionReference] = cls._get_col_ref() @@ -251,7 +251,7 @@ def get_by_doc_id( Returns a model based on the document ID. :param doc_id: The document ID of the entry. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :return: The model. :raise ModelNotFoundError: Raised if no matching document is found. """ @@ -364,7 +364,7 @@ def get_by_id( Get single model by document ID. :param id_: Document ID. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :raises ModelNotFoundError: if no model was found by given id """ return cls.get_by_doc_id(id_, transaction=transaction) @@ -437,7 +437,7 @@ def get_by_id( Get single item by document ID :param id_: Document ID. - :param transaction: Optional Transaction to use. + :param transaction: Optional transaction to use. :raises ModelNotFoundError: """ return cls.get_by_doc_id(id_, transaction=transaction) From c1c4f4f0dc046fe7db8370fb042c19a67ac59155 Mon Sep 17 00:00:00 2001 From: Joakim Nordling Date: Thu, 5 Jun 2025 16:13:13 +0300 Subject: [PATCH 07/11] Revert unnecessary change --- firedantic/_async/model.py | 7 ++++--- firedantic/_sync/model.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/firedantic/_async/model.py b/firedantic/_async/model.py index cc73b5a..c1a19a4 100644 --- a/firedantic/_async/model.py +++ b/firedantic/_async/model.py @@ -216,9 +216,10 @@ def _add_filter( _filter = FieldFilter(field, f_type, value[f_type]) query: AsyncQuery = query.where(filter=_filter) # type: ignore return query - _filter = FieldFilter(field, "==", value) - query: AsyncQuery = query.where(filter=_filter) # type: ignore - return query + else: + _filter = FieldFilter(field, "==", value) + query: AsyncQuery = query.where(filter=_filter) # type: ignore + return query @classmethod async def find_one( diff --git a/firedantic/_sync/model.py b/firedantic/_sync/model.py index ebb211a..0dccb38 100644 --- a/firedantic/_sync/model.py +++ b/firedantic/_sync/model.py @@ -216,9 +216,10 @@ def _add_filter( _filter = FieldFilter(field, f_type, value[f_type]) query: BaseQuery = query.where(filter=_filter) # type: ignore return query - _filter = FieldFilter(field, "==", value) - query: BaseQuery = query.where(filter=_filter) # type: ignore - return query + else: + _filter = FieldFilter(field, "==", value) + query: BaseQuery = query.where(filter=_filter) # type: ignore + return query @classmethod def find_one( From a654d77299c4b70981bdd00ac64a86de22b7887f Mon Sep 17 00:00:00 2001 From: Joakim Nordling Date: Thu, 5 Jun 2025 16:16:43 +0300 Subject: [PATCH 08/11] Minor fixes to docstring. --- firedantic/_async/model.py | 2 +- firedantic/_sync/model.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/firedantic/_async/model.py b/firedantic/_async/model.py index c1a19a4..84251df 100644 --- a/firedantic/_async/model.py +++ b/firedantic/_async/model.py @@ -368,7 +368,7 @@ async def get_by_id( :param id_: Document ID. :param transaction: Optional transaction to use. - :raises ModelNotFoundError: if no model was found by given id + :raises ModelNotFoundError: If no model was found by given id. """ return await cls.get_by_doc_id(id_, transaction=transaction) diff --git a/firedantic/_sync/model.py b/firedantic/_sync/model.py index 0dccb38..78ec372 100644 --- a/firedantic/_sync/model.py +++ b/firedantic/_sync/model.py @@ -366,7 +366,7 @@ def get_by_id( :param id_: Document ID. :param transaction: Optional transaction to use. - :raises ModelNotFoundError: if no model was found by given id + :raises ModelNotFoundError: If no model was found by given id. """ return cls.get_by_doc_id(id_, transaction=transaction) From 57892f53f2352f0c991f674ef6d5277489f32440 Mon Sep 17 00:00:00 2001 From: Joakim Nordling Date: Thu, 5 Jun 2025 16:27:43 +0300 Subject: [PATCH 09/11] Remove unnecessary type ignores Let me know if you see any reason for them. My PyCharm does no complain and mypy is also happy without them. --- firedantic/tests/tests_async/test_model.py | 6 +++--- firedantic/tests/tests_sync/test_model.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/firedantic/tests/tests_async/test_model.py b/firedantic/tests/tests_async/test_model.py index 2796043..309921e 100644 --- a/firedantic/tests/tests_async/test_model.py +++ b/firedantic/tests/tests_async/test_model.py @@ -573,7 +573,7 @@ async def test_delete_in_transaction(configure_db) -> None: :param: configure_db: pytest fixture """ - @async_transactional # type: ignore + @async_transactional async def delete_in_transaction( transaction: AsyncTransaction, profile_id: str ) -> None: @@ -600,7 +600,7 @@ async def test_update_model_in_transaction(configure_db) -> None: :param: configure_db: pytest fixture """ - @async_transactional # type: ignore + @async_transactional async def update_in_transaction( transaction: AsyncTransaction, profile_id: str, name: str ) -> None: @@ -627,7 +627,7 @@ async def test_update_submodel_in_transaction(configure_db) -> None: :param: configure_db: pytest fixture """ - @async_transactional # type: ignore + @async_transactional async def update_submodel_in_transaction( transaction: AsyncTransaction, user_id: str, period: str ) -> UserStats: diff --git a/firedantic/tests/tests_sync/test_model.py b/firedantic/tests/tests_sync/test_model.py index fbf9eba..71cb784 100644 --- a/firedantic/tests/tests_sync/test_model.py +++ b/firedantic/tests/tests_sync/test_model.py @@ -534,7 +534,7 @@ def test_delete_in_transaction(configure_db) -> None: :param: configure_db: pytest fixture """ - @transactional # type: ignore + @transactional def delete_in_transaction(transaction: Transaction, profile_id: str) -> None: """Deletes a Profile in a transaction.""" profile = Profile.get_by_id(profile_id, transaction=transaction) @@ -558,7 +558,7 @@ def test_update_model_in_transaction(configure_db) -> None: :param: configure_db: pytest fixture """ - @transactional # type: ignore + @transactional def update_in_transaction( transaction: Transaction, profile_id: str, name: str ) -> None: @@ -584,7 +584,7 @@ def test_update_submodel_in_transaction(configure_db) -> None: :param: configure_db: pytest fixture """ - @transactional # type: ignore + @transactional def update_submodel_in_transaction( transaction: Transaction, user_id: str, period: str ) -> UserStats: From ff001fec779908edd699d73ed7c4b4ac8ffc4cda Mon Sep 17 00:00:00 2001 From: Joakim Nordling Date: Thu, 5 Jun 2025 16:30:31 +0300 Subject: [PATCH 10/11] Remove unnecessary change This new field was not really in the end used anywhere. --- firedantic/tests/tests_async/conftest.py | 1 - firedantic/tests/tests_async/test_model.py | 4 ++-- firedantic/tests/tests_sync/conftest.py | 1 - firedantic/tests/tests_sync/test_model.py | 4 ++-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/firedantic/tests/tests_async/conftest.py b/firedantic/tests/tests_async/conftest.py index b51c69a..09a4710 100644 --- a/firedantic/tests/tests_async/conftest.py +++ b/firedantic/tests/tests_async/conftest.py @@ -141,7 +141,6 @@ class Profile(AsyncModel): __collection__ = "profiles" name: Optional[str] = "" - email: Optional[str] = None photo_url: Optional[str] = None class Config: diff --git a/firedantic/tests/tests_async/test_model.py b/firedantic/tests/tests_async/test_model.py index 309921e..ed27c0d 100644 --- a/firedantic/tests/tests_async/test_model.py +++ b/firedantic/tests/tests_async/test_model.py @@ -514,7 +514,7 @@ async def test_save_with_exclude_none(configure_db) -> None: document = await Profile._get_col_ref().document(document_id).get() data = document.to_dict() - assert data == {"name": "Foo", "email": None, "photo_url": None} + assert data == {"name": "Foo", "photo_url": None} @pytest.mark.asyncio @@ -536,7 +536,7 @@ async def test_save_with_exclude_unset(configure_db) -> None: document = await Profile._get_col_ref().document(document_id).get() data = document.to_dict() - assert data == {"name": "", "email": None, "photo_url": None} + assert data == {"name": "", "photo_url": None} @pytest.mark.asyncio diff --git a/firedantic/tests/tests_sync/conftest.py b/firedantic/tests/tests_sync/conftest.py index 33dd44e..8debfc4 100644 --- a/firedantic/tests/tests_sync/conftest.py +++ b/firedantic/tests/tests_sync/conftest.py @@ -141,7 +141,6 @@ class Profile(Model): __collection__ = "profiles" name: Optional[str] = "" - email: Optional[str] = None photo_url: Optional[str] = None class Config: diff --git a/firedantic/tests/tests_sync/test_model.py b/firedantic/tests/tests_sync/test_model.py index 71cb784..37e27a4 100644 --- a/firedantic/tests/tests_sync/test_model.py +++ b/firedantic/tests/tests_sync/test_model.py @@ -480,7 +480,7 @@ def test_save_with_exclude_none(configure_db) -> None: document = Profile._get_col_ref().document(document_id).get() data = document.to_dict() - assert data == {"name": "Foo", "email": None, "photo_url": None} + assert data == {"name": "Foo", "photo_url": None} def test_save_with_exclude_unset(configure_db) -> None: @@ -501,7 +501,7 @@ def test_save_with_exclude_unset(configure_db) -> None: document = Profile._get_col_ref().document(document_id).get() data = document.to_dict() - assert data == {"name": "", "email": None, "photo_url": None} + assert data == {"name": "", "photo_url": None} def test_update_city_in_transaction(configure_db) -> None: From d18203499f6b926d8fc284041a3635a58cb7ba24 Mon Sep 17 00:00:00 2001 From: Joakim Nordling Date: Thu, 5 Jun 2025 16:38:04 +0300 Subject: [PATCH 11/11] Update version number and the changelog --- CHANGELOG.md | 10 +++++++++- pyproject.toml | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e7fb0f..212c829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [0.11.0] - 2025-06-06 + +### Added + +- Support for transactions, a big thanks to `@lukwam` for this! For more details of how + to use this, see the new section in the README. + ## [0.10.0] - 2025-02-26 ### Added @@ -283,7 +290,8 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Update README.md - Update .gitignore -[unreleased]: https://github.com/ioxiocom/firedantic/compare/0.10.0...HEAD +[unreleased]: https://github.com/ioxiocom/firedantic/compare/0.11.0...HEAD +[0.11.0]: https://github.com/ioxiocom/firedantic/compare/0.10.0...0.11.0 [0.10.0]: https://github.com/ioxiocom/firedantic/compare/0.9.0...0.10.0 [0.9.0]: https://github.com/ioxiocom/firedantic/compare/0.8.1...0.9.0 [0.8.1]: https://github.com/ioxiocom/firedantic/compare/0.8.0...0.8.1 diff --git a/pyproject.toml b/pyproject.toml index 36e2901..18e2cc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "firedantic" -version = "0.10.0" +version = "0.11.0" description = "Pydantic base models for Firestore" authors = ["IOXIO Ltd"] license = "BSD-3-Clause"