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 })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:
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#
Current key: ["posts","list",{"page":1,"userId":null}]
- #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"));
}