Repository: proteanhq/protean

Issue:
Cache query results as part of the QuerySet object
The results returned by a query should be cached as part of the QuerySet object so that subsequent requests do not fire on the repository. A new QuerySet object is returned anyway on a criteria change (or on the amendment of query preferences - page, per_page, order_by), so the result cache will be empty.

Discussion:

Patch:
diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py
index 5dc8f409..7779faf4 100644
--- a/src/protean/core/entity.py
+++ b/src/protean/core/entity.py
@@ -100,6 +100,10 @@ class QuerySet:
     without actually fetching data. No data fetch actually occurs until you do something
     to evaluate the queryset.
 
+    When being evaluated, a `QuerySet` typically caches its results. If the data in the database
+    might have changed, you can get updated results for the same query by calling `all()` on a
+    previously evaluated `QuerySet`.
+
     Attributes:
         page: The current page number of the records to be pulled
         per_page: The size of each page of the records to be pulled
@@ -133,6 +137,8 @@ class QuerySet:
         self._excludes = excludes_ or {}
         self._filters = filters
 
+        self._result_cache = None
+
     def _clone(self):
         """
         Return a copy of the current QuerySet.
@@ -199,6 +205,9 @@ class QuerySet:
         """
         logger.debug(f'Query `{self.__class__.__name__}` objects with filters {self}')
 
+        # Destroy any cached results
+        self._result_cache = None
+
         # Fetch Model class and connected-adapter from Repository Factory
         model_cls, adapter = self._retrieve_model()
 
@@ -215,6 +224,9 @@ class QuerySet:
             entity_items.append(model_cls.to_entity(item))
         results.items = entity_items
 
+        # Cache results
+        self._result_cache = results
+
         return results
 
     ###############################
@@ -223,14 +235,23 @@ class QuerySet:
 
     def __iter__(self):
         """Return results on iteration"""
+        if self._result_cache:
+            return iter(self._result_cache)
+
         return iter(self.all())
 
     def __len__(self):
         """Return length of results"""
+        if self._result_cache:
+            return self._result_cache.total
+
         return self.all().total
 
     def __bool__(self):
         """Return True if query results have items"""
+        if self._result_cache:
+            return bool(self._result_cache)
+
         return bool(self.all())
 
     def __repr__(self):
@@ -239,6 +260,9 @@ class QuerySet:
 
     def __getitem__(self, k):
         """Support slicing of results"""
+        if self._result_cache:
+            return self._result_cache.items[k]
+
         return self.all().items[k]
 
     #########################
@@ -248,26 +272,41 @@ class QuerySet:
     @property
     def total(self):
         """Return the total number of records"""
+        if self._result_cache:
+            return self._result_cache.total
+
         return self.all().total
 
     @property
     def items(self):
         """Return result values"""
+        if self._result_cache:
+            return self._result_cache.items
+
         return self.all().items
 
     @property
     def first(self):
         """Return the first result"""
+        if self._result_cache:
+            return self._result_cache.first
+
         return self.all().first
 
     @property
     def has_next(self):
         """Return True if there are more values present"""
+        if self._result_cache:
+            return self._result_cache.has_next
+
         return self.all().has_next
 
     @property
     def has_prev(self):
         """Return True if there are previous values present"""
+        if self._result_cache:
+            return self._result_cache.has_prev
+
         return self.all().has_prev
 
 
diff --git a/src/protean/core/repository/pagination.py b/src/protean/core/repository/pagination.py
index 916c6135..878bfb9a 100644
--- a/src/protean/core/repository/pagination.py
+++ b/src/protean/core/repository/pagination.py
@@ -30,7 +30,7 @@ class Pagination(object):
     @property
     def has_prev(self):
         """True if a previous page exists"""
-        return self.page > 1
+        return bool(self.items) and self.page > 1
 
     @property
     def has_next(self):


Test patch:
diff --git a/tests/core/test_entity.py b/tests/core/test_entity.py
index 6fd61850..392e2890 100644
--- a/tests/core/test_entity.py
+++ b/tests/core/test_entity.py
@@ -481,7 +481,7 @@ class TestQuerySet:
         query = Dog.query.filter(owner='John').order_by('age')
         assert repr(query) == ("<QuerySet: {'_entity_cls_name': 'Dog', '_page': 1, "
                                "'_per_page': 10, '_order_by': {'age'}, '_excludes': {}, "
-                               "'_filters': {'owner': 'John'}}>")
+                               "'_filters': {'owner': 'John'}, '_result_cache': None}>")
 
     def test_bool_false(self):
         """Test that `bool` returns `False` on no records"""
@@ -523,6 +523,43 @@ class TestQuerySet:
         sliced = query[1:]
         assert len(sliced) == 4
 
+    def test_caching(self):
+        """Test that results are cached after query is evaluated"""
+        # Add multiple entries to the DB
+        Dog.create(id=2, name='Murdock', age=7, owner='John')
+        Dog.create(id=3, name='Jean', age=3, owner='John')
+        Dog.create(id=4, name='Bart', age=6, owner='Carrie')
+
+        # Filter by Dog attributes
+        query = Dog.query.filter(owner='John').order_by('age')
+
+        # Result cache is empty to begin with
+        assert query._result_cache is None
+
+        # Total invokes an evaluation and a query
+        assert query.total == 2
+
+        # Result cache is now populated
+        assert query._result_cache is not None
+        assert query._result_cache.total == 2
+
+    def test_cache_reset(self):
+        """Test that results are cached after query is evaluated"""
+        # Add multiple entries to the DB
+        Dog.create(id=2, name='Murdock', age=7, owner='John')
+        Dog.create(id=3, name='Jean', age=3, owner='John')
+        Dog.create(id=4, name='Bart', age=6, owner='Carrie')
+
+        # Filter by Dog attributes
+        query = Dog.query.filter(owner='John').order_by('age')
+
+        # Total invokes an evaluation and a query
+        assert query.total == 2
+        assert query._result_cache.total == 2
+
+        query_dup = query.paginate(per_page=25)
+        assert query_dup._result_cache is None
+
     def test_total(self):
         """Test value of `total` results"""
         # Add multiple entries to the DB
@@ -534,6 +571,27 @@ class TestQuerySet:
         query = Dog.query.filter(owner='John').order_by('age')
         assert query.total == 2
 
+    def test_total_with_cache(self):
+        """Test value of `total` results without refresh"""
+        # Add multiple entries to the DB
+        Dog.create(id=2, name='Murdock', age=7, owner='John')
+        Dog.create(id=3, name='Jean', age=3, owner='John')
+        Dog.create(id=4, name='Bart', age=6, owner='Carrie')
+
+        # Filter by Dog attributes
+        query = Dog.query.filter(owner='John').order_by('age')
+        assert query.total == 2
+
+        Dog.create(id=5, name='Berry', age=1, owner='John')
+        assert query.total == 2
+        assert query._result_cache.total == 2
+
+        # Force a refresh
+        assert query.all().total == 3
+
+        # Result cache is now populated
+        assert query._result_cache.total == 3
+
     def test_items(self):
         """Test that items is retrieved from Pagination results"""
         # Add multiple entries to the DB
@@ -545,6 +603,22 @@ class TestQuerySet:
         query = Dog.query.filter(owner='John').order_by('age')
         assert query.items[0].id == query.all().items[0].id
 
+    def test_items_with_cache(self):
+        """Test that items is retrieved from Pagination results"""
+        # Add multiple entries to the DB
+        Dog.create(id=2, name='Murdock', age=7, owner='John')
+        Dog.create(id=3, name='Jean', age=3, owner='John')
+        Dog.create(id=4, name='Bart', age=6, owner='Carrie')
+
+        # Filter by Dog attributes
+        query = Dog.query.filter(owner='John').order_by('age')
+        assert query.items[0].id == 3
+
+        Dog.create(id=5, name='Berry', age=1, owner='John')
+        assert query.items[0].id == 3
+
+        assert query.all().items[0].id == 5
+
     def test_has_next(self):
         """Test if there are results after the current set"""
         # Add multiple entries to the DB
@@ -556,6 +630,22 @@ class TestQuerySet:
         query = Dog.query.paginate(page=1, per_page=2)
         assert query.has_next is True
 
+    def test_has_next_with_cache(self):
+        """Test if there are results after the current set"""
+        # Add multiple entries to the DB
+        Dog.create(id=2, name='Murdock', age=7, owner='John')
+        Dog.create(id=3, name='Jean', age=3, owner='John')
+        dog = Dog.create(id=4, name='Bart', age=6, owner='Carrie')
+
+        # Filter by Dog attributes
+        query = Dog.query.paginate(page=1, per_page=2)
+        assert query.has_next is True
+
+        dog.delete()
+
+        assert query.has_next is True
+        assert query.all().has_next is False
+
     def test_has_prev(self):
         """Test if there are results before the current set"""
         # Add multiple entries to the DB
@@ -567,6 +657,22 @@ class TestQuerySet:
         query = Dog.query.paginate(page=2, per_page=2)
         assert query.has_prev is True
 
+    def test_has_prev_with_cache(self):
+        """Test if there are results before the current set"""
+        # Add multiple entries to the DB
+        Dog.create(id=2, name='Murdock', age=7, owner='John')
+        Dog.create(id=3, name='Jean', age=3, owner='John')
+        dog = Dog.create(id=4, name='Bart', age=6, owner='Carrie')
+
+        # Filter by Dog attributes
+        query = Dog.query.paginate(page=2, per_page=2)
+        assert query.has_prev is True
+
+        dog.delete()
+
+        assert query.has_prev is True
+        assert query.all().has_prev is False
+
     def test_first(self):
         """Test that the first item is retrieved correctly from the resultset"""
         # Add multiple entries to the DB
@@ -577,3 +683,18 @@ class TestQuerySet:
         # Filter by Dog attributes
         query = Dog.query.order_by('-age')
         assert query.first.id == 2
+
+    def test_first_with_cache(self):
+        """Test that the first item is retrieved correctly from the resultset"""
+        # Add multiple entries to the DB
+        Dog.create(id=2, name='Murdock', age=7, owner='John')
+        Dog.create(id=3, name='Jean', age=3, owner='John')
+        Dog.create(id=4, name='Bart', age=6, owner='Carrie')
+
+        # Filter by Dog attributes
+        query = Dog.query.order_by('-age')
+        assert query.first.id == 2
+
+        Dog.create(id=5, name='Berry', age=8, owner='John')
+        assert query.first.id == 2
+        assert query.all().first.id == 5