Repository: proteanhq/protean

Issue:
Introduce `save()` to persist current entity state to repository
Allowing `save()` to be called on a loaded entity will enable us to persist all fields that may have changed in the object. Typically used when the changes to an entity span across multiple attributes or values to be updated are not straightforward and involve a calculation.

Discussion:

Patch:
diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py
index 46e08f9d..b8c625f1 100644
--- a/src/protean/core/entity.py
+++ b/src/protean/core/entity.py
@@ -234,6 +234,25 @@ class Entity(metaclass=EntityBase):
         # Return the first result
         return results.first
 
+    @classmethod
+    def find_by(cls, **kwargs) -> 'Entity':
+        """Find a specific entity record that matches one or more criteria.
+
+        :param kwargs: named arguments consisting of attr_name and attr_value pairs to search on
+        """
+        logger.debug(f'Lookup `{cls.__name__}` object with values '
+                     f'{kwargs}')
+
+        # Find this item in the repository or raise Error
+        results = cls.filter(page=1, per_page=1, **kwargs)
+        if not results:
+            raise ObjectNotFoundError(
+                f'`{cls.__name__}` object with values {[item for item in kwargs.items()]} '
+                f'does not exist.')
+
+        # Return the first result
+        return results.first
+
     @classmethod
     def _retrieve_model(cls):
         """Retrieve model details associated with this Entity"""
@@ -315,6 +334,7 @@ class Entity(metaclass=EntityBase):
         model_cls, adapter = cls._retrieve_model()
 
         # Build the entity from the input arguments
+        # Raises validation errors, if any, at this point
         entity = cls(*args, **kwargs)
 
         # Do unique checks, create this object and return it
@@ -334,6 +354,21 @@ class Entity(metaclass=EntityBase):
 
         return entity
 
+    def save(self):
+        """Save a new Entity into repository.
+
+        Performs unique validations before creating the entity.
+        """
+        logger.debug(
+            f'Creating new `{self.__class__.__name__}` object')
+
+        values = {}
+        for item in self.meta_.declared_fields.items():
+            values[item[0]] = getattr(self, item[0])
+
+        return self.__class__.create(**values)
+
+
     def update(self, *data, **kwargs) -> 'Entity':
         """Update a Record in the repository.
 


Test patch:
diff --git a/tests/core/test_entity.py b/tests/core/test_entity.py
index f12f9be8..2bf05298 100644
--- a/tests/core/test_entity.py
+++ b/tests/core/test_entity.py
@@ -114,6 +114,57 @@ class TestEntity:
         assert dog.to_dict() == {
             'age': 10, 'id': 1, 'name': 'John Doe', 'owner': 'Jimmy'}
 
+    def test_get(self):
+        """Test Entity Retrieval by its primary key"""
+        Dog.create(id=1234, name='Johnny', owner='John')
+
+        dog = Dog.get(1234)
+        assert dog is not None
+        assert dog.id == 1234
+
+    def test_get_object_not_found_error(self):
+        """Test failed Entity Retrieval by its primary key"""
+        Dog.create(id=1234, name='Johnny', owner='John')
+
+        with pytest.raises(ObjectNotFoundError):
+            Dog.get(1235)
+
+    def test_find_by(self):
+        """Test Entity retrieval by a specific column's value"""
+
+        Dog.create(id=2345, name='Johnny', owner='John')
+
+        dog = Dog.find_by(name='Johnny')
+        assert dog is not None
+        assert dog.id == 2345
+
+    def test_find_by_object_not_found_error(self):
+        """Test Entity retrieval by specific column value(s)"""
+
+        Dog.create(id=2345, name='Johnny', owner='John')
+
+        with pytest.raises(ObjectNotFoundError):
+            Dog.find_by(name='JohnnyChase')
+
+    def test_find_by_multiple_attributes(self):
+        """Test Entity retrieval by multiple column value(s)"""
+
+        Dog.create(id=2346, name='Johnny1', age=8, owner='John')
+        Dog.create(id=2347, name='Johnny2', age=6, owner='John')
+
+        dog = Dog.find_by(name='Johnny1', age=8)
+        assert dog is not None
+        assert dog.id == 2346
+
+    def test_find_by_multiple_attributes_object_not_found_error(self):
+        """Test Entity retrieval by multiple column value(s)"""
+
+        Dog.create(id=2346, name='Johnny1', age=8, owner='John')
+        Dog.create(id=2347, name='Johnny2', age=6, owner='John')
+
+        with pytest.raises(ObjectNotFoundError):
+            Dog.find_by(name='Johnny1', age=6)
+
     def test_create_error(self):
         """ Add an entity to the repository missing a required attribute"""
         with pytest.raises(ValidationError):
@@ -131,6 +182,25 @@ class TestEntity:
         dog = Dog.get(11344234)
         assert dog is not None
 
+    def test_save(self):
+        """Initialize an entity and save it to repository"""
+        dog = Dog(name='Johnny', owner='John')
+
+        saved_dog = dog.save()
+        assert saved_dog is not None
+        assert saved_dog.id is not None
+        assert saved_dog.name == 'Johnny'
+        assert saved_dog.age == 5
+        assert saved_dog.owner == 'John'
+
+    def test_save_validation_error(self):
+        """Test failed `save()` because of validation errors"""
+        dog = Dog(name='Johnny', owner='John')
+
+        with pytest.raises(ValidationError):
+            del dog.name  # Simulate an error by force-deleting an attribute
+            dog.save()
+
     def test_update_with_invalid_id(self):
         """Try to update a non-existing entry"""