Skip to content

Pagination

edgy offers built-in support for both counter-based and cursor-based high-performance pagination. You can also set extra attributes to make all items behave like a double-linked list.

High-performance means smart caching is used, so if you reuse the paginator, you might even skip database access. This works because it reuses the order of the QuerySet, which may already have the entire query cached.

However, edgy is not as flexible as Django's Paginator. It only accepts QuerySets.

Counter-based

This is the classic way of pagination. You provide a page number, and it returns a specific set of items based on the order of the QuerySet.

import edgy
from edgy.contrib.pagination import Paginator, Page
from edgy import Registry

models = Registry(database="sqlite:///db.sqlite")


class BlogEntry(edgy.Model):
    title: str = edgy.fields.CharField(max_length=100)
    content: str = edgy.fields.CharField(max_length=100)
    created = edgy.fields.DateTimeField(auto_now_add=True)

    class Meta:
        registry = models


async def get_last_blogpost_page() -> tuple[Page, int]:
    # order by is required for paginators
    paginator = Paginator(BlogEntry.query.order_by("-created", "-id"), page_size=30)
    return await paginator.get_page(-1), await paginator.get_amount_pages()


async def get_blogpost_pages() -> tuple[Page, int]:
    # order by is required for paginators
    paginator = Paginator(BlogEntry.query.order_by("-created", "-id"), page_size=30)
    return [page async for page in paginator.paginate()]


async def search_blogpost(title: str, page: int) -> tuple[Page, int]:
    # order by is required for paginators
    paginator = Paginator(
        BlogEntry.query.filter(title__icontains=title).order_by("-created", "-id"), page_size=30
    )
    return await paginator.get_page(page), await paginator.get_amount_pages()

You can also use attributes to get the previous or next item. For better performance, we use the CursorPaginator:

from __future__ import annotations
import edgy
from edgy.contrib.pagination import CursorPaginator
from edgy import Registry

models = Registry(database="sqlite:///db.sqlite")


class BlogEntry(edgy.Model):
    title: str = edgy.fields.CharField(max_length=100)
    content: str = edgy.fields.CharField(max_length=100)
    created = edgy.fields.DateTimeField(auto_now_add=True)

    class Meta:
        registry = models


async def get_blogpost(id: int) -> BlogEntry | None:
    query = BlogEntry.query.order_by("-created", "-id")
    # order by is required for paginators
    paginator = CursorPaginator(
        query,
        page_size=1,
        next_item_attr="next_blogpost",
        previous_item_attr="last_blogpost",
    )
    try:
        created = (await query.get(id=id)).created
        # cursor must match order_by order
        page = await paginator.get_page(cursor=(created, id))
        if page.content:
            return page.content[0]
    except edgy.ObjectNotFound:
        ...
    # get first blogpost as fallback
    fallback_page = await paginator.get_page()
    if fallback_page.content:
        return fallback_page.content[0]
    return None

This example would be in the slow variant:

from __future__ import annotations
import edgy
from edgy.contrib.pagination import Paginator, CursorPaginator
from edgy import Registry

models = Registry(database="sqlite:///db.sqlite")


class BlogEntry(edgy.Model):
    title: str = edgy.fields.CharField(max_length=100)
    content: str = edgy.fields.CharField(max_length=100)
    created = edgy.fields.DateTimeField(auto_now_add=True)

    class Meta:
        registry = models


async def get_blogpost(id: int) -> BlogEntry | None:
    query = BlogEntry.query.order_by("-created", "-id")
    # order by is required for paginators
    paginator = Paginator(
        query,
        page_size=30,
        next_item_attr="next_blogpost",
        previous_item_attr="last_blogpost",
    )
    # this is less performant than the cursor variant
    async for page in paginator.paginate():
        for blogpost in page:
            if blogpost.id == id:
                return blogpost
    # get first blogpost as fallback
    fallback_page = await paginator.get_page()
    if fallback_page.content:
        return fallback_page.content[0]
    return None

Note

If you use a StrictModel make sure you have placeholders in place.

Cursor-based

This pagination works like the counter-based one but supports only one column: It is used as a cursor. This is more efficient and allows querying for new contents, in case of sequential cursors.

from __future__ import annotations

import datetime
import edgy
from edgy.contrib.pagination import CursorPaginator
from edgy import Registry

models = Registry(database="sqlite:///db.sqlite")


class BlogEntry(edgy.Model):
    title: str = edgy.fields.CharField(max_length=100)
    content: str = edgy.fields.CharField(max_length=100)
    created = edgy.fields.DateTimeField(auto_now_add=True)

    class Meta:
        registry = models


async def get_blogpost(cursor: datetime.datetime) -> BlogEntry | None:
    # order by is required for paginators
    paginator = CursorPaginator(
        BlogEntry.query.order_by("-created"),
        page_size=1,
        next_item_attr="next_blogpost",
        previous_item_attr="last_blogpost",
    )
    page = await paginator.get_page(cursor)
    if page.content:
        return page.content[0]
    return None


async def get_next_blogpost_page(cursor: datetime.datetime):
    # order by is required for paginators
    paginator = CursorPaginator(BlogEntry.query.order_by("-created"), page_size=30)
    return await paginator.get_page(cursor), await paginator.get_amount_pages()


async def get_last_blogpost_page(cursor: datetime.datetime):
    # order by is required for paginators
    paginator = CursorPaginator(BlogEntry.query.order_by("-created"), page_size=30)
    return await paginator.get_page(cursor, backward=True), await paginator.get_amount_pages()


async def get_blogpost_pages(after: datetime.datetime | None = None):
    # order by is required for paginators
    paginator = CursorPaginator(BlogEntry.query.order_by("-created"), page_size=30)
    return [page async for page in paginator.paginate(start_cursor=after)]


async def search_blogpost(title: str, cursor: datetime.datetime | None = None):
    # order by is required for paginators
    paginator = CursorPaginator(
        BlogEntry.query.filter(title__icontains=title).order_by("-created"), page_size=30
    )
    return await paginator.get_page(cursor), await paginator.get_amount_pages()

Because you can have vectors as cursors, you can also use this paginator to calculate efficiently the partners for a single item like shown above:

from __future__ import annotations
import edgy
from edgy.contrib.pagination import CursorPaginator
from edgy import Registry

models = Registry(database="sqlite:///db.sqlite")


class BlogEntry(edgy.Model):
    title: str = edgy.fields.CharField(max_length=100)
    content: str = edgy.fields.CharField(max_length=100)
    created = edgy.fields.DateTimeField(auto_now_add=True)

    class Meta:
        registry = models


async def get_blogpost(id: int) -> BlogEntry | None:
    query = BlogEntry.query.order_by("-created", "-id")
    # order by is required for paginators
    paginator = CursorPaginator(
        query,
        page_size=1,
        next_item_attr="next_blogpost",
        previous_item_attr="last_blogpost",
    )
    try:
        created = (await query.get(id=id)).created
        # cursor must match order_by order
        page = await paginator.get_page(cursor=(created, id))
        if page.content:
            return page.content[0]
    except edgy.ObjectNotFound:
        ...
    # get first blogpost as fallback
    fallback_page = await paginator.get_page()
    if fallback_page.content:
        return fallback_page.content[0]
    return None

Integration

How would an application look like, using this feature?

Here an example for esmerald with cursors and attributes:

from typing import Optional
from esmerald import Esmerald, Gateway, get, post
from pydantic import BaseModel

import edgy
from edgy.testing.factory import ModelFactory
from edgy.contrib.pagination import CursorPaginator
from edgy.core.marshalls import Marshall
from edgy.core.marshalls.config import ConfigMarshall

models = edgy.Registry(database="DATABASE_URL")


class BlogEntryBase(edgy.Model):
    # explicit required, otherwise the id is not found because the model is abstract
    id: int = edgy.fields.BigIntegerField(autoincrement=True, primary_key=True)
    title: str = edgy.fields.CharField(max_length=100)
    content: str = edgy.fields.TextField()

    class Meta:
        abstract = True


class BlogEntry(BlogEntryBase):
    # get rid of nested next and last when serializing otherwise we have loops
    next: Optional["BlogEntryBase"] = None
    last: Optional["BlogEntryBase"] = None

    class Meta:
        registry = models


class BlogEntryMarshall(Marshall):
    marshall_config = ConfigMarshall(model=BlogEntry, exclude=["next", "last"])


class BlogEntryFactory(ModelFactory):
    class Meta:
        model = BlogEntry


class BlogPage(BaseModel):
    content: list[BlogEntry]
    is_first: bool
    is_last: bool
    current_cursor: Optional[int]
    next_cursor: Optional[int]
    pages: int


@get("/blog/item/{id}")
async def get_blogpost(id: int) -> Optional["BlogEntry"]:
    # order by is required for paginators
    paginator = CursorPaginator(
        BlogEntry.query.order_by("-id"),
        page_size=1,
        next_item_attr="next",
        previous_item_attr="last",
    )
    page = await paginator.get_page(id)
    if page.content:
        return page.content[0]
    return None


@get("/")
async def index() -> BlogPage:
    # order by is required for paginators
    paginator = CursorPaginator(
        BlogEntry.query.order_by("-id"),
        page_size=30,
        next_item_attr="next",
        previous_item_attr="last",
    )
    p, amount = await paginator.get_page(), await paginator.get_amount_pages()
    # model_dump would also serialize the BlogEntries, so use __dict__ which should be also faster
    return BlogPage(**p.__dict__, pages=amount)


@get("/blog/nextpage/{advance_cursor}")
async def get_next_blogpost_page(advance_cursor: int) -> BlogPage:
    # order by is required for paginators
    paginator = CursorPaginator(
        BlogEntry.query.order_by("-id"),
        page_size=30,
        next_item_attr="next",
        previous_item_attr="last",
    )
    p, amount = await paginator.get_page(advance_cursor), await paginator.get_amount_pages()
    # model_dump would also serialize the BlogEntries, so use __dict__ which should be also faster
    return BlogPage(**p.__dict__, pages=amount)


@get("/blog/lastpage/{reverse_cursor}")
async def get_last_blogpost_page(reverse_cursor: int) -> BlogPage:
    # order by is required for paginators
    paginator = CursorPaginator(
        BlogEntry.query.order_by("-id"),
        page_size=30,
        next_item_attr="next",
        previous_item_attr="last",
    )
    p, amount = (
        await paginator.get_page(reverse_cursor, backward=True),
        await paginator.get_amount_pages(),
    )
    # model_dump would also serialize the BlogEntries, so use __dict__ which should be also faster
    return BlogPage(**p.__dict__, pages=amount)


@post("/search")
async def search_blogpost(string: str, cursor: Optional[int] = None) -> BlogPage:
    # order by is required for paginators
    paginator = CursorPaginator(
        BlogEntry.query.or_({"title__icontains": string}, {"content__icontains": string}).order_by(
            "-id"
        ),
        page_size=30,
        next_item_attr="next",
        previous_item_attr="last",
    )
    p, amount = await paginator.get_page(cursor), await paginator.get_amount_pages()
    # model_dump would also serialize the BlogEntries, so use __dict__ which should be also faster
    return BlogPage(**p.__dict__, pages=amount)


@post("/create")
async def create_blog_entry(data: BlogEntryMarshall) -> BlogEntryMarshall:
    await data.save()
    return data


def get_application():
    app = Esmerald(
        routes=[
            Gateway(handler=create_blog_entry),
            index,
            get_blogpost,
            search_blogpost,
            get_last_blogpost_page,
            get_next_blogpost_page,
        ],
        on_startup=[models.__aenter__],
        on_shutdown=[models.__aexit__],
    )
    return app

Special features

Single-page mode (linked lists)

If you set the page_size to 0, all items are displayed on one page. This transforms the QuerySet into a linked list, where each item knows its neighbors.

The CursorPaginator works a bit different: it shows only one page, but you can still pass cursors to limit the range.

from __future__ import annotations
import datetime
import edgy
from edgy.contrib.pagination import Paginator, CursorPaginator
from edgy import Registry

models = Registry(database="sqlite:///db.sqlite")


class BlogEntry(edgy.Model):
    title: str = edgy.fields.CharField(max_length=100)
    content: str = edgy.fields.CharField(max_length=100)
    created = edgy.fields.DateTimeField(auto_now_add=True)

    class Meta:
        registry = models


async def get_blogposts_with_partners() -> list[BlogEntry]:
    # order by is required for paginators
    paginator = Paginator(
        BlogEntry.query.order_by("-created", "-id"),
        page_size=0,
        next_item_attr="next_blogpost",
        previous_item_attr="last_blogpost",
    )
    return (await paginator.get_page()).content


async def get_blogposts_with_partners_after(after: datetime.datetime) -> list[BlogEntry]:
    # order by is required for paginators
    paginator = CursorPaginator(
        BlogEntry.query.order_by("-created"),
        page_size=0,
        next_item_attr="next_blogpost",
        previous_item_attr="last_blogpost",
    )
    return (await paginator.get_page(after)).content

Reversing

Every paginator has a get_reverse_paginator() method which returns a cached paginator which contains a reversed QuerySet of the current paginator (the order is reversed).

Cache management

Sometimes you need to clear the cache to get fresh results. For this the paginator provides the clear_caches() method.