Repository: makridenko/supadantic

Issue:
Remove `QSet` methods from `BaseSBModel`
It should be more like `django orm`-style

```python
User.objects.all()
User.objects.filter()
User.objects.exclude()
User.objects.update()
User.objects.delete()
```

Discussion:

Patch:
diff --git a/README.md b/README.md
index ffa372c..a352ce4 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ non_active_user = User(is_active=False)
 non_active_user.save()
 
 # Get all users
-users = User.all()
+users = User.objects.all()
 
 # Count users
 users.count()
@@ -44,15 +44,15 @@ users.first()
 users.last()
 
 # Filter users
-active_users = User.filter(eq={'is_active': True})
+active_users = User.objects.filter(eq={'is_active': True})
 
 # Update all active users
 active_users.update(data={'is_active': False})
 
 # Delete all non active users
-User.filter(neq={'is_active': True}).delete()
+User.objects.filter(neq={'is_active': True}).delete()
 
 # Get one user and delete
-user = User.get(eq={'name': 'John Doe'})
+user = User.objects.get(eq={'name': 'John Doe'})
 user.delete()
 ```
diff --git a/supadantic/models.py b/supadantic/models.py
index 64d3d10..1dba9a2 100644
--- a/supadantic/models.py
+++ b/supadantic/models.py
@@ -1,14 +1,22 @@
 from abc import ABC, abstractmethod
-from typing import Dict, NoReturn
+from typing import Any
 
 from pydantic import BaseModel
+from pydantic._internal._model_construction import ModelMetaclass as PydanticModelMetaclass
 from typing_extensions import Self
 
 from .clients import SupabaseClient
 from .q_set import QSet
 
 
-class BaseSBModel(BaseModel, ABC):
+class ModelMetaclass(PydanticModelMetaclass):
+    def __new__(mcs, name: str, bases: Any, namespace: dict, *args, **kwargs) -> type:
+        new_model = super().__new__(mcs, name, bases, namespace, *args, **kwargs)
+        new_model.objects = QSet(new_model)
+        return new_model
+
+
+class BaseSBModel(BaseModel, ABC, metaclass=ModelMetaclass):
     id: int | None = None
 
     class DoesNotExist(Exception):
@@ -27,14 +35,6 @@ class BaseSBModel(BaseModel, ABC):
         table_name = cls._get_table_name()
         return SupabaseClient(table_name=table_name)
 
-    @classmethod
-    def all(cls) -> QSet:
-        return QSet(model_class=cls).all()
-
-    @classmethod
-    def filter(cls: type[Self], *, eq: Dict | None = None, neq: Dict | None = None) -> QSet:
-        return QSet(model_class=cls).filter(eq=eq, neq=neq)
-
     def save(self: Self) -> Self:
         db_client = self._get_db_client()
         data = self.model_dump(exclude={'id'})
@@ -46,18 +46,6 @@ class BaseSBModel(BaseModel, ABC):
 
         return self.__class__(**response_data)
 
-    @classmethod
-    def get(cls, *, eq: Dict, neq: Dict | None = None) -> Self | NoReturn:
-        result_qs = cls.filter(eq=eq, neq=neq)
-        _filters_str = f"eq={eq}, neq={neq}"
-        if not result_qs:
-            raise cls.DoesNotExist(f'{cls.__name__} object with {_filters_str} does not exist!')
-
-        if result_qs.count() > 1:
-            raise cls.MultipleObjectsReturned(f'For {_filters_str} returned more than 1 {cls.__name__} objects!')
-
-        return result_qs.first()  # pyright: ignore
-
     def delete(self: Self) -> None:
         if self.id:
             db_client = self._get_db_client()
diff --git a/supadantic/q_set.py b/supadantic/q_set.py
index a7bc649..780ccdb 100644
--- a/supadantic/q_set.py
+++ b/supadantic/q_set.py
@@ -1,9 +1,10 @@
-from typing import TYPE_CHECKING, Dict, List, Type
+from typing import TYPE_CHECKING, Dict, List, NoReturn, Type
 
 from typing_extensions import Self
 
 
 if TYPE_CHECKING:
+    from .clients import SupabaseClient
     from .models import BaseSBModel
 
 
@@ -12,9 +13,12 @@ class QSet:
 
     def __init__(self, model_class: Type['BaseSBModel'], objects: List['BaseSBModel'] | None = None) -> None:
         self._model_class = model_class
-        self.client = self._model_class._get_db_client()
         self.objects = objects if objects else []
 
+    @property
+    def client(self) -> 'SupabaseClient':
+        return self._model_class._get_db_client()
+
     def update(self, data: Dict) -> int:
         ids = tuple(obj.id for obj in self.objects)
         response_data = self.client.bulk_update(ids=ids, data=data)  # pyright: ignore
@@ -43,6 +47,21 @@ class QSet:
         objects = list(self._model_class(**data) for data in response_data)
         return self.__class__(model_class=self._model_class, objects=objects)
 
+    def get(self, *, eq: Dict | None = None, neq: Dict | None = None) -> 'BaseSBModel' | NoReturn:
+        result_qs = self.filter(eq=eq, neq=neq)
+        _filters_str = f'eq={eq}, neq={neq}'
+        if not result_qs:
+            raise self._model_class.DoesNotExist(
+                f'{self._model_class.__name__} object with {_filters_str} does not exist!'
+            )
+
+        if result_qs.count() > 1:
+            raise self._model_class.MultipleObjectsReturned(
+                f'For {_filters_str} returned more than 1 {self._model_class.__name__} objects!'
+            )
+
+        return result_qs.first()  # pyright: ignore
+
     def count(self) -> int:
         return len(self.objects)
 
@@ -70,12 +89,9 @@ class QSet:
         return f'<{self.__class__.__name__} {list(self)} >'
 
     def __eq__(self, obj: object) -> bool:
-        if not isinstance(obj, QSet):
-            return False
-
         return all(
             (
-                self._model_class == obj._model_class,
-                self.objects == obj.objects,
+                self._model_class == getattr(obj, '_model_class'),
+                self.objects == getattr(obj, 'objects'),
             )
         )


Test patch:
diff --git a/tests/test_classes/models.py b/tests/test_classes/models.py
index 6515800..893b509 100644
--- a/tests/test_classes/models.py
+++ b/tests/test_classes/models.py
@@ -11,5 +11,5 @@ class ModelMock(BaseSBModel):
         return 'test'
 
     @classmethod
-    def _get_db_client(cls):  # pyright: ignore
+    def _get_db_client(cls) -> SupabaseClientMock:  # pyright: ignore
         return SupabaseClientMock(table_name='test')
diff --git a/tests/test_models.py b/tests/test_models.py
index 1b7756a..efdd410 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,50 +1,9 @@
-import pytest
+from supadantic.q_set import QSet
 
 from .test_classes.models import ModelMock
 
 
 class TestBaseSBModel:
-    def test_all(self):
-        result_qs = ModelMock.all()
-
-        assert result_qs.objects == [
-            ModelMock(id=1, name='test_name'),
-            ModelMock(id=2, name='unique_name'),
-            ModelMock(id=3, name='test_name'),
-            ModelMock(id=4, name='new_name'),
-        ]
-
-    @pytest.mark.parametrize(
-        'with_eq,with_neq',
-        (
-            (False, False),
-            (False, True),
-            (True, False),
-            (True, True),
-        ),
-    )
-    def test_filter(self, with_eq: bool, with_neq: bool):
-        # Prepare data
-        filters = {}
-        expected_data = [
-            ModelMock(id=1, name='test_name'),
-            ModelMock(id=2, name='unique_name'),
-            ModelMock(id=3, name='test_name'),
-            ModelMock(id=4, name='new_name'),
-        ]
-        if with_eq:
-            filters['eq'] = {'name': 'test_name'}
-            expected_data.remove(ModelMock(id=2, name='unique_name'))
-            expected_data.remove(ModelMock(id=4, name='new_name'))
-
-        if with_neq:
-            filters['neq'] = {'id': 1}
-            expected_data.remove(ModelMock(id=1, name='test_name'))
-
-        # Testing
-        actual_qs = ModelMock.filter(**filters)
-        assert actual_qs.objects == expected_data
-
     class TestSave:
         def test_create(self):
             # Prepare data
@@ -68,20 +27,5 @@ class TestBaseSBModel:
             assert updated_entity.id == 2
             assert updated_entity.name == 'test_name'
 
-        class TestGet:
-            def test(self):
-                # Execution
-                actual_entity = ModelMock.get(eq={'name': 'unique_name'})
-
-                # Testing
-                assert actual_entity.id == 2
-                assert actual_entity.name == 'unique_name'
-
-            def test_with_does_not_exist(self):
-                with pytest.raises(ModelMock.DoesNotExist):
-                    ModelMock.get(eq={'id': 5})
-
-            def test_with_multiple_objects_returned(self):
-                # Execution
-                with pytest.raises(ModelMock.MultipleObjectsReturned):
-                    ModelMock.get(eq={'name': 'test_name'})
+    def test_objects(self):
+        assert ModelMock.objects == QSet(model_class=ModelMock)
diff --git a/tests/test_q_set.py b/tests/test_q_set.py
index 35ad740..cea1d98 100644
--- a/tests/test_q_set.py
+++ b/tests/test_q_set.py
@@ -59,6 +59,7 @@ class TestQSet:
             ModelMock(id=3, name='test_name'),
             ModelMock(id=4, name='new_name'),
         ]
+
         if with_eq:
             filters['eq'] = {'name': 'test_name'}
             expected_objects.remove(ModelMock(id=2, name='unique_name'))
@@ -71,9 +72,18 @@ class TestQSet:
         # Testing
         assert QSet(model_class=ModelMock).filter(**filters) == QSet(
             model_class=ModelMock,
-            objects=expected_objects,
+            objects=expected_objects,  # pyright: ignore
         )
 
+    def test_get(self):
+        assert QSet(model_class=ModelMock).get(eq={'id': 1}) == ModelMock(id=1, name='test_name')
+
+        with pytest.raises(ModelMock.DoesNotExist, match='does not exist!'):
+            QSet(model_class=ModelMock).get(eq={'id': 5})
+
+        with pytest.raises(ModelMock.MultipleObjectsReturned, match='returned more than 1'):
+            QSet(model_class=ModelMock).get(eq={'name': 'test_name'})
+
     def test_count(self):
         assert (
             QSet(