diff --git a/mutable_model.py b/mutable_model.py deleted file mode 100644 index 74e8e81..0000000 --- a/mutable_model.py +++ /dev/null @@ -1,151 +0,0 @@ -import typing -from collections.abc import MutableMapping - -import bson -from pydantic import BaseModel, create_model, field_validator -from pydantic.fields import FieldInfo, Field - - -class Document(MutableMapping): - def __new__(cls, *args, **kwargs): - print(f'New {cls=}, {args}, {kwargs}') - cls._required_fields = {k for k, v in cls.model_fields.items() if v.is_required and k != 'metadata_'} - return super().__new__(cls) - - def __init__(self, *args, **kwargs): - print(f'\nInitialized -> {args}, {kwargs}, {self.__class__}\n') - self.cls = self.__class__ - self.metadata_ = {'__class__': self.__class__} - # breakpoint() - - def __getitem__(self, key): - if key == '_id': - key = 'id' - print(f'{id(self)=} {self._required_fields=}= Get -> {key=}') - if hasattr(self, key): - getattr(self, key) - else: - return self.metadata_[key] - - def __setitem__(self, key, value): - if key == '_id': - key = 'id' - # value = str(value) - print(f'{id(self)=} + Set -> {key=}, {value=}') - # if hasattr(self, key): - # setattr(self, key, value) - # else: - self.metadata_[key] = value - - print(set(self.metadata_.keys()), self._required_fields, self._required_fields == set(self.metadata_.keys())) - if set(self.metadata_.keys()) == self._required_fields: - # breakpoint() - obj = self.__class__(**self.metadata_) - print(f'{obj=}\n') - - def __copy__(self): - print(f'{id(self)=} Copy -> {id(self)}') - - def __call__(self, *args, **kwargs): - return self - - def __del__(self): - print(f'{id(self)=} Del --> {self=}') - - def __delitem__(self, key): - print(f'DelItem -> {key=}') - del self.metadata_[key] - - def __iter__(self): - print(f'Iter -> ') - return iter(self.metadata_) - - def __len__(self): - print(f'Len -> ') - return len(self.metadata_) - - -class MetaModel: - def __new__( - cls, - cls_name: str, - bases: tuple[type[typing.Any], ...], - namespace: dict[str, typing.Any], - **kwargs - ): - print(f'{cls_name=}') - if cls_name == 'Model': - return super().__new__(cls) - - field_definitions = { - key: (value, namespace.get(key, FieldInfo(annotation=value))) - for key, value in namespace.pop('__annotations__', {}).items() - } - - return create_model( - __model_name=cls_name, - __module__=namespace['__module__'], - __validators__=namespace, - __base__=(BaseModel, Document), - **field_definitions - ) - - -class Model(metaclass=MetaModel): - pass - - -class User(Model): - metadata_: dict = Field({}, exclude=True, ) - id: str | None = Field(None) - # _id: str | None = PrivateAttr() - name: str - - # def __new__(cls, *args, **kwargs): - # print(f'User.__new__') - # return super().__new__(cls) - # - # def __init__(self): - # print(f'User.__init__') - # return super().__init__(self.__class__) - - - @field_validator('id', mode='before') - def validate_id(cls, value: str | int | bson.ObjectId) -> str: - if isinstance(value, int): - pass - - elif isinstance(value, str): - try: - bson.ObjectId(value) - except bson.objectid.InvalidId as e: - msg = 'Invalid ObjectId' - raise ValueError(msg) from e - - elif not isinstance(value, bson.ObjectId): - msg = 'ObjectId required' - raise ValueError(msg) from None - - return str(value) - - -from bson.codec_options import CodecOptions - -# document_class = Document(User) -codec = CodecOptions(document_class=User) - -from pymongo import MongoClient - -db = MongoClient() -# db = MongoClient(document_class=dict).get_database('Test') - -deleted_count = db.get_database('Test', codec_options=codec).session['User'].delete_many({}) -print(f'{deleted_count=}') -inserted = db.get_database('Test', codec_options=codec).session['User'].insert_one({'name': 'Ali'}) -print(f'{inserted=}') -users = db.get_database('Test', codec_options=codec).session['User'].find() -# for u in users: -# print(f'{u=}') -print(f'{users[0]=}') -# print('ok') -# print(f'{users[1].model_dump()=}') diff --git a/panther/db/connections.py b/panther/db/connections.py index 2210727..3fde392 100644 --- a/panther/db/connections.py +++ b/panther/db/connections.py @@ -21,7 +21,7 @@ from pymongo.database import Database -class DatabaseConnection: +class BaseDatabaseConnection: def __init__(self, *args, **kwargs): """Initialized in application startup""" self.init(*args, **kwargs) @@ -36,7 +36,7 @@ def session(self): pass -class MongoDBConnection(DatabaseConnection): +class MongoDBConnection(BaseDatabaseConnection): def init( self, host: str = 'localhost', @@ -73,7 +73,7 @@ def session(self): return self._database -class PantherDBConnection(DatabaseConnection): +class PantherDBConnection(BaseDatabaseConnection): def init(self, path: str | None = None, encryption: bool = False): params = {'db_name': path, 'return_dict': True} if encryption: @@ -90,7 +90,7 @@ def session(self): return self._connection -class DatabaseSession(Singleton): +class DatabaseConnection(Singleton): @property def session(self): return config['database'].session @@ -130,5 +130,5 @@ def create_connection_for_websocket(self) -> _Redis: return self.websocket_connection -db: DatabaseSession = DatabaseSession() +db: DatabaseConnection = DatabaseConnection() redis: RedisConnection = RedisConnection() diff --git a/panther/db/cursor.py b/panther/db/cursor.py index 775cd3c..923806e 100644 --- a/panther/db/cursor.py +++ b/panther/db/cursor.py @@ -16,8 +16,9 @@ class Cursor(_Cursor): models = {} def __init__(self, collection, *args, cls=None, **kwargs): + # cls.__name__ and collection.name are equal. if cls: - self.models[cls.__name__] = cls + self.models[collection.name] = cls self.cls = cls else: self.cls = self.models[collection.name] diff --git a/panther/db/models.py b/panther/db/models.py index 471ad5a..a1e271f 100644 --- a/panther/db/models.py +++ b/panther/db/models.py @@ -56,7 +56,7 @@ def dict(self, *args, **kwargs) -> dict: class BaseUser(Model): first_name: str = Field('', max_length=64) last_name: str = Field('', max_length=64) - last_login: datetime = None + last_login: datetime | None = None async def update_last_login(self) -> None: await self.update(last_login=datetime.now()) diff --git a/panther/db/queries/base_queries.py b/panther/db/queries/base_queries.py new file mode 100644 index 0000000..51c8c0f --- /dev/null +++ b/panther/db/queries/base_queries.py @@ -0,0 +1,126 @@ +import operator +from abc import abstractmethod +from functools import reduce +from sys import version_info + +from pydantic_core._pydantic_core import ValidationError + +from panther.db.cursor import Cursor +from panther.db.utils import prepare_id_for_query +from panther.exceptions import DatabaseError + +if version_info >= (3, 11): + from typing import Self, Iterator +else: + from typing import TypeVar + + Self = TypeVar('Self', bound='BaseQuery') + + +class BaseQuery: + @classmethod + def _merge(cls, *args, is_mongo: bool = False) -> dict: + prepare_id_for_query(*args, is_mongo=is_mongo) + return reduce(operator.ior, filter(None, args), {}) + + @classmethod + def _clean_error_message(cls, validation_error: ValidationError, is_updating: bool = False) -> str: + error = ', '.join( + '{field}="{error}"'.format( + field='.'.join(loc for loc in e['loc']), + error=e['msg'] + ) + for e in validation_error.errors() + if not is_updating or e['type'] != 'missing' + ) + return f'{cls.__name__}({error})' if error else '' + + @classmethod + def _validate_data(cls, *, data: dict, is_updating: bool = False): + """Validate document before inserting to collection""" + try: + cls(**data) + except ValidationError as validation_error: + if error := cls._clean_error_message(validation_error=validation_error, is_updating=is_updating): + raise DatabaseError(error) + + @classmethod + def _create_model_instance(cls, document: dict): + """Prevent getting errors from document insertion""" + try: + return cls(**document) + except ValidationError as validation_error: + if error := cls._clean_error_message(validation_error=validation_error): + raise DatabaseError(error) + + @classmethod + @abstractmethod + async def find_one(cls, *args, **kwargs) -> Self | None: + raise NotImplementedError + + @classmethod + @abstractmethod + async def find(cls, *args, **kwargs) -> list[Self] | Cursor: + raise NotImplementedError + + @classmethod + @abstractmethod + async def first(cls, *args, **kwargs) -> Self | None: + raise NotImplementedError + + @classmethod + @abstractmethod + async def last(cls, *args, **kwargs): + raise NotImplementedError + + @classmethod + @abstractmethod + async def aggregate(cls, *args, **kwargs) -> Iterator[dict]: + raise NotImplementedError + + # # # # # Count # # # # # + @classmethod + @abstractmethod + async def count(cls, *args, **kwargs) -> int: + raise NotImplementedError + + # # # # # Insert # # # # # + @classmethod + @abstractmethod + async def insert_one(cls, *args, **kwargs) -> Self: + raise NotImplementedError + + @classmethod + @abstractmethod + async def insert_many(cls, *args, **kwargs) -> list[Self]: + raise NotImplementedError + + # # # # # Delete # # # # # + @abstractmethod + async def delete(self) -> None: + raise NotImplementedError + + @classmethod + @abstractmethod + async def delete_one(cls, *args, **kwargs) -> bool: + raise NotImplementedError + + @classmethod + @abstractmethod + async def delete_many(cls, *args, **kwargs) -> int: + raise NotImplementedError + + # # # # # Update # # # # # + @abstractmethod + async def update(self, *args, **kwargs) -> None: + raise NotImplementedError + + @classmethod + @abstractmethod + async def update_one(cls, *args, **kwargs) -> bool: + raise NotImplementedError + + @classmethod + @abstractmethod + async def update_many(cls, *args, **kwargs) -> int: + raise NotImplementedError diff --git a/panther/db/queries/mongodb_queries.py b/panther/db/queries/mongodb_queries.py index 83317a7..a83cd9f 100644 --- a/panther/db/queries/mongodb_queries.py +++ b/panther/db/queries/mongodb_queries.py @@ -1,12 +1,14 @@ from __future__ import annotations + from sys import version_info +from typing import Iterable, Sequence + from bson.codec_options import CodecOptions from panther.db.connections import db from panther.db.cursor import Cursor -from panther.db.utils import merge_dicts, prepare_id_for_query -from panther.exceptions import DatabaseError - +from panther.db.queries.base_queries import BaseQuery +from panther.db.utils import prepare_id_for_query if version_info >= (3, 11): from typing import Self @@ -16,11 +18,10 @@ Self = TypeVar('Self', bound='BaseMongoDBQuery') -class BaseMongoDBQuery: +class BaseMongoDBQuery(BaseQuery): @classmethod - def _merge(cls, *args) -> dict: - prepare_id_for_query(*args, is_mongo=True) - return merge_dicts(*args) + def _merge(cls, *args, is_mongo: bool = True) -> dict: + return super()._merge(*args, is_mongo=is_mongo) @classmethod def collection(cls): @@ -32,69 +33,93 @@ def collection(cls): # # # # # Find # # # # # @classmethod - async def find_one(cls, _data: dict | None = None, /, **kwargs) -> Self | None: - if document := await cls.collection().find_one(cls._merge(_data, kwargs)): + async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: + if document := await cls.collection().find_one(cls._merge(_filter, kwargs)): return cls._create_model_instance(document=document) return None @classmethod - async def find(cls, _data: dict | None = None, /, **kwargs) -> Cursor: - return Cursor(cls=cls, collection=cls.collection().delegate, filter=cls._merge(_data, kwargs)) + async def find(cls, _filter: dict | None = None, /, **kwargs) -> Cursor: + return Cursor(cls=cls, collection=cls.collection().delegate, filter=cls._merge(_filter, kwargs)) + + @classmethod + async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: + cursor = await cls.find(_filter, **kwargs) + for result in cursor.sort('_id', 1).limit(-1): + return result + return None @classmethod - async def first(cls, _data: dict | None = None, /, **kwargs) -> Self | None: - return await cls.find_one(_data, **kwargs) + async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: + cursor = await cls.find(_filter, **kwargs) + for result in cursor.sort('_id', -1).limit(-1): + return result + return None @classmethod - async def last(cls, _data: dict | None = None, /, **kwargs): - msg = 'last() is not supported in MongoDB yet.' - raise DatabaseError(msg) + async def aggregate(cls, pipeline: Sequence[dict]) -> Iterable[dict]: + return await cls.collection().aggregate(pipeline) # # # # # Count # # # # # @classmethod - async def count(cls, _data: dict | None = None, /, **kwargs) -> int: - return await cls.collection().count_documents(cls._merge(_data, kwargs)) + async def count(cls, _filter: dict | None = None, /, **kwargs) -> int: + return await cls.collection().count_documents(cls._merge(_filter, kwargs)) # # # # # Insert # # # # # @classmethod - async def insert_one(cls, _data: dict | None = None, /, **kwargs) -> Self: - document = cls._merge(_data, kwargs) + async def insert_one(cls, _document: dict | None = None, /, **kwargs) -> Self: + document = cls._merge(_document, kwargs) + cls._validate_data(data=document) + await cls.collection().insert_one(document) return cls._create_model_instance(document=document) + @classmethod + async def insert_many(cls, documents: Iterable[dict]) -> list[Self]: + for document in documents: + prepare_id_for_query(document, is_mongo=True) + cls._validate_data(data=document) + + await cls.collection().insert_many(documents) + return [cls._create_model_instance(document=document) for document in documents] + # # # # # Delete # # # # # async def delete(self) -> None: await self.collection().delete_one({'_id': self._id}) @classmethod - async def delete_one(cls, _data: dict | None = None, /, **kwargs) -> bool: - result = await cls.collection().delete_one(cls._merge(_data, kwargs)) + async def delete_one(cls, _filter: dict | None = None, /, **kwargs) -> bool: + result = await cls.collection().delete_one(cls._merge(_filter, kwargs)) return bool(result.deleted_count) @classmethod - async def delete_many(cls, _data: dict | None = None, /, **kwargs) -> int: - result = await cls.collection().delete_many(cls._merge(_data, kwargs)) + async def delete_many(cls, _filter: dict | None = None, /, **kwargs) -> int: + result = await cls.collection().delete_many(cls._merge(_filter, kwargs)) return result.deleted_count # # # # # Update # # # # # - async def update(self, **kwargs) -> None: - for field, value in kwargs.items(): + async def update(self, _update: dict | None = None, /, **kwargs) -> None: + document = self._merge(_update, kwargs) + document.pop('_id', None) + self._validate_data(data=document, is_updating=True) + + for field, value in document.items(): setattr(self, field, value) - update_fields = {'$set': kwargs} + update_fields = {'$set': document} await self.collection().update_one({'_id': self._id}, update_fields) @classmethod - async def update_one(cls, _filter: dict, _data: dict | None = None, /, **kwargs) -> bool: + async def update_one(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> bool: prepare_id_for_query(_filter, is_mongo=True) - update_fields = {'$set': cls._merge(_data, kwargs)} + update_fields = {'$set': cls._merge(_update, kwargs)} result = await cls.collection().update_one(_filter, update_fields) return bool(result.matched_count) @classmethod - async def update_many(cls, _filter: dict, _data: dict | None = None, /, **kwargs) -> int: + async def update_many(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> int: prepare_id_for_query(_filter, is_mongo=True) - update_fields = {'$set': cls._merge(_data, kwargs)} + update_fields = {'$set': cls._merge(_update, kwargs)} result = await cls.collection().update_many(_filter, update_fields) return result.modified_count diff --git a/panther/db/queries/pantherdb_queries.py b/panther/db/queries/pantherdb_queries.py index 4ad4ce6..17f0af5 100644 --- a/panther/db/queries/pantherdb_queries.py +++ b/panther/db/queries/pantherdb_queries.py @@ -1,7 +1,10 @@ from sys import version_info +from typing import Iterable from panther.db.connections import db -from panther.db.utils import merge_dicts, prepare_id_for_query +from panther.db.queries.base_queries import BaseQuery +from panther.db.utils import prepare_id_for_query +from panther.exceptions import DatabaseError if version_info >= (3, 11): from typing import Self @@ -11,69 +14,91 @@ Self = TypeVar('Self', bound='BasePantherDBQuery') -class BasePantherDBQuery: +class BasePantherDBQuery(BaseQuery): @classmethod - def _merge(cls, *args) -> dict: - prepare_id_for_query(*args) - return merge_dicts(*args) + def _merge(cls, *args, is_mongo: bool = False) -> dict: + return super()._merge(*args, is_mongo=is_mongo) # # # # # Find # # # # # @classmethod - async def find_one(cls, _data: dict | None = None, /, **kwargs) -> Self | None: - if document := db.session.collection(cls.__name__).find_one(**cls._merge(_data, kwargs)): + async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: + if document := db.session.collection(cls.__name__).find_one(**cls._merge(_filter, kwargs)): return cls._create_model_instance(document=document) return None @classmethod - async def find(cls, _data: dict | None = None, /, **kwargs) -> list[Self]: - documents = db.session.collection(cls.__name__).find(**cls._merge(_data, kwargs)) + async def find(cls, _filter: dict | None = None, /, **kwargs) -> list[Self]: + documents = db.session.collection(cls.__name__).find(**cls._merge(_filter, kwargs)) return [cls._create_model_instance(document=document) for document in documents] @classmethod - async def first(cls, _data: dict | None = None, /, **kwargs) -> Self | None: - if document := db.session.collection(cls.__name__).first(**cls._merge(_data, kwargs)): + async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: + if document := db.session.collection(cls.__name__).first(**cls._merge(_filter, kwargs)): return cls._create_model_instance(document=document) return None @classmethod - async def last(cls, _data: dict | None = None, /, **kwargs) -> Self | None: - if document := db.session.collection(cls.__name__).last(**cls._merge(_data, kwargs)): + async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: + if document := db.session.collection(cls.__name__).last(**cls._merge(_filter, kwargs)): return cls._create_model_instance(document=document) return None + @classmethod + async def aggregate(cls, *args, **kwargs): + msg = 'aggregate() does not supported in `PantherDB`.' + raise DatabaseError(msg) from None + # # # # # Count # # # # # @classmethod - async def count(cls, _data: dict | None = None, /, **kwargs) -> int: - return db.session.collection(cls.__name__).count(**cls._merge(_data, kwargs)) + async def count(cls, _filter: dict | None = None, /, **kwargs) -> int: + return db.session.collection(cls.__name__).count(**cls._merge(_filter, kwargs)) # # # # # Insert # # # # # @classmethod - async def insert_one(cls, _data: dict | None = None, /, **kwargs) -> Self: - document = db.session.collection(cls.__name__).insert_one(**cls._merge(_data, kwargs)) + async def insert_one(cls, _document: dict | None = None, /, **kwargs) -> Self: + _document = cls._merge(_document, kwargs) + cls._validate_data(data=_document) + + document = db.session.collection(cls.__name__).insert_one(**_document) return cls._create_model_instance(document=document) + @classmethod + async def insert_many(cls, documents: Iterable[dict]) -> list[Self]: + result = [] + for _document in documents: + prepare_id_for_query(_document, is_mongo=False) + cls._validate_data(data=_document) + document = db.session.collection(cls.__name__).insert_one(**_document) + result.append(document) + + return result + # # # # # Delete # # # # # async def delete(self) -> None: db.session.collection(self.__class__.__name__).delete_one(_id=self._id) @classmethod - async def delete_one(cls, _data: dict | None = None, /, **kwargs) -> bool: - return db.session.collection(cls.__name__).delete_one(**cls._merge(_data, kwargs)) + async def delete_one(cls, _filter: dict | None = None, /, **kwargs) -> bool: + return db.session.collection(cls.__name__).delete_one(**cls._merge(_filter, kwargs)) @classmethod - async def delete_many(cls, _data: dict | None = None, /, **kwargs) -> int: - return db.session.collection(cls.__name__).delete_many(**cls._merge(_data, kwargs)) + async def delete_many(cls, _filter: dict | None = None, /, **kwargs) -> int: + return db.session.collection(cls.__name__).delete_many(**cls._merge(_filter, kwargs)) # # # # # Update # # # # # - async def update(self, **kwargs) -> None: - for field, value in kwargs.items(): + async def update(self, _update: dict | None = None, /, **kwargs) -> None: + document = self._merge(_update, kwargs) + document.pop('_id', None) + self._validate_data(data=kwargs, is_updating=True) + + for field, value in document.items(): setattr(self, field, value) - db.session.collection(self.__class__.__name__).update_one({'_id': self._id}, **kwargs) + db.session.collection(self.__class__.__name__).update_one({'_id': self._id}, **document) @classmethod - async def update_one(cls, _filter: dict, _data: dict | None = None, /, **kwargs) -> bool: + async def update_one(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> bool: prepare_id_for_query(_filter) - return db.session.collection(cls.__name__).update_one(_filter, **cls._merge(_data, kwargs)) + return db.session.collection(cls.__name__).update_one(_filter, **cls._merge(_update, kwargs)) @classmethod async def update_many(cls, _filter: dict, _data: dict | None = None, /, **kwargs) -> int: diff --git a/panther/db/queries/queries.py b/panther/db/queries/queries.py index 3695258..2b69ee9 100644 --- a/panther/db/queries/queries.py +++ b/panther/db/queries/queries.py @@ -1,11 +1,11 @@ import sys - -from pydantic import ValidationError +from typing import Sequence, Iterable from panther.configs import QueryObservable from panther.db.cursor import Cursor +from panther.db.queries.base_queries import BaseQuery from panther.db.utils import log_query, check_connection -from panther.exceptions import DatabaseError, NotFoundAPIError +from panther.exceptions import NotFoundAPIError __all__ = ('Query',) @@ -17,12 +17,16 @@ Self = TypeVar('Self', bound='Query') -class Query: +class Query(BaseQuery): def __init_subclass__(cls, **kwargs): QueryObservable.observe(cls) @classmethod def _reload_bases(cls, parent): + if not issubclass(parent, BaseQuery): + msg = f'Invalid Query Class: `{parent.__name__}` should be subclass of `BaseQuery`' + raise ValueError(msg) + if cls.__bases__.count(Query): cls.__bases__ = (*cls.__bases__[: cls.__bases__.index(Query) + 1], parent) else: @@ -30,129 +34,180 @@ def _reload_bases(cls, parent): if kls.__bases__.count(Query): kls.__bases__ = (*kls.__bases__[:kls.__bases__.index(Query) + 1], parent) + # # # # # Find # # # # # @classmethod - def _clean_error_message(cls, validation_error: ValidationError, is_updating: bool = False) -> str: - error = ', '.join( - '{field}="{error}"'.format( - field='.'.join(loc for loc in e['loc']), - error=e['msg'] - ) - for e in validation_error.errors() - if not is_updating or e['type'] != 'missing' - ) - return f'{cls.__name__}({error})' if error else '' + @check_connection + @log_query + async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: + """ + Get a single document from the database. - @classmethod - def _validate_data(cls, *, data: dict, is_updating: bool = False): - """Validate data before inserting to db""" - try: - cls(**data) - except ValidationError as validation_error: - if error := cls._clean_error_message(validation_error=validation_error, is_updating=is_updating): - raise DatabaseError(error) + Example: + ------- + >>> from app.models import User - @classmethod - def _create_model_instance(cls, document: dict): - """Prevent getting errors from document insertion""" - try: - return cls(**document) - except ValidationError as validation_error: - if error := cls._clean_error_message(validation_error=validation_error): - raise DatabaseError(error) + >>> await User.find_one(id=1, name='Ali') + or + >>> await User.find_one({'id': 1, 'name': 'Ali'}) + or + >>> await User.find_one({'id': 1}, name='Ali') + """ + return await super().find_one(_filter, **kwargs) - # # # # # Find # # # # # @classmethod @check_connection @log_query - async def find_one(cls, _data: dict | None = None, /, **kwargs) -> Self | None: + async def find(cls, _filter: dict | None = None, /, **kwargs) -> list[Self] | Cursor: """ + Get documents from the database. + Example: ------- - >>> from example.app.models import User - >>> await User.find_one(id=1) + >>> from app.models import User + + >>> await User.find(age=18, name='Ali') + or + >>> await User.find({'age': 18, 'name': 'Ali'}) + or + >>> await User.find({'age': 18}, name='Ali') """ - return await super().find_one(_data, **kwargs) + return await super().find(_filter, **kwargs) @classmethod @check_connection @log_query - async def find(cls, _data: dict | None = None, /, **kwargs) -> list[Self] | Cursor: + async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: """ + Get the first document from the database. + Example: ------- - >>> from example.app.models import User - >>> await User.find(name='Ali') + >>> from app.models import User + + >>> await User.first(age=18, name='Ali') + or + >>> await User.first({'age': 18, 'name': 'Ali'}) + or + >>> await User.first({'age': 18}, name='Ali') """ - return await super().find(_data, **kwargs) + return await super().first(_filter, **kwargs) @classmethod @check_connection @log_query - async def first(cls, _data: dict | None = None, /, **kwargs) -> Self | None: + async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: """ + Get the last document from the database. + Example: ------- - >>> from example.app.models import User - >>> user = await User.first(name='Ali') - * Alias of find_one() + >>> from app.models import User + + >>> await User.last(age=18, name='Ali') + or + >>> await User.last({'age': 18, 'name': 'Ali'}) + or + >>> await User.last({'age': 18}, name='Ali') """ - return await super().first(_data, **kwargs) + return await super().last(_filter, **kwargs) @classmethod @check_connection @log_query - async def last(cls, _data: dict | None = None, /, **kwargs) -> Self | None: + async def aggregate(cls, pipeline: Sequence[dict]) -> Iterable[dict]: """ + Perform an aggregation using the aggregation framework on this collection. + Example: ------- - >>> from example.app.models import User - >>> user = await User.last(name='Ali') + >>> from app.models import User + + >>> pipeline = [ + >>> {'$match': {...}}, + >>> {'$unwind': ...}, + >>> {'$group': {...}}, + >>> {'$project': {...}}, + >>> {'$sort': {...}} + >>> ... + >>> ] + + >>> await User.aggregate(pipeline) """ - return await super().last(_data, **kwargs) + return await super().aggregate(pipeline) # # # # # Count # # # # # @classmethod @check_connection @log_query - async def count(cls, _data: dict | None = None, /, **kwargs) -> int: + async def count(cls, _filter: dict | None = None, /, **kwargs) -> int: """ + Count the number of documents in this collection. + Example: ------- - >>> from example.app.models import User - >>> await User.count(name='Ali') + >>> from app.models import User + + >>> await User.count(age=18, name='Ali') + or + >>> await User.count({'age': 18, 'name': 'Ali'}) + or + >>> await User.count({'age': 18}, name='Ali') """ - return await super().count(_data, **kwargs) + return await super().count(_filter, **kwargs) # # # # # Insert # # # # # @classmethod @check_connection @log_query - async def insert_one(cls, _data: dict | None = None, /, **kwargs) -> Self: + async def insert_one(cls, _document: dict | None = None, /, **kwargs) -> Self: """ + Insert a single document. + Example: ------- - >>> from example.app.models import User - >>> await User.insert_one(name='Ali', age=24, ...) + >>> from app.models import User + + >>> await User.insert_one(age=18, name='Ali') + or + >>> await User.insert_one({'age': 18, 'name': 'Ali'}) + or + >>> await User.insert_one({'age': 18}, name='Ali') """ - cls._validate_data(data=(_data or {}) | kwargs) - return await super().insert_one(_data, **kwargs) + return await super().insert_one(_document, **kwargs) @classmethod @check_connection @log_query - async def insert_many(cls, _data: dict | None = None, /, **kwargs): - msg = 'insert_many() is not supported yet.' - raise DatabaseError(msg) + async def insert_many(cls, documents: Iterable[dict]) -> list[Self]: + """ + Insert an iterable of documents. + + Example: + ------- + >>> from app.models import User + + >>> users = [ + >>> {'age': 18, 'name': 'Ali'}, + >>> {'age': 17, 'name': 'Saba'} + >>> {'age': 16, 'name': 'Amin'} + >>> ] + >>> await User.insert_many(users) + """ + return super().insert_many(documents) # # # # # Delete # # # # # @check_connection @log_query async def delete(self) -> None: """ + Delete the document. + Example: ------- - >>> from example.app.models import User + >>> from app.models import User + >>> user = await User.find_one(name='Ali') + >>> await user.delete() """ await super().delete() @@ -160,100 +215,145 @@ async def delete(self) -> None: @classmethod @check_connection @log_query - async def delete_one(cls, _data: dict | None = None, /, **kwargs) -> bool: + async def delete_one(cls, _filter: dict | None = None, /, **kwargs) -> bool: """ + Delete a single document matching the filter. + Example: ------- - >>> from example.app.models import User - >>> await User.delete_one(id=1) + >>> from app.models import User + + >>> await User.delete_one(age=18, name='Ali') + or + >>> await User.delete_one({'age': 18, 'name': 'Ali'}) + or + >>> await User.delete_one({'age': 18}, name='Ali') """ - return await super().delete_one(_data, **kwargs) + return await super().delete_one(_filter, **kwargs) @classmethod @check_connection @log_query - async def delete_many(cls, _data: dict | None = None, /, **kwargs) -> int: + async def delete_many(cls, _filter: dict | None = None, /, **kwargs) -> int: """ + Delete one or more documents matching the filter. + Example: ------- - >>> from example.app.models import User - >>> await User.delete_many(last_name='Rn') + >>> from app.models import User + + >>> await User.delete_many(age=18, name='Ali') + or + >>> await User.delete_many({'age': 18, 'name': 'Ali'}) + or + >>> await User.delete_many({'age': 18}, name='Ali') """ - return await super().delete_many(_data, **kwargs) + return await super().delete_many(_filter, **kwargs) # # # # # Update # # # # # @check_connection @log_query - async def update(self, **kwargs) -> None: + async def update(self, _update: dict | None = None, /, **kwargs) -> None: """ + Update the document. + Example: ------- - >>> from example.app.models import User - >>> user = await User.find_one(name='Ali') - >>> await user.update(name='Saba') + >>> from app.models import User + + >>> user = await User.find_one(age=18, name='Ali') + + >>> await user.update(name='Saba', age=19) + or + >>> await user.update({'name': 'Saba'}, age=19) + or + >>> await user.update({'name': 'Saba', 'age': 19}) """ - self._validate_data(data=kwargs, is_updating=True) - await super().update(**kwargs) + await super().update(_update, **kwargs) @classmethod @check_connection @log_query - async def update_one(cls, _filter: dict, _data: dict | None = None, /, **kwargs) -> bool: + async def update_one(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> bool: """ + Update a single document matching the filter. + Example: ------- - >>> from example.app.models import User - >>> await User.update_one({'id': 1}, name='Ali') - >>> await User.update_one({'id': 2}, {'name': 'Ali', 'age': 25}) + >>> from app.models import User + + >>> await User.update_one({'id': 1}, age=18, name='Ali') + or + >>> await User.update_one({'id': 1}, {'age': 18, 'name': 'Ali'}) + or + >>> await User.update_one({'id': 1}, {'age': 18}, name='Ali') """ - return await super().update_one(_filter, _data, **kwargs) + return await super().update_one(_filter, _update, **kwargs) @classmethod @check_connection @log_query - async def update_many(cls, _filter: dict, _data: dict | None = None, /, **kwargs) -> int: + async def update_many(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> int: """ + Update one or more documents that match the filter. + Example: ------- - >>> from example.app.models import User - >>> await User.update_many({'name': 'Mohsen'}, name='Ali') - >>> await User.update_many({'name': 'Mohsen'}, {'name': 'Ali'}) + >>> from app.models import User + + >>> await User.update_many({'name': 'Saba'}, age=18, name='Ali') + or + >>> await User.update_many({'name': 'Saba'}, {'age': 18, 'name': 'Ali'}) + or + >>> await User.update_many({'name': 'Saba'}, {'age': 18}, name='Ali') """ - return await super().update_many(_filter, _data, **kwargs) + return await super().update_many(_filter, _update, **kwargs) # # # # # Other # # # # # @classmethod async def all(cls) -> list[Self] | Cursor: """ + Alias of find() without args + Example: ------- - >>> from example.app.models import User + >>> from app.models import User + >>> await User.all() - * Alias of find() without args """ return await cls.find() @classmethod - async def find_or_insert(cls, **kwargs) -> tuple[bool, any]: + async def find_one_or_insert(cls, _filter: dict | None = None, /, **kwargs) -> tuple[bool, any]: """ Example: ------- - >>> from example.app.models import User - >>> user = await User.find_or_insert(name='Ali') + >>> from app.models import User + + >>> await User.find_one_or_insert(age=18, name='Ali') + or + >>> await User.find_one_or_insert({'age': 18, 'name': 'Ali'}) + or + >>> await User.find_one_or_insert({'age': 18}, name='Ali') """ - if obj := await cls.find_one(**kwargs): + if obj := await cls.find_one(_filter, **kwargs): return False, obj - return True, await cls.insert_one(**kwargs) + return True, await cls.insert_one(_filter, **kwargs) @classmethod - async def find_one_or_raise(cls, **kwargs) -> Self: + async def find_one_or_raise(cls, _filter: dict | None = None, /, **kwargs) -> Self: """ Example: ------- - >>> from example.app.models import User - >>> user = await User.find_one_or_raise(name='Ali') + >>> from app.models import User + + >>> await User.find_one_or_raise(age=18, name='Ali') + or + >>> await User.find_one_or_raise({'age': 18, 'name': 'Ali'}) + or + >>> await User.find_one_or_raise({'age': 18}, name='Ali') """ - if obj := await cls.find_one(**kwargs): + if obj := await cls.find_one(_filter, **kwargs): return obj raise NotFoundAPIError(detail=f'{cls.__name__} Does Not Exists') @@ -264,10 +364,19 @@ async def save(self) -> None: """ Example: ------- - >>> from example.app.models import User + >>> from app.models import User + + # Update >>> user = await User.find_one(name='Ali') >>> user.name = 'Saba' >>> await user.save() + or + # Insert + >>> user = User(name='Ali') + >>> await user.save() """ - msg = 'save() is not supported yet.' - raise DatabaseError(msg) from None + document = self.model_dump(exclude=['_id']) + if self.id: + await self.update(document) + else: + await self.insert_one(document) diff --git a/panther/db/utils.py b/panther/db/utils.py index cb040ff..8154020 100644 --- a/panther/db/utils.py +++ b/panther/db/utils.py @@ -1,6 +1,4 @@ import logging -import operator -from functools import reduce from time import perf_counter from panther.configs import config @@ -11,7 +9,6 @@ except ImportError: pass - logger = logging.getLogger('query') @@ -22,9 +19,10 @@ async def log(*args, **kwargs): start = perf_counter() response = await func(*args, **kwargs) end = perf_counter() - class_name = args[0].__name__ if hasattr(args[0], '__name__') else args[0].__class__.__name__ + class_name = getattr(args[0], '__name__', args[0].__class__.__name__) logger.info(f'\033[1mQuery -->\033[0m {class_name}.{func.__name__}() --> {(end - start) * 1_000:.2} ms') return response + return log @@ -34,6 +32,7 @@ async def wrapper(*args, **kwargs): msg = "You don't have active database connection, Check your middlewares" raise NotImplementedError(msg) return await func(*args, **kwargs) + return wrapper @@ -57,7 +56,3 @@ def _convert_to_object_id(_id): except bson.objectid.InvalidId: msg = f'id={_id} is invalid bson.ObjectId' raise bson.errors.InvalidId(msg) - - -def merge_dicts(*args) -> dict: - return reduce(operator.ior, filter(None, args), {}) diff --git a/panther/main.py b/panther/main.py index 606c82c..034487f 100644 --- a/panther/main.py +++ b/panther/main.py @@ -6,7 +6,6 @@ from collections.abc import Callable from logging.config import dictConfig from pathlib import Path -from threading import Thread import panther.logging from panther import status diff --git a/tests/test_database.py b/tests/test_database.py index 5575212..eed5750 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -9,7 +9,6 @@ from panther.db import Model from panther.db.connections import db from panther.db.cursor import Cursor -from panther.exceptions import DatabaseError f = faker.Faker() @@ -126,12 +125,13 @@ async def test_last(self): author = f.name() pages_count = random.randint(0, 10) await self._insert_many_with_specific_params(name=name, author=author, pages_count=pages_count) + last_obj = await Book.insert_one(name=name, author=author, pages_count=pages_count) # Find One book = await Book.last(name=name, author=author, pages_count=pages_count) assert isinstance(book, Book) - assert book.id + assert book.id == last_obj.id assert book.name == name assert book.pages_count == pages_count @@ -507,19 +507,3 @@ def setUp(self): def tearDown(self) -> None: db.session.drop_collection('Book') - - async def test_last(self): - try: - await super().test_last() - except DatabaseError as exc: - assert exc.args[0] == 'last() is not supported in MongoDB yet.' - else: - assert False - - async def test_last_not_found(self): - try: - await super().test_last_not_found() - except DatabaseError as exc: - assert exc.args[0] == 'last() is not supported in MongoDB yet.' - else: - assert False