From 7223f9c1fee2160f677e8c7ae3adc84d3b7e3c98 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Tue, 29 Sep 2026 06:48:43 +0000 Subject: [PATCH] feat(cache): add opt-in sort_query to merge reordered query strings With @cache(sort_query=True) the default key builder stable-sorts the query parameters by decoded name, so ?a=1&b=2 and ?b=2&a=1 share one entry while repeated values of one name keep their order. Names and values are encoded as in the unsorted key; the default False leaves every key unchanged. build_cache_key() and invalidate() take the same keyword, and combining the flag with a custom key_builder raises CacheXError. --- changelog.d/267.added.md | 9 ++ docs/HTTP_CACHING.md | 39 ++++- fastapi_cachex/cache.py | 99 +++++++++++-- i18n/zh-TW/docs/HTTP_CACHING.md | 15 +- tests/test_cache_sort_query.py | 247 ++++++++++++++++++++++++++++++++ 5 files changed, 389 insertions(+), 20 deletions(-) create mode 100644 changelog.d/267.added.md create mode 100644 tests/test_cache_sort_query.py diff --git a/changelog.d/267.added.md b/changelog.d/267.added.md new file mode 100644 index 0000000..ae9c697 --- /dev/null +++ b/changelog.d/267.added.md @@ -0,0 +1,9 @@ +**Add `@cache(sort_query=True)` so reordered query strings share one entry.** +The default key builder then orders the query parameters by name, so +`?a=1&b=2` and `?b=2&a=1` hit the same entry. The sort is stable: repeated +values of one name keep the order the client sent, so `?tag=b&tag=a` and +`?tag=a&tag=b` stay distinct, and names and values are encoded exactly as in +the unsorted key. The default `False` leaves every existing key unchanged. +Combining it with a custom `key_builder` raises `CacheXError`; such a builder +can call `build_cache_key(request, sort_query=True)` instead. +`invalidate()` takes the same `sort_query` keyword. diff --git a/docs/HTTP_CACHING.md b/docs/HTTP_CACHING.md index c347181..c1df47e 100644 --- a/docs/HTTP_CACHING.md +++ b/docs/HTTP_CACHING.md @@ -280,8 +280,33 @@ This ensures that: - Different query parameters get separate cache entries - The same endpoint with different parameters can be cached independently -Query parameters are taken in the order the client sent them, without sorting, so -`?a=1&b=2` and `?b=2&a=1` are two distinct cache entries for the same logical request. +By default query parameters are taken in the order the client sent them, so +`?a=1&b=2` and `?b=2&a=1` are two distinct cache entries for the same logical +request. Set `sort_query=True` to share one entry between them: + +```python +@app.get("/search") +@cache(ttl=60, sort_query=True) +async def search(q: str, limit: int = 10): + return await run_search(q, limit) +``` + +The parameters are then ordered by name before the key is built. The sort is +stable: repeated values of one name keep the order the client sent, because a +handler reading `tag: list[str]` sees them in that order, so `?tag=b&tag=a` and +`?tag=a&tag=b` stay two entries. Names are compared decoded (`%61` sorts as +`a`, which is how the key already writes it), and each name and value is encoded +exactly as in the unsorted key, so only the order changes: a query already in +order gets the same key with or without the flag. What the key already treats +as equal stays equal (`?a` and `?a=`, an empty segment from `&&`), and nothing +else is merged. The default is `False`, which leaves every existing key +unchanged. + +`sort_query` applies to the default key builder only. Combined with a custom +`key_builder` it raises `CacheXError` when the decorator is applied; call +`build_cache_key(request, ..., sort_query=True)` inside the builder instead. +Pass `sort_query=True` to `invalidate()` for such a route as well (see +[Invalidating a single cached route](#invalidating-a-single-cached-route)). The host and path come from the client, so `|` and `%` in them are percent-encoded (`%7C` and `%25`). A `Host` header or path containing `|||` therefore cannot shift @@ -635,14 +660,16 @@ async def update_item(item_id: int, request: Request): return {"invalidated": await invalidate(StarletteRequest(scope))} ``` -`invalidate(request, key_builder=None, vary=None)` returns `True` when an entry existed and +`invalidate(request, key_builder=None, vary=None, *, sort_query=False)` returns `True` when an entry existed and was removed, `False` otherwise, including when no backend is configured. An error from the backend itself is raised to the caller (see [When the backend fails](#when-the-backend-fails)). The request you hand it must produce the cached route's key: same method, host, path and query string. If the cached route uses a custom -`key_builder` or `vary`, pass the same here, or the key will not match; with -`vary` only the variant selected by the request's own header values is -deleted, and `clear_path()` removes all of them. +`key_builder`, `vary` or `sort_query`, pass the same here, or the key will not +match: `invalidate()` cannot read them from the route. With `vary` only the +variant selected by the request's own header values is deleted, and +`clear_path()` removes all of them. With `sort_query=True` the request's query +is sorted the same way, so `?b=2&a=1` deletes the entry stored for `?a=1&b=2`. ## Monitoring routes diff --git a/fastapi_cachex/cache.py b/fastapi_cachex/cache.py index cbfd75b..f0def76 100644 --- a/fastapi_cachex/cache.py +++ b/fastapi_cachex/cache.py @@ -14,6 +14,7 @@ from functools import wraps from inspect import Parameter from inspect import Signature +from operator import itemgetter from typing import TYPE_CHECKING from typing import Annotated from typing import Any @@ -22,6 +23,7 @@ from typing import get_args from typing import get_origin from typing import get_type_hints +from urllib.parse import urlencode from fastapi import Request from fastapi import Response @@ -63,7 +65,24 @@ _NO_STORE = DirectiveType.NO_STORE.value -def build_cache_key(request: Request, *components: str | int) -> str: +def _query_component(request: Request, sort_query: bool) -> str: + """The query string as it appears in the key. + + Starlette parses the query (blank values kept, empty ``&&`` segments + dropped, names and values percent-decoded) and ``str()`` re-encodes the + pairs in the order sent. ``sort_query`` stable-sorts the same decoded + pairs by name before encoding them the same way, so only the order of + differently named parameters changes: repeated values of one name keep + their relative order, and an already sorted query gives the unsorted key. + """ + if not sort_query: + return str(request.query_params) + return urlencode(sorted(request.query_params.multi_items(), key=itemgetter(0))) + + +def build_cache_key( + request: Request, *components: str | int, sort_query: bool = False +) -> str: """Build the default cache key for ``request``, plus extra components. With no ``components`` the key is ``method|||host|||path|||query_params``, @@ -90,6 +109,11 @@ def per_user_key(request: Request) -> str: ``"1"`` give the same key. An empty string is a component of its own: ``build_cache_key(request, "")`` differs from ``build_cache_key(request)``. + sort_query: Order the query parameters by name (a stable sort, so + ``?tag=b&tag=a`` stays distinct from ``?tag=a&tag=b``), so that + ``?a=1&b=2`` and ``?b=2&a=1`` give the same key. This is what + ``@cache(sort_query=True)`` uses; a custom ``key_builder`` passes + it here instead. Returns: Generated cache key string @@ -105,7 +129,7 @@ def per_user_key(request: Request) -> str: request.method, escape_key_component(request.headers.get("host", "unknown")), escape_key_component(request.url.path), - str(request.query_params), + _query_component(request, sort_query), ] ), components, @@ -332,6 +356,39 @@ def default_key_builder(request: Request) -> str: return build_cache_key(request) +def _sorted_query_key_builder(request: Request) -> str: + """The key builder of ``@cache(sort_query=True)``.""" + return build_cache_key(request, sort_query=True) + + +_SORT_QUERY_WITH_KEY_BUILDER_MSG = ( + "sort_query only applies to the default key builder; a custom key_builder " + "builds its own key, so return build_cache_key(request, sort_query=True) " + "from it instead" +) + + +def _resolve_key_builder( + key_builder: CacheKeyBuilder | None, sort_query: object +) -> CacheKeyBuilder: + """Pick the key builder for ``key_builder`` and ``sort_query``. + + Raises: + CacheXError: If ``sort_query`` is not a ``bool``, if it is combined + with a custom ``key_builder`` (the flag would silently do + nothing), or if ``key_builder`` is an ``async`` callable. + """ + if not isinstance(sort_query, bool): + msg = f"sort_query must be a bool, got {type(sort_query).__name__}" + raise CacheXError(msg) + if key_builder is not None: + if sort_query: + raise CacheXError(_SORT_QUERY_WITH_KEY_BUILDER_MSG) + _validate_key_builder(key_builder) + return key_builder + return _sorted_query_key_builder if sort_query else default_key_builder + + _ASYNC_KEY_BUILDER_MSG = ( "key_builder must be a sync function returning str; async key builders " "are not supported (the key is built without awaiting)" @@ -379,6 +436,8 @@ async def invalidate( request: Request, key_builder: CacheKeyBuilder | None = None, vary: Sequence[str] | None = None, + *, + sort_query: bool = False, ) -> bool: """Invalidate the cache entry a ``@cache``-decorated route would use. @@ -400,18 +459,23 @@ async def invalidate( Credential headers (``Authorization``, ``Cookie``, ...) are hashed exactly as ``@cache`` hashes them, so pass a request carrying the same header value. + sort_query: The target route's ``sort_query``. With ``True`` the + query is sorted as ``@cache(sort_query=True)`` sorts it, so + ``?b=2&a=1`` deletes the entry stored for ``?a=1&b=2``. It is not + read from the route: pass the same value the route uses, or the + key will not match. Returns: True if a cache entry existed and was deleted, False otherwise. Raises: CacheXError: If ``vary`` is not a list of header names, if - ``key_builder`` is an ``async`` callable, or if it returns - something other than a ``str``. Raised before the backend is - touched. + ``sort_query`` is not a ``bool`` or is combined with + ``key_builder``, if ``key_builder`` is an ``async`` callable, or + if it returns something other than a ``str``. Raised before the + backend is touched. """ - builder = key_builder or default_key_builder - _validate_key_builder(builder) + builder = _resolve_key_builder(key_builder, sort_query) vary_names = _validate_vary(vary) cache_key = _append_key_components( _build_key(builder, request), _vary_components(request, vary_names) @@ -857,6 +921,7 @@ def cache( fail_open: bool = True, cache_authorized: bool = False, vary: Sequence[str] | None = None, + sort_query: bool = False, ) -> Callable[[HandlerCallable], AsyncResponseCallable]: """Cache decorator for FastAPI route handlers. @@ -947,6 +1012,16 @@ def cache( A request with ``Authorization`` or a session still bypasses the backend unless ``public`` or ``cache_authorized`` is set, and a response that sets a cookie is still not stored. + sort_query: Order the query parameters by name before building the + key, so ``?a=1&b=2`` and ``?b=2&a=1`` share one entry. The sort is + stable: repeated values of one name keep the order the client + sent, so ``?tag=b&tag=a`` and ``?tag=a&tag=b`` stay distinct, and + the names and values are encoded exactly as in the unsorted key. + Off by default, which keeps every existing key unchanged. Only + the default key builder sorts: combined with a custom + ``key_builder`` it is rejected; call + ``build_cache_key(request, sort_query=True)`` in the builder + instead. Pass the same value to ``invalidate()``. Returns: Decorator function that wraps route handlers with caching logic @@ -956,8 +1031,10 @@ def cache( ``stale_ttl`` are not given together, if ``public`` and ``private`` are both set, if ``ttl`` is not an ``int``, is negative or is larger than ``MAX_TTL``, or if ``vary`` is not a - list of header field names (a single string is rejected), or if - ``key_builder`` is an ``async`` callable. At request time, if + list of header field names (a single string is rejected), if + ``sort_query`` is not a ``bool`` or is combined with + ``key_builder``, or if ``key_builder`` is an ``async`` callable. + At request time, if ``key_builder`` returns anything but a ``str``. """ @@ -984,8 +1061,7 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable: msg = f"ttl must be at most {MAX_TTL} seconds" raise CacheXError(msg) vary_names = _validate_vary(vary) - if key_builder is not None: - _validate_key_builder(key_builder) + builder = _resolve_key_builder(key_builder, sort_query) if any(name.lower() == "cookie" for name in vary_names): # stacklevel=2: the caller applying the decorator, i.e. the line # of the user's @cache(...). @@ -1059,7 +1135,6 @@ def decorator(func: HandlerCallable) -> AsyncResponseCallable: must_revalidate=must_revalidate, ) ) - builder = key_builder or default_key_builder # Without a positive ttl nothing may be served from storage, and a 304 # answered from a stored ETag would be exactly that: it would keep # confirming a copy that the handler no longer produces (#110). Such diff --git a/i18n/zh-TW/docs/HTTP_CACHING.md b/i18n/zh-TW/docs/HTTP_CACHING.md index 1b5705b..0d34abc 100644 --- a/i18n/zh-TW/docs/HTTP_CACHING.md +++ b/i18n/zh-TW/docs/HTTP_CACHING.md @@ -177,7 +177,18 @@ async def report(): - 不同的查詢參數各有獨立的快取項目 - 同一個端點搭配不同參數時可以各自快取 -查詢參數依用戶端送出的順序取用,不會排序,因此 `?a=1&b=2` 與 `?b=2&a=1` 對同一個邏輯上的請求而言是兩筆不同的快取項目。 +預設情況下,查詢參數依用戶端送出的順序取用,因此 `?a=1&b=2` 與 `?b=2&a=1` 對同一個邏輯上的請求而言是兩筆不同的快取項目。設定 `sort_query=True` 可讓它們共用同一筆項目: + +```python +@app.get("/search") +@cache(ttl=60, sort_query=True) +async def search(q: str, limit: int = 10): + return await run_search(q, limit) +``` + +此時會先依名稱排序參數,再建立快取鍵。排序是穩定的:同名參數的多個值保留用戶端送出的順序,因為以 `tag: list[str]` 讀取的處理函式看到的正是這個順序,所以 `?tag=b&tag=a` 與 `?tag=a&tag=b` 仍是兩筆項目。名稱以解碼後的值比較(`%61` 視為 `a` 排序,快取鍵本來就這樣寫它),每個名稱與值的編碼都與未排序的快取鍵完全相同,只有順序改變:已經依序排列的查詢,不論是否開啟此選項都得到相同的鍵。快取鍵原本就視為相同的仍然相同(`?a` 與 `?a=`、`&&` 產生的空段),其餘一律不會合併。預設為 `False`,現有的快取鍵都不會改變。 + +`sort_query` 只套用於預設的 key builder。與自訂的 `key_builder` 一起使用時,套用裝飾器就會拋出 `CacheXError`;請改在 builder 中呼叫 `build_cache_key(request, ..., sort_query=True)`。對這樣的路由呼叫 `invalidate()` 時也要傳入 `sort_query=True`(見[使單一快取路由失效](#invalidating-a-single-cached-route))。 host 與路徑來自用戶端,因此其中的 `|` 與 `%` 會以百分比編碼寫入(`%7C` 與 `%25`)。含有 `|||` 的 `Host` 標頭或路徑因此無法讓各段錯位,使某個請求的快取鍵與另一個請求相同。查詢字串本來就經過 URL 編碼。`clear_path()` 接受應用程式看到的路徑(`request.url.path`),並以同樣方式編碼;`clear_pattern()` 比對的是儲存的快取鍵,所以在模式中要把 `|` 寫成 `%7C`。0.3.8 之前兩者都照原樣儲存,因此升級後,host 或路徑含有 `|` 或 `%` 的項目會重新快取一次。 @@ -407,7 +418,7 @@ async def update_item(item_id: int, request: Request): return {"invalidated": await invalidate(StarletteRequest(scope))} ``` -`invalidate(request, key_builder=None, vary=None)` 在項目存在且已移除時回傳 `True`,否則回傳 `False`,包括尚未設定後端的情況。後端本身的錯誤則會拋給呼叫端(見[後端發生錯誤時](#when-the-backend-fails))。傳入的請求必須能產生快取路由的鍵:相同的方法、主機、路徑與查詢字串。如果快取路由使用自訂的 `key_builder` 或 `vary`,這裡也要傳入相同的值,否則鍵不會相符;使用 `vary` 時,只會刪除請求本身的標頭值所選中的變體,`clear_path()` 則會移除所有變體。 +`invalidate(request, key_builder=None, vary=None, *, sort_query=False)` 在項目存在且已移除時回傳 `True`,否則回傳 `False`,包括尚未設定後端的情況。後端本身的錯誤則會拋給呼叫端(見[後端發生錯誤時](#when-the-backend-fails))。傳入的請求必須能產生快取路由的鍵:相同的方法、主機、路徑與查詢字串。如果快取路由使用自訂的 `key_builder`、`vary` 或 `sort_query`,這裡也要傳入相同的值,否則鍵不會相符:`invalidate()` 無法從路由讀取這些設定。使用 `vary` 時,只會刪除請求本身的標頭值所選中的變體,`clear_path()` 則會移除所有變體。使用 `sort_query=True` 時,請求的查詢會以相同方式排序,因此 `?b=2&a=1` 會刪除為 `?a=1&b=2` 儲存的項目。 ## 監控路由 {#monitoring-routes} diff --git a/tests/test_cache_sort_query.py b/tests/test_cache_sort_query.py new file mode 100644 index 0000000..9db2496 --- /dev/null +++ b/tests/test_cache_sort_query.py @@ -0,0 +1,247 @@ +"""Tests for ``@cache(sort_query=True)`` (#267).""" + +from collections.abc import Callable + +import pytest +from fastapi import FastAPI +from fastapi import Request +from fastapi.testclient import TestClient +from starlette.requests import Request as StarletteRequest + +from fastapi_cachex.backends import MemoryBackend +from fastapi_cachex.cache import build_cache_key +from fastapi_cachex.cache import cache +from fastapi_cachex.cache import default_key_builder +from fastapi_cachex.cache import invalidate +from fastapi_cachex.exceptions import CacheXError +from fastapi_cachex.proxy import BackendProxy +from fastapi_cachex.routes import _parse_cache_key + + +def _request(query: bytes) -> StarletteRequest: + return StarletteRequest( + { + "type": "http", + "method": "GET", + "path": "/items", + "query_string": query, + "headers": [(b"host", b"testserver")], + } + ) + + +def _sorted_query(query: bytes) -> str: + return _parse_cache_key(build_cache_key(_request(query), sort_query=True))[3] + + +def _app(*, sort_query: bool, sync: bool = False) -> tuple[TestClient, list[str]]: + app = FastAPI() + calls: list[str] = [] + + if sync: + + @app.get("/items") + @cache(ttl=60, sort_query=sort_query) + def sync_items(request: Request) -> dict[str, str]: + calls.append(request.url.query) + return {"query": request.url.query} + + else: + + @app.get("/items") + @cache(ttl=60, sort_query=sort_query) + async def async_items(request: Request) -> dict[str, str]: + calls.append(request.url.query) + return {"query": request.url.query} + + return TestClient(app), calls + + +@pytest.mark.parametrize("sync", [False, True], ids=["async", "sync"]) +def test_reordered_parameters_share_one_entry(sync: bool) -> None: + client, calls = _app(sort_query=True, sync=sync) + + first = client.get("/items?limit=1&q=wid") + second = client.get("/items?q=wid&limit=1") + + assert first.json() == second.json() == {"query": "limit=1&q=wid"} + assert calls == ["limit=1&q=wid"] + + +@pytest.mark.parametrize("sync", [False, True], ids=["async", "sync"]) +def test_repeated_name_order_stays_distinct(sync: bool) -> None: + client, calls = _app(sort_query=True, sync=sync) + + client.get("/items?tag=b&tag=a") + client.get("/items?tag=a&tag=b") + # Moving another name around a repeated one does not reorder its values. + client.get("/items?x=1&tag=b&tag=a") + client.get("/items?tag=b&x=1&tag=a") + + assert calls == ["tag=b&tag=a", "tag=a&tag=b", "x=1&tag=b&tag=a"] + + +@pytest.mark.parametrize("sync", [False, True], ids=["async", "sync"]) +def test_default_keeps_reordered_queries_apart(sync: bool) -> None: + client, calls = _app(sort_query=False, sync=sync) + + client.get("/items?a=1&b=2") + client.get("/items?b=2&a=1") + client.get("/items?a=1&b=2") + + assert calls == ["a=1&b=2", "b=2&a=1"] + + +def test_only_parameter_order_merges() -> None: + client, calls = _app(sort_query=True) + + for query in ("a=1&b=2", "b=2&a=1", "a=1&b=3", "a=1", "b=2", "a=1&b=2&c="): + client.get(f"/items?{query}") + + assert calls == ["a=1&b=2", "a=1&b=3", "a=1", "b=2", "a=1&b=2&c="] + + +def test_default_key_is_unchanged() -> None: + for query in (b"b=2&a=1", b"tag=b&tag=a", b"q=a%20b&n%26=x%3D", b""): + request = _request(query) + assert default_key_builder(request) == build_cache_key(request) + assert _parse_cache_key(build_cache_key(request))[3] == str( + request.query_params + ) + + +def test_sorted_key_matches_the_default_for_a_sorted_query() -> None: + request = _request(b"a=1&b=x%26y&c=") + + assert build_cache_key(request, sort_query=True) == build_cache_key(request) + + +@pytest.mark.parametrize( + ("query", "expected"), + [ + (b"", ""), + (b"&", ""), + (b"b=2&&a=1", "a=1&b=2"), + (b"b&a", "a=&b="), + (b"b=&a=1", "a=1&b="), + (b"b=2&a=1&a=", "a=1&a=&b=2"), + (b"b=1&a=2&b=0", "a=2&b=1&b=0"), + ], + ids=[ + "empty", + "only-separator", + "empty-segment", + "no-equals", + "blank-value", + "repeated-with-blank", + "repeated-split", + ], +) +def test_edge_cases(query: bytes, expected: str) -> None: + assert _sorted_query(query) == expected + + +@pytest.mark.parametrize( + ("query", "expected"), + [ + (b"z=a%20b&n%26=x%3Dy", "n%26=x%3Dy&z=a+b"), + (b"z=%E4%B8%AD&a=%7C", "a=%7C&z=%E4%B8%AD"), + ], +) +def test_percent_encoding_is_kept(query: bytes, expected: str) -> None: + request = _request(query) + + assert _sorted_query(query) == expected + # Each pair is encoded exactly as the unsorted key encodes it. + assert sorted(expected.split("&")) == sorted(str(request.query_params).split("&")) + + +def test_names_are_compared_decoded() -> None: + # `%61` is `a`: the key already writes it as `a`, so it sorts as `a`, + # before `b`, and keeps its place among the other `a` values. + assert _sorted_query(b"b=0&%61=1&a=2") == "a=1&a=2&b=0" + + +def test_sort_query_with_custom_key_builder_is_rejected() -> None: + with pytest.raises(CacheXError, match="sort_query only applies"): + cache(ttl=60, key_builder=default_key_builder, sort_query=True)(lambda: None) + + +@pytest.mark.parametrize("value", [1, "yes", None]) +def test_sort_query_must_be_a_bool(value: object) -> None: + with pytest.raises(CacheXError, match="sort_query must be a bool"): + cache(ttl=60, sort_query=value)(lambda: None) # type: ignore[arg-type] + + +def test_custom_key_builder_can_sort_through_build_cache_key() -> None: + app = FastAPI() + calls: list[int] = [] + + def sorted_key(request: Request) -> str: + return build_cache_key(request, "tenant", sort_query=True) + + @app.get("/items") + @cache(ttl=60, key_builder=sorted_key) + async def items() -> dict[str, int]: + calls.append(1) + return {} + + client = TestClient(app) + client.get("/items?a=1&b=2") + client.get("/items?b=2&a=1") + + assert calls == [1] + + +async def test_invalidate_with_reordered_query_drops_the_entry() -> None: + backend = MemoryBackend() + BackendProxy.set(backend) + client, calls = _app(sort_query=True) + client.get("/items?a=1&b=2") + + assert await invalidate(_request(b"b=2&a=1"), sort_query=True) is True + assert not backend.cache + client.get("/items?a=1&b=2") + assert calls == ["a=1&b=2", "a=1&b=2"] + + +async def test_invalidate_needs_the_routes_sort_query() -> None: + # invalidate() cannot read the route's settings: without the flag it + # builds the unsorted key and misses the entry stored for a reordered + # query. + backend = MemoryBackend() + BackendProxy.set(backend) + client, _ = _app(sort_query=True) + client.get("/items?a=1&b=2") + + assert await invalidate(_request(b"b=2&a=1")) is False + assert len(backend.cache) == 1 + + +@pytest.mark.parametrize( + "call", + [ + lambda: invalidate( + _request(b""), key_builder=default_key_builder, sort_query=True + ), + lambda: invalidate(_request(b""), sort_query=1), # type: ignore[arg-type] + ], + ids=["with-key-builder", "not-a-bool"], +) +async def test_invalidate_validates_sort_query( + call: Callable[[], object], +) -> None: + with pytest.raises(CacheXError): + await call() # type: ignore[misc] + + +async def test_clear_path_clears_sorted_entries() -> None: + backend = MemoryBackend() + BackendProxy.set(backend) + client, _ = _app(sort_query=True) + client.get("/items?b=2&a=1") + client.get("/items") + + assert await backend.clear_path("/items") == 1 + assert await backend.clear_path("/items", include_params=True) == 1 + assert not backend.cache