Skip to content

Commit 68a1eca

Browse files
authored
Merge pull request #1 from sinisaos/cursor_pagination
add cursor pagination
2 parents 10fa39c + 233f0d4 commit 68a1eca

34 files changed

+1028
-2
lines changed

.github/workflows/release.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Release
2+
3+
on:
4+
release:
5+
types:
6+
- created
7+
8+
jobs:
9+
release:
10+
name: "Publish release"
11+
runs-on: "ubuntu-latest"
12+
13+
steps:
14+
- uses: "actions/checkout@v2"
15+
- uses: "actions/setup-python@v1"
16+
with:
17+
python-version: 3.9
18+
- name: "Install dependencies"
19+
run: "pip install -r requirements/dev-requirements.txt"
20+
- name: "Publish to PyPI"
21+
run: "./scripts/release.sh"
22+
env:
23+
TWINE_USERNAME: __token__
24+
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}

README.md

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,114 @@
1-
# piccolo_cursor_pagination
2-
Cursor pagination for Piccolo ORM
1+
## Cursor pagination for Piccolo ORM
2+
3+
[Piccolo](https://github.com/piccolo-orm) is an great ecosystem that helps you create [ASGI](https://asgi.readthedocs.io/en/latest/) apps faster and easier. [LimitOffset](https://piccolo-api.readthedocs.io/en/latest/crud/piccolo_crud.html#pagination) is the default Piccolo pagination used by Piccolo Admin and Piccolo API. This package contains usage of cursor pagination which is suitable for large data sets and has better performance than ``LimitOffset`` pagination,
4+
but it is **strictly optional** because it does not work with the Piccolo Admin and Piccolo API.
5+
6+
# Installation
7+
8+
```bash
9+
pip install piccolo_cursor_pagination
10+
```
11+
# Usage
12+
13+
Example usage of ``CursorPagination``:
14+
15+
```python
16+
import typing as t
17+
18+
from fastapi import FastAPI, Request
19+
from fastapi.responses import JSONResponse
20+
from piccolo_api.crud.serializers import create_pydantic_model
21+
from piccolo_cursor_pagination.pagination import CursorPagination
22+
23+
from home.tables import Task
24+
25+
app = FastAPI()
26+
27+
TaskModelOut: t.Any = create_pydantic_model(
28+
table=Task, include_default_columns=True, model_name="TaskModelOut"
29+
)
30+
31+
@app.get("/tasks/", response_model=t.List[TaskModelOut])
32+
async def tasks(
33+
request: Request,
34+
__cursor: str,
35+
__previous: t.Optional[str] = None,
36+
):
37+
try:
38+
cursor = request.query_params["__cursor"]
39+
previous = request.query_params["__previous"]
40+
paginator = CursorPagination(cursor=cursor)
41+
rows_result, headers_result = paginator.get_cursor_rows(Task, request)
42+
rows = await rows_result.run()
43+
headers = headers_result
44+
response = JSONResponse(
45+
{"rows": rows[::-1]},
46+
headers={
47+
"next_cursor": headers["cursor"],
48+
},
49+
)
50+
except KeyError:
51+
cursor = request.query_params["__cursor"]
52+
paginator = CursorPagination(cursor=cursor)
53+
rows_result, headers_result = paginator.get_cursor_rows(Task, request)
54+
rows = await rows_result.run()
55+
headers = headers_result
56+
response = JSONResponse(
57+
{"rows": rows},
58+
headers={
59+
"next_cursor": headers["cursor"],
60+
},
61+
)
62+
return response
63+
64+
@app.on_event("startup")
65+
async def open_database_connection_pool():
66+
try:
67+
engine = engine_finder()
68+
await engine.start_connection_pool()
69+
except Exception:
70+
print("Unable to connect to the database")
71+
72+
73+
@app.on_event("shutdown")
74+
async def close_database_connection_pool():
75+
try:
76+
engine = engine_finder()
77+
await engine.close_connection_pool()
78+
except Exception:
79+
print("Unable to connect to the database")
80+
```
81+
The ``CursorPagination`` stores the value of ``next_cursor`` in the response headers.
82+
We can then use the ``next_cursor`` value to get new set of results by passing
83+
``next_cursor`` to ``__cursor`` query parameter.
84+
85+
Full Piccolo ASGI app is in **example** folder.
86+
87+
# Customization
88+
89+
The ``CursorPagination`` class has a default value of ``page_size`` and ``order_by``,
90+
but we can overide this value in constructor to adjust the way the results are displayed.
91+
92+
Example of displaying results in ascending order and page size is 10:
93+
94+
```python
95+
paginator = CursorPagination(cursor=cursor, page_size=10, order_by="id")
96+
```
97+
98+
# Directions
99+
100+
The ``CursorPagination`` has the ability to move forward and backward.
101+
To go backward we have to pass ``__previous=yes`` in the query parameters.
102+
103+
Example usage of direction:
104+
105+
```
106+
GET http://localhost:8000/tasks/?__cursor=NA== (forward)
107+
GET http://localhost:8000/tasks/?__cursor=NA==&__previous=yes (backward)
108+
```
109+
110+
# Limitations
111+
112+
You need to be aware that cursor pagination has several trade offs. The cursor must be based on a unique and sequential column in the table and the client can't go to a specific page because there is no concept of the total number of pages or results.
113+
114+
> **WARNING**: ``CursorPagination`` use Piccolo ORM ``id`` (PK type **integer**) column as unique and sequential column for pagination.

example/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# piccolo_project
2+
3+
## Setup
4+
5+
### Install requirements
6+
7+
```bash
8+
pip install -r requirements.txt
9+
```
10+
11+
### Getting started guide
12+
13+
```bash
14+
python main.py
15+
```
16+
17+
### Running tests
18+
19+
```bash
20+
piccolo tester run
21+
```

example/__init__.py

Whitespace-only changes.

example/app.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import typing as t
2+
3+
from fastapi import FastAPI, Request
4+
from fastapi.responses import JSONResponse
5+
from piccolo_admin.endpoints import create_admin
6+
from piccolo_api.crud.serializers import create_pydantic_model
7+
from piccolo_cursor_pagination.pagination import CursorPagination
8+
from piccolo.engine import engine_finder
9+
from starlette.routing import Route, Mount
10+
from starlette.staticfiles import StaticFiles
11+
12+
from home.endpoints import HomeEndpoint
13+
from home.piccolo_app import APP_CONFIG
14+
from home.tables import Task
15+
16+
17+
app = FastAPI(
18+
routes=[
19+
Route("/", HomeEndpoint),
20+
Mount(
21+
"/admin/",
22+
create_admin(
23+
tables=APP_CONFIG.table_classes,
24+
# Required when running under HTTPS:
25+
# allowed_hosts=['my_site.com']
26+
),
27+
),
28+
Mount("/static/", StaticFiles(directory="static")),
29+
],
30+
)
31+
32+
33+
TaskModelIn: t.Any = create_pydantic_model(
34+
table=Task, model_name="TaskModelIn"
35+
)
36+
TaskModelOut: t.Any = create_pydantic_model(
37+
table=Task, include_default_columns=True, model_name="TaskModelOut"
38+
)
39+
40+
41+
@app.get("/tasks/", response_model=t.List[TaskModelOut])
42+
async def tasks(
43+
request: Request,
44+
__cursor: str,
45+
__previous: t.Optional[str] = None,
46+
):
47+
try:
48+
cursor = request.query_params["__cursor"]
49+
previous = request.query_params["__previous"]
50+
paginator = CursorPagination(cursor=cursor, page_size=1, order_by="id")
51+
rows_result, headers_result = paginator.get_cursor_rows(Task, request)
52+
rows = await rows_result.run()
53+
headers = headers_result
54+
response = JSONResponse(
55+
{"rows": rows[::-1]},
56+
headers={
57+
"next_cursor": headers["cursor"],
58+
},
59+
)
60+
except KeyError:
61+
cursor = request.query_params["__cursor"]
62+
paginator = CursorPagination(cursor=cursor, page_size=1, order_by="id")
63+
rows_result, headers_result = paginator.get_cursor_rows(Task, request)
64+
rows = await rows_result.run()
65+
headers = headers_result
66+
response = JSONResponse(
67+
{"rows": rows},
68+
headers={
69+
"next_cursor": headers["cursor"],
70+
},
71+
)
72+
return response
73+
74+
75+
@app.post("/tasks/", response_model=TaskModelOut)
76+
async def create_task(task_model: TaskModelIn):
77+
task = Task(**task_model.dict())
78+
await task.save()
79+
return task.to_dict()
80+
81+
82+
@app.put("/tasks/{task_id}/", response_model=TaskModelOut)
83+
async def update_task(task_id: int, task_model: TaskModelIn):
84+
task = await Task.objects().get(Task.id == task_id)
85+
if not task:
86+
return JSONResponse({}, status_code=404)
87+
88+
for key, value in task_model.dict().items():
89+
setattr(task, key, value)
90+
91+
await task.save()
92+
93+
return task.to_dict()
94+
95+
96+
@app.delete("/tasks/{task_id}/")
97+
async def delete_task(task_id: int):
98+
task = await Task.objects().get(Task.id == task_id)
99+
if not task:
100+
return JSONResponse({}, status_code=404)
101+
102+
await task.remove()
103+
104+
return JSONResponse({})
105+
106+
107+
@app.on_event("startup")
108+
async def open_database_connection_pool():
109+
try:
110+
engine = engine_finder()
111+
await engine.start_connection_pool()
112+
except Exception:
113+
print("Unable to connect to the database")
114+
115+
116+
@app.on_event("shutdown")
117+
async def close_database_connection_pool():
118+
try:
119+
engine = engine_finder()
120+
await engine.close_connection_pool()
121+
except Exception:
122+
print("Unable to connect to the database")

example/conftest.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import os
2+
import sys
3+
4+
from piccolo.utils.warnings import colored_warning
5+
6+
7+
def pytest_configure(*args):
8+
if os.environ.get("PICCOLO_TEST_RUNNER") != "True":
9+
colored_warning(
10+
"\n\n"
11+
"We recommend running Piccolo tests using the "
12+
"`piccolo tester run` command, which wraps Pytest, and makes "
13+
"sure the test database is being used. "
14+
"To stop this warning, modify conftest.py."
15+
"\n\n"
16+
)
17+
sys.exit(1)

example/home/__init__.py

Whitespace-only changes.

example/home/endpoints.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import os
2+
3+
import jinja2
4+
from starlette.endpoints import HTTPEndpoint
5+
from starlette.responses import HTMLResponse
6+
7+
8+
ENVIRONMENT = jinja2.Environment(
9+
loader=jinja2.FileSystemLoader(
10+
searchpath=os.path.join(os.path.dirname(__file__), "templates")
11+
)
12+
)
13+
14+
15+
class HomeEndpoint(HTTPEndpoint):
16+
async def get(self, request):
17+
template = ENVIRONMENT.get_template("home.html.jinja")
18+
19+
content = template.render(
20+
title="Piccolo + ASGI",
21+
)
22+
23+
return HTMLResponse(content)

example/home/piccolo_app.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""
2+
Import all of the Tables subclasses in your app here, and register them with
3+
the APP_CONFIG.
4+
"""
5+
6+
import os
7+
8+
from piccolo.conf.apps import AppConfig, table_finder
9+
10+
11+
CURRENT_DIRECTORY = os.path.dirname(os.path.abspath(__file__))
12+
13+
14+
APP_CONFIG = AppConfig(
15+
app_name="home",
16+
migrations_folder_path=os.path.join(CURRENT_DIRECTORY, "piccolo_migrations"),
17+
table_classes=table_finder(modules=["home.tables"], exclude_imported=True),
18+
migration_dependencies=[],
19+
commands=[],
20+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add migrations using `piccolo migrations new home --auto`.

0 commit comments

Comments
 (0)