Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/pagination.py: 0%
55 statements
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-16 22:16 +0000
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-16 22:16 +0000
1from hashlib import sha256
2import typing as t
4from viur.core import db, utils
7class Pagination:
8 """
9 This module provides efficient pagination for a small specified set of queries.
10 The datastore does not provide an efficient method for skipping N number of entities. This prevents
11 the usual navigation over multiple pages (in most cases - like a google search - the user expects a list
12 of pages (e.g. 1-10) on the bottom of each page with direct access to these pages). With the datastore and it's
13 cursors, the most we can provide is a next-page & previous-page link using cursors. This module provides an
14 efficient method to provide these direct-access page links under the condition that only a few, known-in-advance
15 queries will be run. This is typically the case for forums, where there is only one query per thread (it's posts
16 ordered by creation date) and one for the threadlist (it's threads, ordered by changedate).
18 To use this module, create an instance of this index-manager on class-level (setting page_size & max_pages).
19 Then call :meth:get_pages with the query you want to retrieve the cursors for the individual pages for. This
20 will return one start-cursor per available page that can then be used to create urls that point to the specific
21 page. When the entities returned by the query change (eg a new post is added), call :meth:refresh_index for
22 each affected query.
24 .. Note::
26 The refreshAll Method is missing - intentionally. Whenever data changes you have to call
27 refresh_index for each affected Index. As long as you can name them, their number is
28 limited and this module can be efficiently used.
29 """
31 _db_type = "viur_pagination"
33 def __init__(self, page_size: int = 10, max_pages: int = 100):
34 """
35 :param page_size: How many entities shall fit on one page
36 :param max_pages: How many pages are build.
37 Items become unreachable if the amount of items exceeds
38 page_size*max_pages (i.e. if a forum-thread has more than
39 page_size*max_pages Posts, Posts after that barrier won't show up).
40 """
41 self.page_size = page_size
42 self.max_pages = max_pages
44 def key_from_query(self, query: db.Query) -> str:
45 """
46 Derives a unique Database-Key from a given query.
47 This Key is stable regardless in which order the filter have been applied
49 :param query: Query to derive key from
50 :returns: The unique key derived
51 """
52 if not isinstance(query, db.Query):
53 raise TypeError(
54 f"Expected a query. Got {query!r} of type {type(query)!r}")
55 if isinstance(query.queries, list):
56 raise NotImplementedError # TODO: Can we handle this case? How?
57 elif query.queries is None:
58 raise ValueError("The query has no queries!")
59 orig_filter = [(x, y) for x, y in query.queries.filters.items()]
60 for field, sort_order in query.queries.orders:
61 orig_filter.append((f"{field} =", sort_order))
62 if query.queries.limit:
63 orig_filter.append(("__pagesize =", self.page_size))
64 orig_filter.sort(key=lambda sx: sx[0])
65 filter_key = "".join(f"{x}{y}" for x, y in orig_filter)
66 return sha256(filter_key.encode()).hexdigest()
68 def get_or_build_index(self, orig_query: db.Query) -> list[str]:
69 """
70 Builds a specific index based on origQuery
71 AND local variables (self.page_size and self.max_pages)
72 Returns a list of starting-cursors for each page.
73 You probably shouldn't call this directly. Use cursor_for_query.
75 :param orig_query: Query to build the index for
76 """
77 key = self.key_from_query(orig_query)
79 # We don't have it cached - try to load it from DB
80 index = db.Get(db.Key(self._db_type, key))
81 if index is not None:
82 return index["data"]
84 # We don't have this index yet... Build it
85 query = orig_query.clone()
86 cursors = [None]
87 while len(cursors) < self.max_pages:
88 query_res = query.run(limit=self.page_size)
89 if not query_res:
90 # This cursor returns no data, remove it
91 cursors.pop()
92 break
93 if query.getCursor() is None:
94 # We reached the end of our data
95 break
96 cursors.append(query.getCursor())
97 query.setCursor(query.getCursor())
99 entry = db.Entity(db.Key(self._db_type, key))
100 entry["data"] = cursors
101 entry["creationdate"] = utils.utcNow()
102 db.Put(entry)
103 return cursors
105 def cursor_for_query(self, query: db.Query, page: int) -> t.Optional[str]:
106 """
107 Returns the starting-cursor for the given query and page using an index.
109 .. WARNING:
111 Make sure the maximum count of different queries are limited!
112 If an attacker can choose the query freely, he can consume a lot
113 datastore quota per request!
115 :param query: Query to get the cursor for
116 :param page: Page the user wants to retrieve
117 :returns: Cursor or None if no cursor is applicable
118 """
119 page = int(page)
120 pages = self.get_or_build_index(query)
121 if 0 <= page < len(pages):
122 return pages[page]
123 else:
124 return None
126 def get_pages(self, query: db.Query) -> list[str]:
127 """
128 Returns a list of all starting-cursors for this query.
129 The first element is always None as the first page doesn't
130 have any start-cursor
131 """
132 return self.get_or_build_index(query)
134 def refresh_index(self, query: db.Query) -> None:
135 """
136 Refreshes the Index for the given query
137 (Actually it removes it from the db, so it gets rebuild on next use)
139 :param query: Query for which the index should be refreshed
140 """
141 key = self.key_from_query(query)
142 db.Delete(db.Key(self._db_type, key))