Query-Keys

Ein QueryKey ist eine strukturierte, geordnete Cache-Identität. Baue hierarchische Keys und du bekommst Präfix-Invalidierung gratis dazu.

Keys bauen#

QueryKey.Of("todos")                     // ["todos"]
QueryKey.Of("todos", "detail", 5)        // ["todos","detail",5]
QueryKey.Of("posts", new Dictionary<string, object?> { ["userId"] = 1, ["page"] = 2 })

Die Reihenfolge der Segmente zählt — Of("todos", 1) und Of(1, "todos") sind verschiedene Keys. Unterstützte Segmenttypen: Strings, numerische Primitive, bool, char, Guid, Datums- und Zeitwerte, TimeSpan, Enums, null, Collections daraus sowie Dictionaries mit String-Keys. Ein nicht unterstützter Typ wirft schon bei der Konstruktion — lautes Scheitern an der Aufrufstelle ist besser als ein still kollidierender Eintrag.

Kanonische Form#

Jeder Key berechnet einmalig einen kanonischen String (das, was ToString() zeigt, z. B. ["todos",{"page":2}]). Strings werden in Anführungszeichen gesetzt und escapet, damit ["a,b"] und ["a","b"] nicht kollidieren können; Zahlen und Datumswerte nutzen die invariante Kultur; Enums nutzen ihren numerischen Wert, damit das Umbenennen eines Members einen Key nicht still verändern kann. Gleichheit — einschließlich == — ist Wertgleichheit auf dieser kanonischen Form.

Dictionaries normalisieren Filterobjekte#

Dictionary-Segmente werden mit sortierten Keys geschrieben, die Eigenschaftsreihenfolge in einem Ad-hoc-Filter ändert die Identität also nicht:

// Same key, either way — dictionary keys are ordinal-sorted in the canonical form.
QueryKey.Of("posts", new Dictionary<string, object?> { ["page"] = 2, ["status"] = "open" })
QueryKey.Of("posts", new Dictionary<string, object?> { ["status"] = "open", ["page"] = 2 })
Anonyme Typen werden nicht unterstützt

QueryKey.Of("todos", new { Page = 2 }) wirft eine Exception. Das Key-Hashing läuft bei jedem Cache-Lookup und muss trimming- und AOT-sicher bleiben — Reflection über anonyme Typen ist das nicht. Nutze ein Dictionary<string, object?> für Ad-hoc-Formen oder implementiere IQueryKeySegment auf einem benannten Typ.

Präfix-Matching#

StartsWith vergleicht Segment für Segment auf den kanonischen Formen — nie als String-Präfix, ["todo"] matcht also nicht ["todos"]. Invalidierung, Remove und RefetchAsync matchen standardmäßig alle per Präfix:

await Client.InvalidateAsync(QueryKey.Of("todos"));
// hits ["todos"], ["todos","list",8], ["todos","detail",5], …
// but NOT ["todo"] or ["todoLists"]

IQueryKeySegment#

Implementiere IQueryKeySegment auf einem eigenen Typ, damit er direkt teilnehmen kann: ToKeySegment() muss einen stabilen, deterministischen String zurückgeben — gleiche Werte, gleiche Strings, über Sessions hinweg.

Strings konvertieren implizit#

QueryKey = "todos" bedeutet QueryKey.Of("todos"). Null oder Whitespace konvertiert zu keinem Key — dem ungecachten Pfad.

Muster: eine Keys-Klasse#

Diese Seite hält jeden Demo-Key in einer statischen Klasse — ein Ort, an dem die ganze Hierarchie sichtbar ist:

DocsKeys.cs
public static class DocsKeys
{
    public static QueryKey Todos => QueryKey.Of("todos");
    public static QueryKey TodoList(int limit) => QueryKey.Of("todos", "list", limit);
    public static QueryKey TodoDetail(int id) => QueryKey.Of("todos", "detail", id);
    public static QueryKey Posts(int? userId, int page) => QueryKey.Of(
        "posts", "list", new Dictionary<string, object?> { ["userId"] = userId, ["page"] = page });
}

Live-Demo#

Hierarchische Keys + Präfix-Invalidierung
Page 1

Current key: ["posts","list",{"page":1,"userId":null}]

Posts query IsLoading IsReFetching IsError IsCachedData IsStale ReFetchCount: 1UpdatedAt: 20:33:35
  • #1 · u1 — sunt aut facere repellat provident occaecati excepturi optio reprehenderit
  • #2 · u1 — qui est esse
  • #3 · u1 — ea molestias quasi exercitationem repellat qui ipsa sit aut
  • #4 · u1 — eum et est occaecati
  • #5 · u1 — nesciunt quas odio

Each author/page combination is its own cache entry — flip back to a visited page and it renders instantly. The dictionary segment normalises property order, and invalidating the ["posts"] prefix marks every combination stale in one call.

@inherits DejaComponentBase
@inject JsonPlaceholderApi Api
@inject DejaClient Client

<div class="demo-toolbar">
    <label>
        Author:
        <select class="demo-input" @onchange="OnUserChanged">
            <option value="" selected="@(_userId is null)">All authors</option>
            <option value="1" selected="@(_userId == 1)">User 1</option>
            <option value="2" selected="@(_userId == 2)">User 2</option>
        </select>
    </label>
    <button class="demo-button" @onclick="() => ChangePage(-1)" disabled="@(_page <= 1)"></button>
    <span>Page @_page</span>
    <button class="demo-button" @onclick="() => ChangePage(1)"></button>
    <button class="demo-button" @onclick="InvalidatePrefix">Invalidate ["posts"] prefix</button>
</div>

<p class="demo-note">Current key: <code>@DocsKeys.Posts(_userId, _page).ToString()</code></p>

<StateInspector Query="_posts" Label="Posts query"/>

@if (_posts.Data is { } posts)
{
    <ul class="demo-list">
        @foreach (var post in posts.Take(5))
        {
            <li>#@post.Id · u@(post.UserId)@post.Title</li>
        }
    </ul>
}

<p class="demo-note">
    Each author/page combination is its own cache entry — flip back to a visited page and it
    renders instantly. The dictionary segment normalises property order, and invalidating the
    <code>["posts"]</code> prefix marks every combination stale in one call.
</p>

@code {
    private readonly Query<IReadOnlyList<PostDto>> _posts = new();
    private int? _userId;
    private int _page = 1;

    protected override Task OnInitializedAsync() => Load();

    private Task Load() => _posts.Execute(
        DocsKeys.Posts(_userId, _page),
        token => Api.GetPostsAsync(_userId, _page, token),
        p => p.OnError = _ => { });

    private Task OnUserChanged(ChangeEventArgs e)
    {
        _userId = int.TryParse((string?)e.Value, out var id) ? id : null;
        _page = 1;
        return Load();
    }

    private Task ChangePage(int delta)
    {
        _page = Math.Max(1, _page + delta);
        return Load();
    }

    private Task InvalidatePrefix() => Client.InvalidateAsync(QueryKey.Of("posts"));
}