From d18f225757b009d8311e0046fb828c1364486381 Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Fri, 26 Jun 2026 11:42:20 +0300 Subject: [PATCH] feat: pagination --- lnbits/core/extensions/api.py | 16 ++++++ lnbits/core/extensions/models.py | 27 ++++++++++ lnbits/core/extensions/prototype.py | 23 ++++++++ lnbits/core/extensions/storage.py | 83 +++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) diff --git a/lnbits/core/extensions/api.py b/lnbits/core/extensions/api.py index b6f708528..c7cdb798f 100644 --- a/lnbits/core/extensions/api.py +++ b/lnbits/core/extensions/api.py @@ -27,6 +27,8 @@ from .models import ( StorageGetResponse, StorageListRequest, StorageListResponse, + StoragePaginatedRequest, + StoragePaginatedResponse, StorageSetRequest, StorageSetResponse, WatchPaymentRequest, @@ -162,6 +164,20 @@ class ExtensionAPI: async def storage_list(self, request: StorageListRequest) -> StorageListResponse: self._raise_unwired_runtime("storage_list") + @extension_api_method( + method_id="storage.get_paginated", + namespace="storage", + name="Get paginated storage rows", + host_name="storage_get_paginated", + sdk_name="getPaginated", + description="Get filtered, searched, sorted, paginated storage rows.", + required_permission="ext.storage.read_write", + ) + async def storage_get_paginated( + self, request: StoragePaginatedRequest + ) -> StoragePaginatedResponse: + self._raise_unwired_runtime("storage_get_paginated") + @extension_api_method( method_id="storage.delete", namespace="storage", diff --git a/lnbits/core/extensions/models.py b/lnbits/core/extensions/models.py index d6cd24ad9..9d5abc575 100644 --- a/lnbits/core/extensions/models.py +++ b/lnbits/core/extensions/models.py @@ -51,6 +51,33 @@ class StorageListResponse(BaseModel): rows_json: str = "[]" +class StoragePaginatedRequest(BaseModel): + table: str = Field(..., min_length=1, max_length=128) + filters: dict[str, Any] = Field(default_factory=dict) + search: str | None = Field(None, max_length=256) + search_fields: list[str] = Field(default_factory=list) + sort_by: str | None = Field(None, min_length=1, max_length=128) + descending: bool = False + limit: int = Field(25, ge=1, le=1000) + offset: int = Field(0, ge=0) + + @root_validator(pre=True) + def parse_json_fields(cls, values: dict[str, Any]) -> dict[str, Any]: + filters_json = values.get("filters_json") + if filters_json is not None and "filters" not in values: + values["filters"] = json.loads(filters_json) + + search_fields_json = values.get("search_fields_json") + if search_fields_json is not None and "search_fields" not in values: + values["search_fields"] = json.loads(search_fields_json) + return values + + +class StoragePaginatedResponse(BaseModel): + rows_json: str = "[]" + total: int = 0 + + class StorageDeleteRequest(BaseModel): table: str = Field(..., min_length=1, max_length=128) id: str = Field(..., min_length=1, max_length=512) diff --git a/lnbits/core/extensions/prototype.py b/lnbits/core/extensions/prototype.py index a970452ea..cc1a93cc5 100644 --- a/lnbits/core/extensions/prototype.py +++ b/lnbits/core/extensions/prototype.py @@ -15,6 +15,8 @@ from .models import ( StorageGetResponse, StorageListRequest, StorageListResponse, + StoragePaginatedRequest, + StoragePaginatedResponse, StorageSetRequest, StorageSetResponse, UserWalletSummary, @@ -23,6 +25,7 @@ from .models import ( ) from .storage import ( storage_delete_row, + storage_get_paginated_rows, storage_get_row, storage_list_rows, storage_set_row, @@ -70,6 +73,26 @@ class InMemoryExtensionAPI(ExtensionAPI): ) return StorageListResponse(rows_json=json.dumps(rows)) + async def storage_get_paginated( + self, request: StoragePaginatedRequest + ) -> StoragePaginatedResponse: + self.require_permission("ext.storage.read_write") + page = await storage_get_paginated_rows( + self.extension_id, + request.table, + request.filters, + search=request.search, + search_fields=request.search_fields, + sort_by=request.sort_by, + descending=request.descending, + limit=request.limit, + offset=request.offset, + ) + return StoragePaginatedResponse( + rows_json=json.dumps(page["data"]), + total=page["total"], + ) + async def storage_delete( self, request: StorageDeleteRequest ) -> StorageDeleteResponse: diff --git a/lnbits/core/extensions/storage.py b/lnbits/core/extensions/storage.py index 5b17d9327..4eacf6b3f 100644 --- a/lnbits/core/extensions/storage.py +++ b/lnbits/core/extensions/storage.py @@ -86,6 +86,47 @@ async def storage_list_rows( return [_row_from_db(table_schema, row) for row in rows] +async def storage_get_paginated_rows( + ext_id: str, + table: str, + filters: dict[str, Any], + *, + search: str | None, + search_fields: list[str], + sort_by: str | None, + descending: bool, + limit: int, + offset: int, +) -> dict[str, Any]: + table_schema = _load_table_schema(ext_id, table) + where_sql, values = _where_sql(table_schema, filters, search, search_fields) + order_sql = _order_sql(table_schema, sort_by, descending) + count_values = dict(values) + values.update({"limit": min(limit, 1000), "offset": offset}) + + table_ref = _table_ref_for_schema(ext_id, table) + rows_query = f""" + SELECT * FROM {table_ref} + {where_sql} + {order_sql} + LIMIT :limit + OFFSET :offset + """ # noqa: S608 + count_query = f""" + SELECT COUNT(*) AS count FROM {table_ref} + {where_sql} + """ # noqa: S608 + + async with Database(f"ext_{ext_id}").connect() as conn: + rows = await conn.fetchall(rows_query, values) + count_row = await conn.fetchone(count_query, count_values) + + return { + "data": [_row_from_db(table_schema, row) for row in rows], + "total": int(count_row["count"]) if count_row else 0, + } + + async def storage_delete_row( ext_id: str, table: str, @@ -319,6 +360,48 @@ def _filters_to_db( } +def _where_sql( + table_schema: dict[str, Any], + filters: dict[str, Any], + search: str | None, + search_fields: list[str], +) -> tuple[str, dict[str, Any]]: + clean_filters = _filters_to_db(table_schema, filters) + clauses = [f"{field} = :filter_{field}" for field in clean_filters] + values = {f"filter_{field}": value for field, value in clean_filters.items()} + + clean_search = search.strip().lower() if search else "" + if clean_search: + fields = _fields_by_name(table_schema) + invalid_fields = sorted(set(search_fields) - set(fields)) + if invalid_fields: + raise ValueError( + "WASM storage search has unknown fields: " + ", ".join(invalid_fields) + ) + if search_fields: + search_clause = " OR ".join( + f"LOWER(CAST({field} AS TEXT)) LIKE :search" for field in search_fields + ) + clauses.append(f"({search_clause})") + values["search"] = f"%{clean_search}%" + + return ("WHERE " + " AND ".join(clauses), values) if clauses else ("", values) + + +def _order_sql( + table_schema: dict[str, Any], + sort_by: str | None, + descending: bool, +) -> str: + if not sort_by: + return "" + fields = _fields_by_name(table_schema) + if sort_by not in fields: + raise ValueError(f"WASM storage sort field is unknown: {sort_by}") + direction = "DESC" if descending else "ASC" + return f"ORDER BY {sort_by} {direction}" + + def _row_from_db( table_schema: dict[str, Any], row: dict[str, Any],