added support for pagination
authorMichael Elsdörfer <michael@elsdoerfer.info>
Thu, 26 Jun 2008 17:15:56 +0000 (17:15 +0000)
committerMichael Elsdörfer <michael@elsdoerfer.info>
Thu, 26 Jun 2008 17:15:56 +0000 (17:15 +0000)
README
django_tables/models.py
django_tables/tables.py
tests/test_basic.py
tests/test_models.py

diff --git a/README b/README
index 9afb539461afe0f46c08233463f1ab45a9a5347f..f1f32abd89940e714e1daaa2c601025ec78d4461 100644 (file)
--- a/README
+++ b/README
@@ -311,18 +311,45 @@ static and ModelTables.
 Tables and Pagination\r
 ---------------------\r
 \r
-    table = MyTable(queryset)\r
-    p = Paginator(table.rows, 10)  # paginator will need to be able to handle our modelproxy\r
+If your table has a large number of rows, you probably want to paginate\r
+the output. There are two distinct approaches.\r
 \r
-    or\r
+First, you can just paginate over ``rows`` as you would do with any other\r
+data:\r
 \r
     table = MyTable(queryset)\r
-    table.pagination = Paginator(10, padding=2)\r
+    paginator = Paginator(table.rows, 10)\r
+    page = paginator.page(1)\r
 \r
-    or\r
+You're not necessarily restricted to Django's own paginator (or subclasses) -\r
+any paginator should work with this approach, so long it only requires\r
+``rows`` to implement ``len()``, slicing, and, in the case of ModelTables, a\r
+``count()`` method. The latter means that the ``QuerySetPaginator`` also\r
+works as expected.\r
 \r
-    table.paginate(DiggPaginator, 10, padding=2)\r
+Alternatively, you may use the ``paginate`` feature:\r
 \r
+    table = MyTable(queryset)\r
+    table.paginate(QuerySetPaginator, page=1, 10, padding=2)\r
+    for row in table.rows.page():\r
+        pass\r
+    table.paginator                # new attributes\r
+    table.page\r
+\r
+The table will automatically create an instance of ``QuerySetPaginator``,\r
+passing it's own data as the first argument and additionally any arguments\r
+you have specified, except for ``page``. You may use any paginator, as long\r
+as it follows the Django protocol:\r
+\r
+    * Take data as first argument.\r
+    * Support a page() method returning an object with an ``object_list``\r
+      attribute, exposing the paginated data.\r
+\r
+Note that due to the abstraction layer that django-tables represents, it is\r
+not necessary to use Django's QuerySetPaginator with ModelTables. Since the\r
+table knows that it holds a queryset, it will automatically choose to use\r
+count() to determine the data length (which is exactly what\r
+QuerySetPaginator would do).\r
 \r
 Ordering Syntax\r
 ---------------\r
@@ -400,4 +427,4 @@ TODO
       the template engine handle it\r
     - tests could use some refactoring, they are currently all over the place\r
     - what happens if duplicate column names are used? we currently don't\r
-      check for that at all.
\ No newline at end of file
+      check for that at all
\ No newline at end of file
index 23082a096399cc5acb91e56d46929458ab310765..9aea7353e7614f5a6c395e774ed3ca1e878de4ec 100644 (file)
@@ -1,6 +1,7 @@
 from django.core.exceptions import FieldError\r
 from django.utils.datastructures import SortedDict\r
-from tables import BaseTable, DeclarativeColumnsMetaclass, Column, BoundRow\r
+from tables import BaseTable, DeclarativeColumnsMetaclass, \\r
+    Column, BoundRow, Rows\r
 \r
 __all__ = ('BaseModelTable', 'ModelTable')\r
 \r
@@ -82,6 +83,7 @@ class BaseModelTable(BaseTable):
             self.queryset = data\r
 \r
         super(BaseModelTable, self).__init__(self.queryset, *args, **kwargs)\r
+        self._rows = ModelRows(self)\r
 \r
     def _validate_column_name(self, name, purpose):\r
         """Overridden. Only allow model-based fields and valid model\r
@@ -115,7 +117,7 @@ class BaseModelTable(BaseTable):
 \r
         # reset caches\r
         self._columns._reset()\r
-        self._rows = None\r
+        self._rows._reset()\r
 \r
         queryset = self.queryset\r
         if self.order_by:\r
@@ -129,6 +131,26 @@ class BaseModelTable(BaseTable):
 class ModelTable(BaseModelTable):\r
     __metaclass__ = ModelTableMetaclass\r
 \r
+class ModelRows(Rows):\r
+    def __init__(self, *args, **kwargs):\r
+        super(ModelRows, self).__init__(*args, **kwargs)\r
+        self.row_klass = BoundModelRow\r
+\r
+    def _reset(self):\r
+        self._length = None\r
+\r
+    def __len__(self):\r
+        """Use the queryset count() method to get the length, instead of\r
+        loading all results into memory. This allows, for example,\r
+        smart paginators that use len() to perform better.\r
+        """\r
+        if getattr(self, '_length', None) is None:\r
+            self._length = self.table.data.count()\r
+        return self._length\r
+\r
+    # for compatibility with QuerySetPaginator\r
+    count = __len__\r
+\r
 class BoundModelRow(BoundRow):\r
     """Special version of the BoundRow class that can handle model instances\r
     as data.\r
index 136fda225128f57d2ab39c3db23a5173aa4193ba..8fab3d7b5c851dd1cc08a5f1361dc2b44bb34f56 100644 (file)
@@ -120,7 +120,7 @@ class BaseTable(object):
         """\r
         self._data = data\r
         self._snapshot = None      # will store output dataset (ordered...)\r
-        self._rows = None          # will store BoundRow objects\r
+        self._rows = Rows(self)\r
         self._columns = Columns(self)\r
         self._order_by = order_by\r
 \r
@@ -147,7 +147,7 @@ class BaseTable(object):
 \r
         # reset caches\r
         self._columns._reset()\r
-        self._rows = None\r
+        self._rows._reset()\r
 \r
         snapshot = copy.copy(self._data)\r
         for row in snapshot:\r
@@ -257,12 +257,9 @@ class BaseTable(object):
             raise KeyError('Key %r not found in Table' % name)\r
         return BoundColumn(self, column, name)\r
 \r
-    columns = property(lambda s: s._columns)  # just to make it readonly\r
-\r
-    def _get_rows(self):\r
-        for row in self.data:\r
-            yield BoundRow(self, row)\r
-    rows = property(lambda s: s._get_rows())\r
+    # just to make those readonly\r
+    columns = property(lambda s: s._columns)\r
+    rows = property(lambda s: s._rows)\r
 \r
     def as_html(self):\r
         pass\r
@@ -278,6 +275,12 @@ class BaseTable(object):
         """\r
         self._build_snapshot()\r
 \r
+    def paginate(self, klass, *args, **kwargs):\r
+        page = kwargs.pop('page', 1)\r
+        self.paginator = klass(self.rows, *args, **kwargs)\r
+        self.page = self.paginator.page(page)\r
+\r
+\r
 class Table(BaseTable):\r
     "A collection of columns, plus their associated data rows."\r
     # This is a separate class from BaseTable in order to abstract the way\r
@@ -291,7 +294,7 @@ class Columns(object):
     This is bound to a table and provides it's ``columns`` property. It\r
     provides access to those columns in different ways (iterator,\r
     item-based, filtered and unfiltered etc)., stuff that would not be\r
-    possible with a simple iterator on the table class.\r
+    possible with a simple iterator in the table class.\r
 \r
     Note that when you define your column using a name override, e.g.\r
     ``author_name = tables.Column(name="author")``, then the column will\r
@@ -408,6 +411,46 @@ class BoundColumn(StrAndUnicode):
     def as_html(self):\r
         pass\r
 \r
+class Rows(object):\r
+    """Container for spawning BoundRows.\r
+\r
+    This is bound to a table and provides it's ``rows`` property. It\r
+    provides functionality that would not be possible with a simple\r
+    iterator in the table class.\r
+    """\r
+    def __init__(self, table, row_klass=None):\r
+        self.table = table\r
+        self.row_klass = row_klass and row_klass or BoundRow\r
+\r
+    def _reset(self):\r
+        pass   # we currently don't use a cache\r
+\r
+    def all(self):\r
+        """Return all rows."""\r
+        for row in self.table.data:\r
+            yield self.row_klass(self.table, row)\r
+\r
+    def page(self):\r
+        """Return rows on current page (if paginated)."""\r
+        if not hasattr(self.table, 'page'):\r
+            return None\r
+        return iter(self.table.page.object_list)\r
+\r
+    def __iter__(self):\r
+        return iter(self.all())\r
+\r
+    def __len__(self):\r
+        return len(self.table.data)\r
+\r
+    def __getitem__(self, key):\r
+        if isinstance(key, slice):\r
+            result = list()\r
+            for row in self.table.data[key]:\r
+                result.append(self.row_klass(self.table, row))\r
+            return result\r
+        else:\r
+            return self.row_klass(self, table, self.table.data[key])\r
+\r
 class BoundRow(object):\r
     """Represents a single row of data, bound to a table.\r
 \r
index 205e4978ac0fa48327e9e66a454be97ecb745fa4..cc6c33d9926ee8d9886ccaa0ca2c1519ef5585be 100644 (file)
@@ -5,6 +5,7 @@ This includes the core, as well as static data, non-model tables.
 \r
 from math import sqrt\r
 from py.test import raises\r
+from django.core.paginator import Paginator\r
 import django_tables as tables\r
 \r
 def test_declaration():\r
@@ -194,4 +195,32 @@ def test_callable():
 \r
     # data function is called while sorting\r
     math.order_by = ('sqrt',)\r
-    assert [row['sqrt'] for row in math] == [1,1,2]
\ No newline at end of file
+    assert [row['sqrt'] for row in math] == [1,1,2]\r
+\r
+def test_pagination():\r
+    class BookTable(tables.Table):\r
+        name = tables.Column()\r
+\r
+    # create some sample data\r
+    data = []\r
+    for i in range(1,101):\r
+        data.append({'name': 'Book Nr. %d'%i})\r
+    books = BookTable(data)\r
+\r
+    # external paginator\r
+    paginator = Paginator(books.rows, 10)\r
+    assert paginator.num_pages == 10\r
+    page = paginator.page(1)\r
+    assert len(page.object_list) == 10\r
+    assert page.has_previous() == False\r
+    assert page.has_next() == True\r
+\r
+    # integrated paginator\r
+    books.paginate(Paginator, 10, page=1)\r
+    # rows is now paginated\r
+    assert len(list(books.rows.page())) == 10\r
+    assert len(list(books.rows.all())) == 100\r
+    # new attributes\r
+    assert books.paginator.num_pages == 10\r
+    assert books.page.has_previous() == False\r
+    assert books.page.has_next() == True
\ No newline at end of file
index 528d921f79601409daae88142e76e1b27494432b..cb7c3a6bd62ba003247c7f638b48c3ee6777dd86 100644 (file)
@@ -5,6 +5,7 @@ Sets up a temporary Django project using a memory SQLite database.
 \r
 from py.test import raises\r
 from django.conf import settings\r
+from django.core.paginator import *\r
 import django_tables as tables\r
 \r
 def setup_module(module):\r
@@ -195,10 +196,6 @@ def test_sort():
     countries.order_by = ('custom1', 'custom2')\r
     assert countries.order_by == ()\r
 \r
-def test_pagination():\r
-    # TODO: support pagination\r
-    pass\r
-\r
 def test_callable():\r
     """Some of the callable code is reimplemented for modeltables, so\r
     test some specifics again.\r
@@ -262,4 +259,59 @@ def test_column_data():
     # neato trick: a callable data= column is sortable, if otherwise refers\r
     # to correct model column; can be used to rewrite what is displayed\r
     countries.order_by = 'name'\r
-    assert countries.order_by == ('name',)
\ No newline at end of file
+    assert countries.order_by == ('name',)\r
+\r
+def test_pagination():\r
+    """Pretty much the same as static table pagination, but make sure we\r
+    provide the capability, at least for paginators that use it, to not\r
+    have the complete queryset loaded (by use of a count() query).\r
+\r
+    Note: This test changes the available cities, make sure it is last,\r
+    or that tests that follow are written appropriately.\r
+    """\r
+    from django.db import connection\r
+\r
+    class CityTable(tables.ModelTable):\r
+        class Meta:\r
+            model = City\r
+            columns = ['name']\r
+    cities = CityTable()\r
+\r
+    # add some sample data\r
+    City.objects.all().delete()\r
+    for i in range(1,101):\r
+        City.objects.create(name="City %d"%i)\r
+\r
+    # for query logging\r
+    settings.DEBUG = True\r
+\r
+    # external paginator\r
+    start_querycount = len(connection.queries)\r
+    paginator = Paginator(cities.rows, 10)\r
+    assert paginator.num_pages == 10\r
+    page = paginator.page(1)\r
+    assert len(page.object_list) == 10\r
+    assert page.has_previous() == False\r
+    assert page.has_next() == True\r
+    # Make sure the queryset is not loaded completely - there must be two\r
+    # queries, one a count(). This check is far from foolproof...\r
+    assert len(connection.queries)-start_querycount == 2\r
+\r
+    # using a queryset paginator is possible as well (although unnecessary)\r
+    paginator = QuerySetPaginator(cities.rows, 10)\r
+    assert paginator.num_pages == 10\r
+\r
+    # integrated paginator\r
+    start_querycount = len(connection.queries)\r
+    cities.paginate(Paginator, 10, page=1)\r
+    # rows is now paginated\r
+    assert len(list(cities.rows.page())) == 10\r
+    assert len(list(cities.rows.all())) == 100\r
+    # new attributes\r
+    assert cities.paginator.num_pages == 10\r
+    assert cities.page.has_previous() == False\r
+    assert cities.page.has_next() == True\r
+    assert len(connection.queries)-start_querycount == 2\r
+\r
+    # reset\r
+    settings.DEBUG = False
\ No newline at end of file