Repository: proteanhq/protean

Issue:
Add support for `update_all` and `delete_all` methods that ignore Entity Callbacks

Discussion:

Patch:
diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py
index 2d5bfbed..65bee4ca 100644
--- a/src/protean/core/entity.py
+++ b/src/protean/core/entity.py
@@ -316,6 +316,9 @@ class QuerySet:
     def update(self, *data, **kwargs):
         """Updates all objects with details given if they match a set of conditions supplied.
 
+        This method updates each object individually, to fire callback methods and ensure
+        validations are run.
+
         Returns the number of objects matched (which may not be equal to the number of objects
             updated if objects rows already have the new value).
         """
@@ -336,7 +339,6 @@ class QuerySet:
         """Deletes matching objects from the Repository
 
         Does not throw error if no objects are matched.
-        Throws ObjectNotFoundError if the object was not found in the repository
 
         Returns the number of objects matched (which may not be equal to the number of objects
             deleted if objects rows already have the new value).
@@ -355,6 +357,27 @@ class QuerySet:
 
         return deleted_item_count
 
+    def update_all(self, *args, **kwargs):
+        """Updates all objects with details given if they match a set of conditions supplied.
+
+        This method forwards filters and updates directly to the adapter. It does not instantiate
+        models and it does not trigger Entity callbacks or validations.
+
+        Update values can be specified either as a dict, or keyword arguments.
+
+        Returns the number of objects matched (which may not be equal to the number of objects
+            updated if objects rows already have the new value).
+        """
+        updated_item_count = 0
+        _, adapter = self._retrieve_model()
+        try:
+            updated_item_count = adapter._update_all_objects(self._criteria, *args, **kwargs)
+        except Exception as exc:
+            # FIXME Log Exception
+            raise
+
+        return updated_item_count
+
     ###############################
     # Python Magic method support #
     ###############################
diff --git a/src/protean/core/repository/base.py b/src/protean/core/repository/base.py
index c77fec5b..32254636 100644
--- a/src/protean/core/repository/base.py
+++ b/src/protean/core/repository/base.py
@@ -55,6 +55,10 @@ class BaseAdapter(RegisterLookupMixin, metaclass=ABCMeta):
     def _update_object(self, model_obj: Any):
         """Update a model object in the repository and return it"""
 
+    @abstractmethod
+    def _update_all_objects(self, criteria: Q, *args, **kwargs):
+        """Updates object directly in the repository and returns update count"""
+
     @abstractmethod
     def _delete_objects(self, **filters):
         """Delete a Record from the Repository"""
diff --git a/src/protean/impl/repository/dict_repo.py b/src/protean/impl/repository/dict_repo.py
index 911073b1..af9579e4 100644
--- a/src/protean/impl/repository/dict_repo.py
+++ b/src/protean/impl/repository/dict_repo.py
@@ -139,6 +139,21 @@ class Adapter(BaseAdapter):
             self.conn['data'][self.model_name][identifier] = model_obj
         return model_obj
 
+    def _update_all_objects(self, criteria: Q, *args, **kwargs):
+        """ Update all objects satisfying the criteria """
+        items = self._filter(criteria, self.conn['data'][self.model_name])
+
+        update_count = 0
+        for key in items:
+            item = items[key]
+            item.update(*args)
+            item.update(kwargs)
+            self.conn['data'][self.model_name][key] = item
+
+            update_count += 1
+
+        return update_count
+
     def _delete_objects(self, **filters):
         """ Delete the dictionary object by its id"""
 


Test patch:
diff --git a/tests/core/test_entity.py b/tests/core/test_entity.py
index de314e2f..50f8f676 100644
--- a/tests/core/test_entity.py
+++ b/tests/core/test_entity.py
@@ -293,6 +293,50 @@ class TestEntity:
         assert u_dog3.owner == 'Jane'
         assert u_dog4.owner == 'Jane'
 
+    def test_update_all_with_args(self):
+        """Try updating all records satisfying filter in one step, passing a dict"""
+        Dog.create(id=1, name='Athos', owner='John', age=2)
+        Dog.create(id=2, name='Porthos', owner='John', age=3)
+        Dog.create(id=3, name='Aramis', owner='John', age=4)
+        Dog.create(id=4, name='dArtagnan', owner='John', age=5)
+
+        # Perform update
+        updated_count = Dog.query.filter(age__gt=3).update_all({'owner': 'Jane'})
+
+        # Query and check if only the relevant records have been updated
+        assert updated_count == 2
+
+        u_dog1 = Dog.get(1)
+        u_dog2 = Dog.get(2)
+        u_dog3 = Dog.get(3)
+        u_dog4 = Dog.get(4)
+        assert u_dog1.owner == 'John'
+        assert u_dog2.owner == 'John'
+        assert u_dog3.owner == 'Jane'
+        assert u_dog4.owner == 'Jane'
+
+    def test_update_all_with_kwargs(self):
+        """Try updating all records satisfying filter in one step"""
+        Dog.create(id=1, name='Athos', owner='John', age=2)
+        Dog.create(id=2, name='Porthos', owner='John', age=3)
+        Dog.create(id=3, name='Aramis', owner='John', age=4)
+        Dog.create(id=4, name='dArtagnan', owner='John', age=5)
+
+        # Perform update
+        updated_count = Dog.query.filter(age__gt=3).update_all(owner='Jane')
+
+        # Query and check if only the relevant records have been updated
+        assert updated_count == 2
+
+        u_dog1 = Dog.get(1)
+        u_dog2 = Dog.get(2)
+        u_dog3 = Dog.get(3)
+        u_dog4 = Dog.get(4)
+        assert u_dog1.owner == 'John'
+        assert u_dog2.owner == 'John'
+        assert u_dog3.owner == 'Jane'
+        assert u_dog4.owner == 'Jane'
+
     def test_unique(self):
         """ Test the unique constraints for the entity """
         Dog.create(id=2, name='Johnny', owner='Carey')
diff --git a/tests/test_config.py b/tests/test_config.py
index 7189f16c..63f81cd7 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,22 +1,24 @@
 """ Module to test Config functionality """
 import os
 
-import pytest
-
 from protean.conf import Config
-from protean.core.exceptions import ConfigurationError
-
 
-def test_config_module():
-    """ Test that config module is loaded correctly"""
 
+def test_default_config_module():
+    """ Test that default config module is loaded correctly"""
     # Do not set any config file
     os.environ['PROTEAN_CONFIG'] = ''
-    with pytest.raises(ConfigurationError):
-        Config()
+    config1 = Config()
+
+    # Config should have considered protean.conf.default_config s default config
+    assert config1.SECRET_KEY == 'wR5yJVF!PVA3&bBaFK%e3#MQna%DJfyT'
+
+
+def test_config_module_load():
+    """ Test that specified config module is loaded correctly"""
 
     # Set the config file and make sure values get loaded
     os.environ['PROTEAN_CONFIG'] = 'tests.support.sample_config'
-    config = Config()
-    assert config.TESTING
-    assert config.SECRET_KEY == 'abcdefghijklmn'
+    config2 = Config()
+    assert config2.TESTING
+    assert config2.SECRET_KEY == 'abcdefghijklmn'