Repository: proteanhq/protean

Issue:
Add ability to define Abstract Entities
Such abstract entities will then serve as templates/parents of concrete Entities.

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/proteanhq/protean/pull/138?src=pr&el=h1) Report
> Merging [#138](https://codecov.io/gh/proteanhq/protean/pull/138?src=pr&el=desc) into [master](https://codecov.io/gh/proteanhq/protean/commit/42384231376f6c2a732321021b60302242d27618?src=pr&el=desc) will **increase** coverage by `0.07%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/proteanhq/protean/pull/138/graphs/tree.svg?width=650&token=0sFuFdLBOx&height=150&src=pr)](https://codecov.io/gh/proteanhq/protean/pull/138?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #138      +/-   ##
==========================================
+ Coverage    82.6%   82.68%   +0.07%     
==========================================
  Files          46       46              
  Lines        2185     2194       +9     
  Branches      320      324       +4     
==========================================
+ Hits         1805     1814       +9     
  Misses        259      259              
  Partials      121      121
```


| [Impacted Files](https://codecov.io/gh/proteanhq/protean/pull/138?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/protean/core/repository/factory.py](https://codecov.io/gh/proteanhq/protean/pull/138/diff?src=pr&el=tree#diff-c3JjL3Byb3RlYW4vY29yZS9yZXBvc2l0b3J5L2ZhY3RvcnkucHk=) | `91.04% <100%> (+0.41%)` | :arrow_up: |
| [src/protean/core/entity.py](https://codecov.io/gh/proteanhq/protean/pull/138/diff?src=pr&el=tree#diff-c3JjL3Byb3RlYW4vY29yZS9lbnRpdHkucHk=) | `91.53% <100%> (+0.2%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/proteanhq/protean/pull/138?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/proteanhq/protean/pull/138?src=pr&el=footer). Last update [4238423...0737cfd](https://codecov.io/gh/proteanhq/protean/pull/138?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py
index e3c23e18..82d80926 100644
--- a/src/protean/core/entity.py
+++ b/src/protean/core/entity.py
@@ -3,6 +3,7 @@ import copy
 import logging
 from typing import Any
 
+from protean.core.exceptions import NotSupportedError
 from protean.core.exceptions import ObjectNotFoundError
 from protean.core.exceptions import ValidationError
 from protean.core.field import Auto
@@ -38,6 +39,11 @@ class EntityBase(type):
         if not parents:
             return super().__new__(mcs, name, bases, attrs)
 
+        # Remove `abstract` in base classes if defined
+        for base in bases:
+            if hasattr(base.Meta, 'abstract'):
+                delattr(base.Meta, 'abstract')
+
         new_class = super().__new__(mcs, name, bases, attrs, **kwargs)
 
         # Gather `Meta` class/object if defined
@@ -149,6 +155,14 @@ class EntityMeta:
     - ``provider``: the name of the datasource associated with this
         entity, default value is `default`.
     - ``order_by``: default ordering of objects returned by filter queries.
+
+    Also acts as a placeholder for generated entity fields like:
+
+        :declared_fields: dict
+            Any instances of `Field` included as attributes on either the class
+            or on any of its superclasses will be include in this dictionary.
+        :id_field: protean.core.Field
+            An instance of the field that will serve as the unique identifier for the entity
     """
 
     def __init__(self, entity_name, meta):
@@ -253,13 +267,7 @@ class Entity(metaclass=EntityBase):
     class Meta:
         """Options object for an Entity.
 
-        Acts as a placeholder for generated entity fields like:
-
-            :declared_fields: dict
-                Any instances of `Field` included as attributes on either the class
-                or on any of its superclasses will be include in this dictionary.
-            :id_field: protean.core.Field
-                An instance of the field that will serve as the unique identifier for the entity
+        Check ``EntityMeta`` class for full documentation.
         """
 
     def __init__(self, *template, **kwargs):
@@ -272,6 +280,11 @@ class Entity(metaclass=EntityBase):
             can even use a template for initial data.
         """
 
+        if self.meta_.abstract is True:
+            raise NotSupportedError(
+                f'{self.__class__.__name__} class has been marked abstract'
+                f' and cannot be instantiated')
+
         self.errors = {}
 
         # Set up the storage for instance state
diff --git a/src/protean/core/repository/factory.py b/src/protean/core/repository/factory.py
index b98e007f..5b543035 100644
--- a/src/protean/core/repository/factory.py
+++ b/src/protean/core/repository/factory.py
@@ -4,6 +4,7 @@ from collections import namedtuple
 from threading import local
 
 from protean.core.exceptions import ConfigurationError
+from protean.core.exceptions import NotSupportedError
 from protean.core.provider import providers
 from protean.utils.generic import fully_qualified_name
 
@@ -32,9 +33,8 @@ class RepositoryFactory:
 
     def register(self, entity_cls, provider_name=None):
         """ Register the given model with the factory
-        :param model_cls: class of the model to be registered
-        :param adapter_cls: Optional adapter class to use if not the
-        `Adapter` defined by the provider is user
+        :param entity_cls: Entity class to be registered
+        :param provider: Optional provider to associate with Entity class
         """
         self._validate_entity_cls(entity_cls)
 
@@ -104,7 +104,12 @@ class RepositoryFactory:
 
         if not issubclass(entity_cls, Entity):
             raise AssertionError(
-                f'Entity {entity_cls} must be subclass of `Entity`')
+                f'Entity {entity_cls.__name__} must be subclass of `Entity`')
+
+        if entity_cls.meta_.abstract is True:
+            raise NotSupportedError(
+                f'{entity_cls.__name__} class has been marked abstract'
+                f' and cannot be instantiated')
 
     def get_model(self, entity_cls):
         """Retrieve Model class connected to Entity"""


Test patch:
diff --git a/tests/core/test_entity.py b/tests/core/test_entity.py
index a4f79fd0..0e56cca3 100644
--- a/tests/core/test_entity.py
+++ b/tests/core/test_entity.py
@@ -1,6 +1,7 @@
 """Tests for Entity Functionality and Base Classes"""
 
 from collections import OrderedDict
+from datetime import datetime
 
 import pytest
 from tests.support.dog import Dog
@@ -11,6 +12,7 @@ from tests.support.human import Human
 
 from protean.core import field
 from protean.core.entity import Entity
+from protean.core.exceptions import NotSupportedError
 from protean.core.exceptions import ObjectNotFoundError
 from protean.core.exceptions import ValidationError
 from protean.core.queryset import QuerySet
@@ -661,6 +663,78 @@ class TestEntity:
         with pytest.raises(SystemError):
             immortal_dog.delete()
 
+    def test_abstract(self):
+        """Test that abstract entities cannot be initialized"""
+        class AbstractDog(Entity):
+            """A Dog that cannot Live!"""
+            name = field.String(required=True, unique=True, max_length=50)
+            age = field.Integer(default=5)
+            owner = field.String(required=True, max_length=15)
+
+            class Meta:
+                abstract = True
+
+        with pytest.raises(NotSupportedError) as exc1:
+            from protean.core.repository import repo_factory
+            repo_factory.register(AbstractDog)
+        assert exc1.value.args[0] == ('AbstractDog class has been marked abstract'
+                                      ' and cannot be instantiated')
+
+        with pytest.raises(NotSupportedError) as exc2:
+            AbstractDog(name='Titan', age=10001, owner='God')
+        assert exc2.value.args[0] == ('AbstractDog class has been marked abstract'
+                                      ' and cannot be instantiated')
+
+    def test_abstract_inheritance(self):
+        """Test that abstract entities cannot be initialized"""
+        class AbstractDog(Entity):
+            """A Dog that cannot Live!"""
+            age = field.Integer(default=5)
+
+            class Meta:
+                abstract = True
+
+        class ConcreteDog(AbstractDog):
+            """A Dog that inherits aging and death"""
+            name = field.String(required=True, unique=True, max_length=50)
+            owner = field.String(required=True, max_length=15)
+
+        immortal_dog = ConcreteDog(name='Titan', owner='God')
+        assert immortal_dog is not None
+        assert immortal_dog.age == 5
+
+    def test_two_level_abstract_inheritance(self):
+        """Test that abstract entities cannot be initialized"""
+        class AbstractDog(Entity):
+            """A Dog that cannot Live!"""
+            age = field.Integer(default=5)
+
+            class Meta:
+                abstract = True
+
+        class DogWithRecords(AbstractDog):
+            """A Dog that has medical records"""
+            born_at = field.DateTime(default=datetime.now())
+
+            class Meta:
+                abstract = True
+
+        class ConcreteDog(DogWithRecords):
+            """A Dog that inherits aging and death, with medical records"""
+            name = field.String(required=True, unique=True, max_length=50)
+            owner = field.String(required=True, max_length=15)
+
+        ordinary_dog = ConcreteDog(name='Titan', owner='God')
+        assert ordinary_dog is not None
+        assert ordinary_dog.age == 5
+        assert ordinary_dog.born_at is not None
+
+        with pytest.raises(NotSupportedError) as exc1:
+            from protean.core.repository import repo_factory
+            repo_factory.register(DogWithRecords)
+        assert exc1.value.args[0] == ('DogWithRecords class has been marked abstract'
+                                      ' and cannot be instantiated')
+
 
 class TestEntityMetaAttributes:
     """Class that holds testcases for Entity's meta attributes"""
diff --git a/tests/core/test_entity_associations.py b/tests/core/test_entity_associations.py
index 62fc8bbe..f52cf975 100644
--- a/tests/core/test_entity_associations.py
+++ b/tests/core/test_entity_associations.py
@@ -21,11 +21,7 @@ from tests.support.human import Human
 
 from protean.core.exceptions import ValidationError
 from protean.core.queryset import QuerySet
-<<<<<<< HEAD
-from protean.core.repository import Pagination
-=======
 from protean.core.repository import ResultSet
->>>>>>> 930ae3e64b2af179fecacd853dccd2f45fa916c4
 
 
 class TestReference:
@@ -306,11 +302,7 @@ class TestHasMany:
         assert 'dogs' in human.__dict__  # Avaiable after access
 
         assert isinstance(human.dogs, QuerySet)
-<<<<<<< HEAD
-        assert isinstance(human.dogs.all(), Pagination)
-=======
         assert isinstance(human.dogs.all(), ResultSet)
->>>>>>> 930ae3e64b2af179fecacd853dccd2f45fa916c4
         assert all(dog.id in [101, 102] for dog in human.dogs)  # `__iter__` magic here
 
     def test_init_with_via(self):