diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index fcf2eb8..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "files.exclude": { - "**/.git": true, - "**/.svn": true, - "**/.hg": true, - "**/.DS_Store": true, - "**/Thumbs.db": true, - "**/bin": true, - "**/obj": true - }, - "cSpell.words": [ - "blazor", - "bunit", - "csharpier", - "direnv", - "docfx", - "dotnet", - "dotnetquery", - "github", - "Invokable", - "netcoredbg", - "nixd", - "nixos", - "nixpkgs", - "omnisharp", - "pkgs", - "psachmann", - "roslyn", - "snupkg", - "tanstack", - "typeparam" - ], -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 5bfc52e..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "tool restore", - "type": "shell", - "command": "dotnet tool restore", - "options": { "cwd": "${workspaceFolder}" }, - "group": "none", - "presentation": { - "reveal": "silent", - "panel": "shared" - }, - "problemMatcher": "$msCompile" - }, - { - "label": "format", - "type": "shell", - "command": "dotnet csharpier format .", - "options": { "cwd": "${workspaceFolder}" }, - "group": "none", - "presentation": { - "reveal": "silent", - "panel": "shared" - }, - "problemMatcher": "$msCompile" - }, - { - "label": "build", - "type": "shell", - "command": "dotnet build", - "options": { "cwd": "${workspaceFolder}" }, - "group": { - "kind": "build", - "isDefault": true - }, - "presentation": { - "reveal": "always", - "panel": "shared" - }, - "problemMatcher": "$msCompile" - }, - { - "label": "test", - "type": "shell", - "command": "dotnet test --no-build -- --coverage --coverage-output-format cobertura", - "options": { "cwd": "${workspaceFolder}" }, - "group": { - "kind": "test", - "isDefault": true - }, - "dependsOn": "build", - "presentation": { - "reveal": "always", - "panel": "shared" - }, - "problemMatcher": "$msCompile" - } - ] -} diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 0000000..5e9b8a4 --- /dev/null +++ b/.zed/tasks.json @@ -0,0 +1,18 @@ +[ + { + "label": "restore", + "command": "dotnet tool restore && dotnet restore", + }, + { + "label": "build", + "command": "dotnet build", + }, + { + "label": "test", + "command": "dotnet test", + }, + { + "label": "format", + "command": "dotnet csharpier format .", + }, +] diff --git a/TODOS.md b/TODOS.md index b4ac686..c58b966 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,7 +2,8 @@ ## Core Library Gaps (vs TanStack Query) -- [ ] **Infinite/Paginated Queries** — `IInfiniteQuery` equivalent with fetch-next-page / fetch-previous-page support +- [x] **Infinite/Paginated Queries** — `IInfiniteQuery` equivalent with fetch-next-page / fetch-previous-page support + - [ ] Initial data ## Blazor-Specific Gaps @@ -11,4 +12,3 @@ ## .NET Ecosystem Integration - [ ] **MVVM Integration (`DotNetQuery.Mvvm`)** — `QueryViewModel` wrapping `IQuery` for MVVM-based UI frameworks (MAUI, WPF, UNO Platform); implements `INotifyPropertyChanged` and exposes bindable properties (`IsLoading`, `IsSuccess`, `IsFailure`, `Data`, `Error`); thread marshaling handled per-platform (`MainThread` / `Dispatcher` / `DispatcherQueue`) - diff --git a/codebook.toml b/codebook.toml new file mode 100644 index 0000000..44d816a --- /dev/null +++ b/codebook.toml @@ -0,0 +1,8 @@ +words = [ + "Blazor", + "MVVM", + "cref", + "paramref", + "typeparam", + "typeparamref", +] diff --git a/docs/doc/examples/blazor.md b/docs/doc/examples/blazor.md deleted file mode 100644 index 7e2438c..0000000 --- a/docs/doc/examples/blazor.md +++ /dev/null @@ -1,299 +0,0 @@ -# Example: Blazor Integration - -This example builds a complete Blazor page with a list view, detail view, create form, and delete — wiring up queries and mutations end to end. - -## Program.cs - -```csharp -using DotNetQuery.Extensions.DependencyInjection; - -var builder = WebAssemblyHostBuilder.CreateDefault(args); -builder.RootComponents.Add("#app"); - -builder.Services.AddScoped(sp => - new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); - -builder.Services.AddDotNetQuery(options => -{ - options.StaleTime = TimeSpan.FromMinutes(2); - options.CacheTime = TimeSpan.FromMinutes(15); -}); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -await builder.Build().RunAsync(); -``` - -## MainLayout.razor - -Add `` so stale queries automatically refetch when the network comes back online or the user returns to the tab. Use `IHostEnvironment` to limit `` to development. - -Both components require an interactive render mode — placing them in `MainLayout.razor` rather than `App.razor` ensures they run under the correct render context. If your app has multiple layouts, add the components to each one or extract them into a shared parent layout. - -```html -@* MainLayout.razor *@ -@inherits LayoutComponentBase - -@using DotNetQuery.Blazor -@using DotNetQuery.Blazor.DevTools -@inject IHostEnvironment Env - - - -@if (Env.IsDevelopment()) -{ - -} - -
- @Body -
-``` - -## Service Layer - -Register queries and mutations in dedicated service classes and inject them into components. The services own the query and mutation instances and handle disposal — components stay focused on rendering. - -```csharp -public sealed class UserQueries(IQueryClient queryClient, HttpClient http) : IDisposable -{ - public readonly IQuery> UsersQuery = queryClient.CreateQuery( - new QueryOptions> - { - KeyFactory = _ => QueryKey.From("users"), - Fetcher = (_, ct) => http.GetFromJsonAsync>("/api/users", ct)!, - }); - - public readonly IQuery UserQuery = queryClient.CreateQuery( - new QueryOptions - { - KeyFactory = id => QueryKey.From("users", id), - Fetcher = (id, ct) => http.GetFromJsonAsync($"/api/users/{id}", ct)!, - StaleTime = TimeSpan.FromMinutes(5), - }); - - public void Dispose() - { - UsersQuery.Dispose(); - UserQuery.Dispose(); - } -} - -public sealed class UserMutations(IQueryClient queryClient, HttpClient http) : IDisposable -{ - public readonly IMutation CreateUser = queryClient.CreateMutation( - new MutationOptions - { - Mutator = (req, ct) => http.PostAsJsonAsync("/api/users", req, ct), - InvalidateKeys = [QueryKey.From("users")], - }); - - public readonly IMutation DeleteUser = queryClient.CreateMutation( - new MutationOptions - { - Mutator = (id, ct) => http.DeleteAsync($"/api/users/{id}", ct) - .ContinueWith(_ => Unit.Default, ct), - InvalidateKeys = [QueryKey.From("users")], - }); - - public void Dispose() - { - CreateUser.Dispose(); - DeleteUser.Dispose(); - } -} -``` - -## User List Page - -```html -@page "/users" -@inject UserQueries Queries -@inject NavigationManager Nav - -

Users

- - - - - - - - - - - - - - - @foreach (var user in users) - { - - - - - - } - -
NameEmail
@user.Name@user.Email - - -
-
- -

Loading users...

-
- -

@error.Message

-
-
- -@code { - protected override void OnInitialized() - { - Queries.UsersQuery.SetArgs(Unit.Default); - } -} -``` - -## User Detail Page - -```html -@page "/users/{Id:int}" -@inject UserQueries Queries -@inject NavigationManager Nav - - - -

@user.Name

-
-
Email
@user.Email
-
Role
@user.Role
-
Joined
@user.CreatedAt.ToShortDateString()
-
- -
- - -
-
- -

Loading...

-
- -

@error.Message

- -
-
- -@code { - [Parameter] public int Id { get; set; } - - protected override void OnParametersSet() => Queries.UserQuery.SetArgs(Id); -} -``` - -## Create User Form - -The component only disposes its own state subscription — the mutation itself is owned and disposed by the injected service. - -```html -@page "/users/new" -@inject UserMutations Mutations -@inject NavigationManager Nav -@implements IDisposable - -

New User

- -@if (_errorMessage is not null) -{ -
@_errorMessage
-} - - - - - -
- - -
- -
- - -
- - - -
- -@code { - private readonly CreateUserRequest _model = new(); - - private IDisposable? _subscription; - private bool _isBusy; - private string? _errorMessage; - - protected override void OnInitialized() - { - _subscription = Mutations.CreateUser.State.Subscribe(async state => - { - _isBusy = state.IsRunning; - _errorMessage = state.IsFailure ? state.Error!.Message : null; - - if (state.IsSuccess) - Nav.NavigateTo($"/users/{state.CurrentData!.Id}"); - - await InvokeAsync(StateHasChanged); - }); - } - - private void HandleSubmit() => Mutations.CreateUser.Execute(_model); - - public void Dispose() => _subscription?.Dispose(); -} -``` - -## Delete Button Component - -Uses `Settled.Take(1)` to track per-click state without holding a long-lived subscription. Because `DeleteUser` declares `InvalidateKeys`, the users list refetches automatically on success. - -```html -@* Components/DeleteButton.razor *@ -@inject UserMutations Mutations - - - -@code { - [Parameter, EditorRequired] public int UserId { get; set; } - - private bool _isDeleting; - - private void HandleClick() - { - _isDeleting = true; - - Mutations.DeleteUser.Settled - .Take(1) - .Subscribe(async _ => - { - _isDeleting = false; - await InvokeAsync(StateHasChanged); - }); - - Mutations.DeleteUser.Execute(UserId); - } -} -``` diff --git a/docs/doc/examples/mutations.md b/docs/doc/examples/mutations.md deleted file mode 100644 index 84695f5..0000000 --- a/docs/doc/examples/mutations.md +++ /dev/null @@ -1,204 +0,0 @@ -# Example: Creating and Updating Data - -This example demonstrates common mutation patterns: creating a resource, updating it, deleting it, and automatically refreshing related queries. - -## Setup - -```csharp -public sealed class UserMutations(IQueryClient queryClient, UserApiClient api) : IDisposable -{ - public readonly IMutation CreateUser = - queryClient.CreateMutation(new MutationOptions - { - Mutator = (req, ct) => api.CreateUserAsync(req, ct), - InvalidateKeys = [QueryKey.From("users")], - OnSuccess = (req, user) => Console.WriteLine($"Created {user.Name}"), - }); - - public readonly IMutation UpdateUser = - queryClient.CreateMutation(new MutationOptions - { - Mutator = (req, ct) => api.UpdateUserAsync(req, ct), - // Invalidate both the list and the specific user entry - InvalidateKeys = [ - QueryKey.From("users"), - QueryKey.From("users", req.Id), // req is only available at execution time — see note below - ], - }); - - public readonly IMutation DeleteUser = - queryClient.CreateMutation(new MutationOptions - { - Mutator = (id, ct) => api.DeleteUserAsync(id, ct), - OnSuccess = (id, _) => Console.WriteLine($"Deleted user {id}"), - OnFailure = error => Console.WriteLine($"Delete failed: {error.Message}"), - OnSettled = () => Console.WriteLine("Delete operation finished"), - }); - - public void Dispose() - { - CreateUser.Dispose(); - UpdateUser.Dispose(); - DeleteUser.Dispose(); - } -} -``` - -> **Notes on `InvalidateKeys`:** -> - Keys are evaluated when the mutation is *created*, not when it is *executed*. To invalidate a key based on the args (e.g. `QueryKey.From("users", req.Id)`), use `OnSuccess` instead: -> ```csharp -> OnSuccess = (req, _) => queryClient.Invalidate(QueryKey.From("users", req.Id)) -> ``` -> - Invalidation fires immediately when the mutator's `Task` resolves. For eventually-consistent backends (read replicas, CQRS read models), the re-fetch may return stale data. Use `OnSuccess` when you need to delay or conditionally invalidate. - -## Create User Form - -```html -@inject UserMutations Mutations -@implements IDisposable - -

Create User

- -@if (_errorMessage is not null) -{ -
@_errorMessage
-} - -
-
- - -
-
- - -
- -
- -@code { - private string _name = ""; - private string _email = ""; - private bool _isBusy; - private string? _errorMessage; - - private IDisposable? _subscription; - - protected override void OnInitialized() - { - _subscription = Mutations.CreateUser.State.Subscribe(state => - { - _isBusy = state.IsRunning; - _errorMessage = state.IsFailure ? state.Error!.Message : null; - InvokeAsync(StateHasChanged); - }); - } - - private void HandleSubmit() - { - _errorMessage = null; - Mutations.CreateUser.Execute(new CreateUserRequest - { - Name = _name, - Email = _email, - }); - } - - public void Dispose() => _subscription?.Dispose(); -} -``` - -## Delete with Optimistic Feedback - -```html -@inject UserMutations Mutations - - - -@code { - [Parameter] public UserDto User { get; set; } = default!; - - private bool _isDeleting; - - private void HandleDelete(int id) - { - _isDeleting = true; - - // Subscribe just for this execution - Mutations.DeleteUser.Settled - .Take(1) - .Subscribe(_ => - { - _isDeleting = false; - InvokeAsync(StateHasChanged); - }); - - Mutations.DeleteUser.Execute(id); - } -} -``` - -## Multiple Mutations in a Workflow - -Here is a realistic scenario: uploading an avatar and then updating the user profile, keeping the UI in sync throughout: - -```csharp -public sealed class ProfileWorkflow(IQueryClient queryClient, UserApiClient api) : IDisposable -{ - // Step 1: upload the avatar - public readonly IMutation UploadAvatar = - queryClient.CreateMutation(new MutationOptions - { - Mutator = (stream, ct) => api.UploadAvatarAsync(stream, ct), - }); - - // Step 2: save the profile (triggered after avatar upload succeeds) - public readonly IMutation SaveProfile = - queryClient.CreateMutation(new MutationOptions - { - Mutator = (req, ct) => api.UpdateProfileAsync(req, ct), - InvalidateKeys = [QueryKey.From("users", "me")], - }); - - public void Dispose() - { - UploadAvatar.Dispose(); - SaveProfile.Dispose(); - } -} -``` - -```html -@inject ProfileWorkflow Workflow -@implements IDisposable - -@code { - private IDisposable? _avatarSuccessSub; - - protected override void OnInitialized() - { - // When avatar upload succeeds, chain into profile save - _avatarSuccessSub = Workflow.UploadAvatar.Success.Subscribe(avatarUrl => - { - Workflow.SaveProfile.Execute(new UpdateProfileRequest - { - AvatarUrl = avatarUrl, - // ... other fields - }); - }); - } - - private void HandleAvatarSelected(Stream avatarStream) - { - Workflow.UploadAvatar.Execute(avatarStream); - } - - public void Dispose() => _avatarSuccessSub?.Dispose(); -} -``` diff --git a/docs/doc/examples/queries.md b/docs/doc/examples/queries.md deleted file mode 100644 index 673dfc1..0000000 --- a/docs/doc/examples/queries.md +++ /dev/null @@ -1,179 +0,0 @@ -# Example: Fetching Data - -This example shows a realistic user profile page that fetches a user and their posts, demonstrates background refresh, and handles error states. - -## Setup - -```csharp -// Program.cs -builder.Services.AddDotNetQuery(options => -{ - options.StaleTime = TimeSpan.FromMinutes(2); - options.CacheTime = TimeSpan.FromMinutes(10); -}); - -builder.Services.AddHttpClient(client => - client.BaseAddress = new Uri("https://api.example.com")); -``` - -## Service Layer - -Define your queries in a service class so they are easy to share and test: - -```csharp -public sealed class UserQueries(IQueryClient queryClient, UserApiClient api) : IDisposable -{ - public readonly IQuery UserQuery = queryClient.CreateQuery( - new QueryOptions - { - KeyFactory = id => QueryKey.From("users", id), - Fetcher = (id, ct) => api.GetUserAsync(id, ct), - StaleTime = TimeSpan.FromMinutes(5), - } - ); - - public readonly IQuery> PostsQuery = queryClient.CreateQuery( - new QueryOptions> - { - KeyFactory = id => QueryKey.From("users", id, "posts"), - Fetcher = (id, ct) => api.GetUserPostsAsync(id, ct), - RefetchInterval = TimeSpan.FromSeconds(60), - } - ); - - public void Dispose() - { - UserQuery.Dispose(); - PostsQuery.Dispose(); - } -} -``` - -## Blazor Component - -```html -@page "/users/{Id:int}" -@inject UserQueries Queries -@implements IDisposable - -

User Profile

- - - -
-

@user.Name

-

@user.Email

- -
- -

Recent Posts

- - - - @if (posts.Count == 0) - { -

No posts yet.

- } - else - { -
    - @foreach (var post in posts) - { -
  • - @post.Title - @post.CreatedAt.ToShortDateString() -
  • - } -
- } -
- -

Loading posts...

-
- -

Could not load posts: @error.Message

-
-
-
- -
Loading user profile...
-
- -
-

Failed to load user: @error.Message

- -
-
-
- -@code { - [Parameter] public int Id { get; set; } - - protected override void OnParametersSet() - { - Queries.UserQuery.SetArgs(Id); - Queries.PostsQuery.SetArgs(Id); - } - - public void Dispose() => Queries.Dispose(); -} -``` - -## Without Blazor (Console / Worker Service) - -```csharp -using var client = QueryClientFactory.Create(new QueryClientOptions -{ - StaleTime = TimeSpan.FromMinutes(1), -}); - -var query = client.CreateQuery(new QueryOptions -{ - KeyFactory = id => QueryKey.From("users", id), - Fetcher = (id, ct) => userApi.GetUserAsync(id, ct), -}); - -// Subscribe before pushing args to capture every state -using var subscription = query.State.Subscribe(state => -{ - Console.WriteLine($"Status: {state.Status}"); - - if (state.IsSuccess) - Console.WriteLine($"User: {state.CurrentData!.Name}"); - - if (state.IsFailure) - Console.WriteLine($"Error: {state.Error!.Message}"); -}); - -query.SetArgs(42); - -// Wait for the result -var result = await query.Success.FirstAsync(); -Console.WriteLine($"Done: {result.Name}"); -``` - -## Conditional Query (Disabled by Default) - -Sometimes you do not want to fetch until a condition is met — for example, a search box that only queries once the user has typed at least 3 characters: - -```csharp -var searchQuery = queryClient.CreateQuery(new QueryOptions> -{ - KeyFactory = term => QueryKey.From("users", "search", term), - Fetcher = (term, ct) => userApi.SearchAsync(term, ct), - IsEnabled = false, // do not fetch until we have a valid term -}); - -// In your OnSearchTermChanged handler: -if (term.Length >= 3) -{ - searchQuery.SetEnabled(true); - searchQuery.SetArgs(term); -} -else -{ - searchQuery.SetEnabled(false); -} -``` diff --git a/docs/doc/guides/infinite-queries.md b/docs/doc/guides/infinite-queries.md new file mode 100644 index 0000000..7f49b44 --- /dev/null +++ b/docs/doc/guides/infinite-queries.md @@ -0,0 +1,308 @@ +# Infinite Queries + +Infinite queries accumulate pages of data fetched sequentially with a **page parameter** — a cursor, offset, or page number. They share the same cache, deduplication, stale-while-revalidate, and invalidation guarantees as regular queries, but instead of holding a single `TData` value they hold an ordered list of pages and expose `FetchNextPage` / `FetchPreviousPage` for navigation. + +## Creating an Infinite Query + +Use `IQueryClient.CreateInfiniteQuery`: + +```csharp +IInfiniteQuery query = + queryClient.CreateInfiniteQuery(new InfiniteQueryOptions + { + KeyFactory = args => ..., + Fetcher = (args, page, ct) => ..., + InitialPageParam = ..., + GetNextPageParam = info => ..., + }); +``` + +`TData` is the type of a **single page** (e.g. `List`). `TPageParam` is the type of the cursor or page identifier (e.g. `int`, `Guid`, `DateTimeOffset`). + +### Required Options + +**`KeyFactory`** — same as regular queries. Keys identify the cache entry; all observers with the same key share one `InfiniteQuery` instance. + +```csharp +KeyFactory = listId => ["posts", listId] +``` + +**`Fetcher`** — receives args, the current page param, and a `CancellationToken`: + +```csharp +Fetcher = (listId, page, ct) => api.GetPostsAsync(listId, page, PageSize, ct) +``` + +**`InitialPageParam`** — the page param passed to `Fetcher` on the very first fetch: + +```csharp +InitialPageParam = 1 // offset / page-number pagination +InitialPageParam = Guid.Empty // cursor-based, "before all items" +``` + +**`GetNextPageParam`** — returns the param for the next page, or `PageParam.None` to signal there are no more pages. Receives an `InfinitePageInfo` containing the last loaded page and all accumulated pages: + +```csharp +// Page-number: stop when we get fewer items than requested +GetNextPageParam = info => + info.Page.Count < PageSize + ? PageParam.None + : PageParam.Some(info.PageParam + 1) + +// Cursor: use the last item's id +GetNextPageParam = info => + info.Page.LastOrDefault()?.Id is { } id + ? PageParam.Some(id) + : PageParam.None +``` + +### Optional Options + +| Option | Default | Description | +|---|---|---| +| `GetPreviousPageParam` | `null` | Enables `FetchPreviousPage`. Return `None` when at the start. | +| `MaxPages` | `null` (unbounded) | Trims oldest pages when exceeded on `FetchNextPage`; trims newest on `FetchPreviousPage`. | +| `StaleTime` | global | Stale time for `Invalidate()` no-op check. | +| `CacheTime` | global | Time to keep the entry after the last subscriber leaves. | +| `RefetchInterval` | `null` | Automatic polling (triggers `RefetchAll`). | +| `RetryHandler` | global | Custom retry logic. | +| `IsEnabled` | `true` | When `false`, no fetches run. | +| `DataComparer` | `null` | Per-page equality check to suppress duplicate emissions. | +| `InitialData` | `null` | Pre-seeds the first page. The query starts in `Success` state and immediately displays the seeded data while a background refetch refreshes it. | + +### InitialData + +`InitialData` lets you pre-populate the first page from a local cache, SSR payload, or any synchronous source. The query starts in `Success` state — no loading flash — and immediately triggers a background refetch to stay fresh. + +```csharp +public readonly IInfiniteQuery, int> PostsQuery = + queryClient.CreateInfiniteQuery(new InfiniteQueryOptions, int> + { + KeyFactory = boardId => ["posts", boardId], + Fetcher = (boardId, page, ct) => api.GetPostsAsync(boardId, page, PageSize, ct), + InitialPageParam = 1, + GetNextPageParam = info => + info.Page.Count < PageSize ? PageParam.None : PageParam.Some(info.PageParam + 1), + InitialData = cachedFirstPage, // IReadOnlyList from local storage / SSR + }); +``` + +Only the first page is seeded. Subsequent pages must be fetched via `FetchNextPage`. The seeded page uses `InitialPageParam` as its page parameter. + +## PageParam\ + +`PageParam` is a small discriminated union that avoids the C# generic nullable ambiguity for value-type page params. + +```csharp +PageParam.None // no more pages +PageParam.Some(2) // next page param is 2 +(PageParam)2 // implicit conversion — equivalent to Some(2) +``` + +## Fetching Pages + +### Initial Load + +Call `SetArgs` to provide the args. The first fetch uses `InitialPageParam`: + +```csharp +query.SetArgs(listId); +``` + +### FetchNextPage + +Appends the next page. The page param comes from `GetNextPageParam` applied to the last loaded page. No-op when `HasNextPage` is `false` or no pages have been loaded yet. + +```csharp +query.FetchNextPage(); +``` + +During the fetch, `IsFetchingNextPage` is `true` and `Status` remains `Success` — existing pages stay visible. + +### FetchPreviousPage + +Prepends the previous page. Requires `GetPreviousPageParam` to be configured. No-op when `HasPreviousPage` is `false`. + +```csharp +query.FetchPreviousPage(); +``` + +### Invalidate / Refetch + +Both re-fetch **all currently loaded pages** sequentially (rebuilding `Pages` and `PageParams` from scratch), matching TanStack Query behavior. Existing pages are carried in the `Fetching` state for stale-while-revalidate display. + +```csharp +query.Invalidate(); // respects StaleTime +query.Refetch(); // always re-fetches all pages +``` + +Client-level invalidation targets infinite queries the same way as regular queries: + +```csharp +queryClient.Invalidate(["posts", listId]); +queryClient.Invalidate(key => key.ToString().StartsWith("posts")); +``` + +## State + +### InfiniteQueryState\ + +Every state transition emits an immutable `InfiniteQueryState` snapshot: + +```csharp +state.Status // Idle | Fetching | Success | Failure +state.Pages // IReadOnlyList — all loaded pages, oldest first +state.PageParams // IReadOnlyList — PageParams[i] fetched Pages[i] +state.Error // Exception? — last fetch failure + +state.HasNextPage // true when GetNextPageParam returns Some(...) +state.HasPreviousPage // true when GetPreviousPageParam returns Some(...) +state.IsFetchingNextPage // true while FetchNextPage is in progress (Status stays Success) +state.IsFetchingPreviousPage // true while FetchPreviousPage is in progress + +state.IsIdle // Status == Idle +state.IsFetching // Status == Fetching (full RefetchAll) +state.IsSuccess // Status == Success +state.IsFailure // Status == Failure +state.HasData // Pages.Count > 0 +state.HasError // Error is not null +``` + +When `IsFetching` is true during a RefetchAll, the state still carries the previous `Pages` and `PageParams` so you can show stale content while rebuilding. + +### Subscribing + +```csharp +// All state transitions — replays current state to new subscribers +query.State.Subscribe(state => +{ + var allItems = state.Pages.SelectMany(p => p).ToList(); + Render(allItems, state.HasNextPage, state.IsFetchingNextPage); +}); + +// Emits all pages after a full settle (not during IsFetchingNextPage/Previous) +query.Success.Subscribe(pages => Console.WriteLine($"Loaded {pages.Count} pages")); + +// Exception on each failed fetch +query.Failure.Subscribe(e => ShowError(e)); + +// Final state after every fetch (success or failure) +query.Settled.Subscribe(_ => HideGlobalSpinner()); +``` + +## Blazor Components + +`` and `` in `DotNetQuery.Blazor` handle subscriptions and re-rendering automatically. Both receive the full `InfiniteQueryState` as the `Content` context so the template can access pages, page counts, and loading flags in one place. + +### \ (recommended) + +Shows `Content` whenever at least one page is available — during background refetches and while loading more pages. Shows `Loading` only before the first page arrives. + +```razor + + + @foreach (var post in state.Pages.SelectMany(p => p)) + { + + } + + @if (state.HasNextPage || state.IsFetchingNextPage) + { + + } + + + + +``` + +### \ + +Shows `Content` only when `IsSuccess && HasData`. Reverts to `Loading` during full background refetches (same strict behavior as ``). + +```razor + + + @foreach (var post in state.Pages.SelectMany(p => p)) + { + + } + + + + +``` + +| Scenario | InfiniteSuspense | InfiniteTransition | +|---|---|---| +| Initial load (no pages) | Loading | Loading | +| Full refetch (stale pages available) | Loading | Content | +| FetchNextPage in progress | Content | Content | +| Success | Content | Content | +| Failure (no pages) | Failure | Failure | +| Failure (has pages) | Failure | Content | + +## Service/Facade Pattern + +Keep infinite queries in a dedicated service class, consistent with regular queries: + +```csharp +public sealed class PostsService(IQueryClient queryClient, IPostsApi api) : IDisposable +{ + private const int PageSize = 20; + + public readonly IInfiniteQuery, int> PostsQuery = + queryClient.CreateInfiniteQuery(new InfiniteQueryOptions, int> + { + KeyFactory = boardId => ["posts", boardId], + Fetcher = (boardId, page, ct) => api.GetPostsAsync(boardId, page, PageSize, ct), + InitialPageParam = 1, + GetNextPageParam = info => + info.Page.Count < PageSize ? PageParam.None : PageParam.Some(info.PageParam + 1), + StaleTime = TimeSpan.FromMinutes(1), + }); + + public void Dispose() => PostsQuery.Dispose(); +} +``` + +```razor +@page "/boards/{BoardId:guid}" +@inject PostsService Posts + + + + @foreach (var post in state.Pages.SelectMany(p => p)) + { + + } + @if (state.HasNextPage) + { + + } + +

Loading posts…

+

@e.Message

+
+ +@code { + [Parameter] public Guid BoardId { get; set; } + + protected override void OnParametersSet() => Posts.PostsQuery.SetArgs(BoardId); +} +``` + +## Cleaning Up + +Infinite queries implement `IDisposable`. Dispose the query (or the service that owns it) when done: + +```csharp +query.Dispose(); +``` + +`` and `` dispose their internal subscriptions automatically when the component is removed from the render tree. diff --git a/docs/doc/toc.yml b/docs/doc/toc.yml index 9e4dbd7..e76719d 100644 --- a/docs/doc/toc.yml +++ b/docs/doc/toc.yml @@ -6,6 +6,8 @@ items: - name: Queries href: guides/queries.md + - name: Infinite Queries + href: guides/infinite-queries.md - name: Mutations href: guides/mutations.md - name: Prefetching @@ -24,13 +26,5 @@ href: guides/observability.md - name: Server-Side Rendering href: guides/ssr.md -- name: Examples - items: - - name: Fetching Data - href: examples/queries.md - - name: Creating & Updating Data - href: examples/mutations.md - - name: Blazor Integration - href: examples/blazor.md - name: Contributing href: contributing.md diff --git a/docs/llms.txt b/docs/llms.txt index ee93b8e..641f302 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -93,6 +93,10 @@ public interface IQueryClient : IDisposable // Gets or creates a query. Queries with the same key share one cache entry. IQuery CreateQuery(QueryOptions options); + // Gets or creates an infinite (paginated) query. Queries with the same key share one cache entry. + IInfiniteQuery CreateInfiniteQuery( + InfiniteQueryOptions options); + // Creates a mutation. InvalidateKeys are wired automatically on success. IMutation CreateMutation(MutationOptions options); @@ -212,6 +216,158 @@ A new `SetArgs` while a fetch is in progress cancels the in-flight fetch (`.Swit --- +## IInfiniteQuery + +Returned by `IQueryClient.CreateInfiniteQuery`. Accumulates pages of data fetched sequentially with a **page parameter** (cursor, offset, or page number). Shares the same cache, deduplication, stale-while-revalidate, and invalidation guarantees as `IQuery`. + +### PageParam + +A small discriminated union used by `GetNextPageParam` / `GetPreviousPageParam` to signal whether a next/previous page exists. Necessary because C# generic nullable (`TPageParam?`) is ambiguous for value types. + +```csharp +// Signal "no more pages" +PageParam.None + +// Signal "next page param is 2" +PageParam.Some(2) + +// Implicit conversion from the underlying type +PageParam p = 2; // equivalent to Some(2) +``` + +### InfiniteQueryOptions + +```csharp +new InfiniteQueryOptions, int> +{ + // Required + KeyFactory = id => ["items", id], + Fetcher = (id, page, ct) => api.GetItemsAsync(id, page, ct), + InitialPageParam = 1, + GetNextPageParam = info => + info.Page.Count < PageSize ? PageParam.None : PageParam.Some(info.PageParam + 1), + + // Optional + GetPreviousPageParam = null, // omit for forward-only lists + MaxPages = null, // unbounded by default; set to trim oldest pages + StaleTime = null, + CacheTime = null, + RefetchInterval = null, + RetryHandler = null, + IsEnabled = true, + DataComparer = null, + InitialData = null, // pre-seed first page; query starts Success, refetches in background +} +``` + +`info` in `GetNextPageParam` is `InfinitePageInfo` — it carries the boundary page and all loaded pages so you can derive the next cursor from the data itself: + +```csharp +GetNextPageParam = info => info.Page.LastOrDefault()?.CreatedAt is { } cursor + ? PageParam.Some(cursor) + : PageParam.None +``` + +### IInfiniteQuery + +```csharp +public interface IInfiniteQuery : IQuery +{ + InfiniteQueryState CurrentState { get; } + + void SetArgs(TArgs args); + void SetEnabled(bool enabled); + + // Fetch the next page (no-op when HasNextPage is false or no pages loaded yet) + void FetchNextPage(); + + // Fetch the previous page (no-op when HasPreviousPage is false or no GetPreviousPageParam) + void FetchPreviousPage(); + + IObservable> State { get; } + IObservable> Success { get; } // all pages after full settle + IObservable Failure { get; } + IObservable> Settled { get; } + + void Detach(); +} +``` + +### InfiniteQueryState + +```csharp +record InfiniteQueryState +{ + QueryStatus Status { get; } // Idle | Fetching | Success | Failure + IReadOnlyList Pages { get; } // all loaded pages, oldest first + IReadOnlyList PageParams { get; } // PageParams[i] fetched Pages[i] + Exception? Error { get; } + bool HasNextPage { get; } + bool HasPreviousPage { get; } + bool IsFetchingNextPage { get; } // Status stays Success; existing pages remain visible + bool IsFetchingPreviousPage { get; } // Status stays Success; existing pages remain visible + + bool IsIdle { get; } // Status == Idle + bool IsFetching { get; } // Status == Fetching (full refetch of all pages) + bool IsSuccess { get; } + bool IsFailure { get; } + bool HasData { get; } // Pages.Count > 0 + bool HasError { get; } +} +``` + +`Invalidate()` / `Refetch()` re-fetches **all** currently loaded pages sequentially (TanStack behavior), rebuilding `Pages` and `PageParams`. Existing pages are carried in the `Fetching` state for stale-while-revalidate display. + +### Typical usage + +```csharp +public sealed class ItemService(IQueryClient client, IApi api) : IDisposable +{ + private const int PageSize = 20; + + public readonly IInfiniteQuery, int> ItemsQuery = + client.CreateInfiniteQuery(new InfiniteQueryOptions, int> + { + KeyFactory = listId => ["items", listId], + Fetcher = (listId, page, ct) => api.GetItemsAsync(listId, page, PageSize, ct), + InitialPageParam = 1, + GetNextPageParam = info => + info.Page.Count < PageSize ? PageParam.None : PageParam.Some(info.PageParam + 1), + }); + + public void Dispose() => ItemsQuery.Dispose(); +} +``` + +```razor +@inject ItemService Items + + + + @foreach (var item in state.Pages.SelectMany(p => p)) + { +
@item.Name
+ } + @if (state.HasNextPage) + { + + } +
+

Loading…

+

@e.Message

+
+ +@code { + [Parameter] public Guid ListId { get; set; } + protected override void OnParametersSet() => Items.ItemsQuery.SetArgs(ListId); +} +``` + +--- + ## IMutation ```csharp @@ -419,6 +575,59 @@ Use when: data updates frequently and you want smooth background refreshes witho | Failure (has old data) | Failure | old Content | | Failure (no old data) | Failure | Failure | +### `` + +For `IInfiniteQuery`. Shows `Loading` during initial load and full background refetches. Shows `Content` once data is available (`IsSuccess && HasData`). `Content` receives the full `InfiniteQueryState` so the template can render all pages and wire up "Load more". + +```razor + + + @foreach (var item in state.Pages.SelectMany(p => p)) + { +
@item.Name
+ } + @if (state.HasNextPage) + { + + } +
+

Loading…

+

Error: @e.Message

+
+``` + +### `` + +For `IInfiniteQuery`. Stale-while-revalidate: shows `Content` whenever `HasData`, including during full background refetches and `FetchNextPage`. Shows `Loading` only before the very first page arrives. + +```razor + + + @foreach (var item in state.Pages.SelectMany(p => p)) + { +
@item.Name
+ } + @if (state.HasNextPage) + { + + } +
+

Loading…

+

@e.Message

+
+``` + +| Scenario | InfiniteSuspense | InfiniteTransition | +|---|---|---| +| Initial load (no pages) | Loading | Loading | +| Full refetch with stale pages | Loading | old Content | +| FetchNextPage in progress | Content + IsFetchingNextPage | Content + IsFetchingNextPage | +| Success | Content | Content | +| Failure (no pages) | Failure | Failure | +| Failure (has pages) | Failure | old Content | + +Both components require three type parameters inferred from the `Query` parameter. + ### `` Side-effect component. Registers browser `visibilitychange` and `online` events. When the tab regains focus or network reconnects, calls `queryClient.Invalidate(_ => true)`. Respects `StaleTime` — fresh data is never re-fetched. diff --git a/src/DotNetQuery.Blazor/InfiniteSuspense.razor b/src/DotNetQuery.Blazor/InfiniteSuspense.razor new file mode 100644 index 0000000..47cd99d --- /dev/null +++ b/src/DotNetQuery.Blazor/InfiniteSuspense.razor @@ -0,0 +1,74 @@ +@typeparam TArgs +@typeparam TData +@typeparam TPageParam +@implements IDisposable + +@if (_state.IsSuccess && _state.HasData) +{ + @Content(_state) +} +else if (_state.IsFailure && _state.Error is { } error) +{ + @Failure?.Invoke(error) +} +else +{ + @Loading +} + +@code +{ + private InfiniteQueryState _state = InfiniteQueryState.CreateIdle(); + private IDisposable? _subscription; + private IInfiniteQuery? _subscribedQuery; + + /// + /// The infinite query whose state drives rendering. Required. + /// + [Parameter, EditorRequired] + public IInfiniteQuery Query { get; set; } = default!; + + /// + /// Rendered when data has been successfully loaded. Receives the full query state as context, + /// including all accumulated pages, , + /// and . Required. + /// + [Parameter, EditorRequired] + public RenderFragment> Content { get; set; } = default!; + + /// + /// Rendered while the query is Idle or Fetching, including during background + /// full refetches even when stale pages are available. + /// Defaults to nothing if not provided. + /// + [Parameter] + public RenderFragment? Loading { get; set; } + + /// + /// Rendered when the query has failed and no pages are available to fall back on. + /// Receives the exception as context. Defaults to nothing if not provided. + /// + [Parameter] + public RenderFragment? Failure { get; set; } + + protected override void OnParametersSet() + { + if (ReferenceEquals(Query, _subscribedQuery)) + { + return; + } + + _subscribedQuery = Query; + _subscription?.Dispose(); + _subscription = Query.State.Subscribe(state => + { + _state = state; + _ = InvokeAsync(StateHasChanged); + }); + } + + public void Dispose() + { + _subscription?.Dispose(); + } +} diff --git a/src/DotNetQuery.Blazor/InfiniteSuspense.razor.cs b/src/DotNetQuery.Blazor/InfiniteSuspense.razor.cs new file mode 100644 index 0000000..e2b2e9a --- /dev/null +++ b/src/DotNetQuery.Blazor/InfiniteSuspense.razor.cs @@ -0,0 +1,18 @@ +namespace DotNetQuery.Blazor; + +/// +/// Renders infinite query state with explicit loading and failure templates. +/// Shows Loading while the query is Idle or Fetching (including full +/// background refetches), Content once data has been successfully loaded, and +/// Failure when the query fails with no pages to fall back on. +/// +/// +/// Use when you want a clean loading +/// state on every full refetch, even if stale pages are available. +/// For stale-while-revalidate semantics — keeping existing pages visible during background +/// refetches and next-page fetches — use instead. +/// +/// The type of arguments that identify the resource to fetch. +/// The type of a single page of data. +/// The type of the page parameter (cursor / offset). +public partial class InfiniteSuspense; diff --git a/src/DotNetQuery.Blazor/InfiniteTransition.razor b/src/DotNetQuery.Blazor/InfiniteTransition.razor new file mode 100644 index 0000000..82d2879 --- /dev/null +++ b/src/DotNetQuery.Blazor/InfiniteTransition.razor @@ -0,0 +1,73 @@ +@typeparam TArgs +@typeparam TData +@typeparam TPageParam +@implements IDisposable + +@if (_state.HasData) +{ + @Content(_state) +} +else if (_state.IsFailure && _state.Error is { } error) +{ + @Failure?.Invoke(error) +} +else +{ + @Loading +} + +@code +{ + private InfiniteQueryState _state = InfiniteQueryState.CreateIdle(); + private IDisposable? _subscription; + private IInfiniteQuery? _subscribedQuery; + + /// + /// The infinite query whose state drives rendering. Required. + /// + [Parameter, EditorRequired] + public IInfiniteQuery Query { get; set; } = default!; + + /// + /// Rendered whenever at least one page is available — including during background full refetches + /// and while is true. + /// Receives the full query state as context. Required. + /// + [Parameter, EditorRequired] + public RenderFragment> Content { get; set; } = default!; + + /// + /// Rendered only before any page has been loaded (initial fetch with no cached data). + /// Defaults to nothing if not provided. + /// + [Parameter] + public RenderFragment? Loading { get; set; } + + /// + /// Rendered when the query has failed and no pages are available to fall back on. + /// Receives the exception as context. Defaults to nothing if not provided. + /// + [Parameter] + public RenderFragment? Failure { get; set; } + + protected override void OnParametersSet() + { + if (ReferenceEquals(Query, _subscribedQuery)) + { + return; + } + + _subscribedQuery = Query; + _subscription?.Dispose(); + _subscription = Query.State.Subscribe(state => + { + _state = state; + _ = InvokeAsync(StateHasChanged); + }); + } + + public void Dispose() + { + _subscription?.Dispose(); + } +} diff --git a/src/DotNetQuery.Blazor/InfiniteTransition.razor.cs b/src/DotNetQuery.Blazor/InfiniteTransition.razor.cs new file mode 100644 index 0000000..253c84d --- /dev/null +++ b/src/DotNetQuery.Blazor/InfiniteTransition.razor.cs @@ -0,0 +1,19 @@ +namespace DotNetQuery.Blazor; + +/// +/// Renders infinite query state with stale-while-revalidate semantics. +/// Shows Content whenever at least one page is available — including during background +/// full refetches and while additional pages are loading — so existing results stay visible +/// without flicker. Shows Loading only on the very first fetch before any data arrives, +/// and Failure only when the query fails with no pages to fall back on. +/// +/// +/// Use for lists that update frequently +/// or where the "Load more" interaction should not hide previously loaded pages. +/// For a strict loading state on every full refetch, use +/// instead. +/// +/// The type of arguments that identify the resource to fetch. +/// The type of a single page of data. +/// The type of the page parameter (cursor / offset). +public partial class InfiniteTransition; diff --git a/src/DotNetQuery.Core/IInfiniteQuery.cs b/src/DotNetQuery.Core/IInfiniteQuery.cs new file mode 100644 index 0000000..688e628 --- /dev/null +++ b/src/DotNetQuery.Core/IInfiniteQuery.cs @@ -0,0 +1,65 @@ +namespace DotNetQuery.Core; + +/// +/// An infinite (paginated) query that accumulates pages of fetched +/// with page parameters of type . +/// +/// The type of the arguments that identify which resource to fetch. +/// The type of a single page of data. +/// The type of the page parameter (cursor/offset). +public interface IInfiniteQuery : IQuery +{ + /// The current state snapshot. Can be read synchronously without subscribing. + InfiniteQueryState CurrentState { get; } + + /// + /// Sets the args that drive the cache key and fetching. When args change, the query switches to + /// the cache entry for the newly derived key and triggers a fetch if the query is enabled. + /// + void SetArgs(TArgs args); + + /// + /// Enables or disables the query. When false, fetching is suspended even if the query + /// is invalidated or new args are pushed. Pass true to resume. + /// + void SetEnabled(bool enabled); + + /// + /// Fetches the next page using the cursor derived from the last loaded page via + /// GetNextPageParam. No-op when no pages are loaded yet or + /// is false. + /// + void FetchNextPage(); + + /// + /// Fetches the previous page using the cursor derived from the first loaded page via + /// GetPreviousPageParam. No-op when no pages are loaded yet, + /// is false, + /// or no GetPreviousPageParam was configured. + /// + void FetchPreviousPage(); + + /// + /// Emits on every state transition. Replays the latest state to new subscribers. + /// + IObservable> State { get; } + + /// + /// Emits the full accumulated page list after each fetch fully settles — not during + /// or + /// . + /// + IObservable> Success { get; } + + /// Emits the on each failed fetch. + IObservable Failure { get; } + + /// Emits the final state after each fetch completes, regardless of success or failure. + IObservable> Settled { get; } + + /// + /// Removes this query from the client cache while keeping active subscriptions alive. + /// Use to also tear down all subscriptions and release resources. + /// + void Detach(); +} diff --git a/src/DotNetQuery.Core/IQueryClient.cs b/src/DotNetQuery.Core/IQueryClient.cs index 5173d38..38d4d65 100644 --- a/src/DotNetQuery.Core/IQueryClient.cs +++ b/src/DotNetQuery.Core/IQueryClient.cs @@ -12,6 +12,21 @@ public interface IQueryClient : IDisposable /// IQuery CreateQuery(QueryOptions options); + /// + /// Gets or creates an infinite (paginated) query. Queries with the same key share one cache entry. + /// Use and + /// to accumulate pages. + /// + IInfiniteQuery CreateInfiniteQuery( + InfiniteQueryOptions options + ); + + /// + /// Creates a mutation. If is set, + /// the specified keys are invalidated automatically on success. + /// + IMutation CreateMutation(MutationOptions options); + /// /// Marks all queries matching the given key as stale and triggers a re-fetch. /// @@ -21,10 +36,4 @@ public interface IQueryClient : IDisposable /// Marks all queries whose key matches the predicate as stale and triggers a re-fetch. /// void Invalidate(Func predicate); - - /// - /// Creates a mutation. If is set, - /// the specified keys are invalidated automatically on success. - /// - IMutation CreateMutation(MutationOptions options); } diff --git a/src/DotNetQuery.Core/InfinitePageInfo.cs b/src/DotNetQuery.Core/InfinitePageInfo.cs new file mode 100644 index 0000000..0c38bde --- /dev/null +++ b/src/DotNetQuery.Core/InfinitePageInfo.cs @@ -0,0 +1,24 @@ +namespace DotNetQuery.Core; + +/// +/// Contextual information passed to GetNextPageParam and GetPreviousPageParam +/// to derive the cursor or offset for the next fetch. +/// +/// The type of a single page of data. +/// The type of the page parameter (cursor/offset). +/// +/// The boundary page — the last page for GetNextPageParam, +/// the first page for GetPreviousPageParam. +/// +/// All currently loaded pages, in order from oldest to newest. +/// The parameter that was used to fetch . +/// +/// The parameters used to fetch each page in . +/// AllPageParams[i] corresponds to AllPages[i]. +/// +public readonly record struct InfinitePageInfo( + TData Page, + IReadOnlyList AllPages, + TPageParam PageParam, + IReadOnlyList AllPageParams +); diff --git a/src/DotNetQuery.Core/InfiniteQueryOptions.cs b/src/DotNetQuery.Core/InfiniteQueryOptions.cs new file mode 100644 index 0000000..869aa10 --- /dev/null +++ b/src/DotNetQuery.Core/InfiniteQueryOptions.cs @@ -0,0 +1,73 @@ +namespace DotNetQuery.Core; + +/// +/// Configuration for an infinite (paginated) query created via +/// . +/// +/// The type of the arguments that identify which resource to fetch. +/// The type of a single page of data. +/// The type of the page parameter (cursor/offset). +public sealed record InfiniteQueryOptions +{ + /// Derives the cache key from the current args. + public required Func KeyFactory { get; init; } + + /// Fetches a single page of data for the given args and page parameter. + public required Func> Fetcher { get; init; } + + /// The page parameter used for the very first page fetch. + public required TPageParam InitialPageParam { get; init; } + + /// + /// Derives the page parameter for the next page from the boundary info of the last loaded page. + /// Return to signal there are no more pages. + /// + public required Func, PageParam> GetNextPageParam { get; init; } + + /// + /// Optionally derives the page parameter for the previous page from the boundary info of the + /// first loaded page. When null, + /// is always a no-op. + /// Return to signal there are no earlier pages. + /// + public Func, PageParam>? GetPreviousPageParam { get; init; } + + /// + /// Maximum number of pages to keep. When exceeded during FetchNextPage, the oldest pages + /// are dropped from the front; during FetchPreviousPage, the newest are dropped from the back. + /// null means unbounded (default). + /// + public int? MaxPages { get; init; } + + /// Overrides the global for this query. + public TimeSpan? StaleTime { get; init; } + + /// Overrides the global for this query. + public TimeSpan? CacheTime { get; init; } + + /// Overrides the global for this query. + public TimeSpan? RefetchInterval { get; init; } + + /// Overrides the global for this query. + public IRetryHandler? RetryHandler { get; init; } + + /// + /// When false, the query will not fetch until explicitly enabled. + /// Defaults to true. + /// + public bool IsEnabled { get; init; } = true; + + /// + /// Equality comparer applied per page to detect structurally identical pages. + /// Defaults to . + /// + public IEqualityComparer? DataComparer { get; init; } + + /// + /// Pre-seeds the first page so the query starts in a Success state without an initial + /// network fetch. The seeded page is immediately displayed while a background fetch refreshes + /// it. Only the first page is seeded; subsequent pages require + /// . + /// + public TData? InitialData { get; init; } +} diff --git a/src/DotNetQuery.Core/InfiniteQueryState.cs b/src/DotNetQuery.Core/InfiniteQueryState.cs new file mode 100644 index 0000000..4617d78 --- /dev/null +++ b/src/DotNetQuery.Core/InfiniteQueryState.cs @@ -0,0 +1,157 @@ +namespace DotNetQuery.Core; + +/// +/// An immutable snapshot of an infinite query's current state. +/// Use the static factory methods to create instances. +/// +/// The type of a single page of data. +/// The type of the page parameter (cursor/offset). +public sealed record InfiniteQueryState +{ + /// The current lifecycle status of the query. + public QueryStatus Status { get; private set; } + + /// All currently loaded pages, ordered from oldest to newest. + public IReadOnlyList Pages { get; private set; } = []; + + /// + /// The page parameters used to fetch each page in . + /// PageParams[i] was the parameter used to fetch Pages[i]. + /// + public IReadOnlyList PageParams { get; private set; } = []; + + /// The exception from the most recent failed fetch. null when not in a failure state. + public Exception? Error { get; private set; } + + /// true when a next page is available to fetch. + public bool HasNextPage { get; private set; } + + /// true when a previous page is available to fetch. + public bool HasPreviousPage { get; private set; } + + /// + /// true while a is in progress. + /// Existing pages remain visible during this state. + /// + public bool IsFetchingNextPage { get; private set; } + + /// + /// true while a is in progress. + /// Existing pages remain visible during this state. + /// + public bool IsFetchingPreviousPage { get; private set; } + + /// true when is . + public bool IsIdle => Status == QueryStatus.Idle; + + /// true when is . + public bool IsFetching => Status == QueryStatus.Fetching; + + /// true when is . + public bool IsSuccess => Status == QueryStatus.Success; + + /// true when is . + public bool IsFailure => Status == QueryStatus.Failure; + + /// true when at least one page has been successfully loaded. + public bool HasData => Pages.Count > 0; + + /// true when is not null. + public bool HasError => Error is not null; + + /// Creates an state with no pages. + public static InfiniteQueryState CreateIdle() => new() { Status = QueryStatus.Idle }; + + /// + /// Creates a state that carries existing pages for + /// stale-while-revalidate display during a full refetch. + /// + public static InfiniteQueryState CreateFetching( + IReadOnlyList pages, + IReadOnlyList pageParams, + bool hasNextPage, + bool hasPreviousPage + ) => + new() + { + Status = QueryStatus.Fetching, + Pages = pages, + PageParams = pageParams, + HasNextPage = hasNextPage, + HasPreviousPage = hasPreviousPage, + }; + + /// + /// Creates a success state with set. + /// Existing pages remain visible while the next page loads. + /// + public static InfiniteQueryState CreateFetchingNext( + IReadOnlyList pages, + IReadOnlyList pageParams, + bool hasNextPage, + bool hasPreviousPage + ) => + new() + { + Status = QueryStatus.Success, + Pages = pages, + PageParams = pageParams, + HasNextPage = hasNextPage, + HasPreviousPage = hasPreviousPage, + IsFetchingNextPage = true, + }; + + /// + /// Creates a success state with set. + /// Existing pages remain visible while the previous page loads. + /// + public static InfiniteQueryState CreateFetchingPrevious( + IReadOnlyList pages, + IReadOnlyList pageParams, + bool hasNextPage, + bool hasPreviousPage + ) => + new() + { + Status = QueryStatus.Success, + Pages = pages, + PageParams = pageParams, + HasNextPage = hasNextPage, + HasPreviousPage = hasPreviousPage, + IsFetchingPreviousPage = true, + }; + + /// Creates a settled state with the given pages. + public static InfiniteQueryState CreateSuccess( + IReadOnlyList pages, + IReadOnlyList pageParams, + bool hasNextPage, + bool hasPreviousPage + ) => + new() + { + Status = QueryStatus.Success, + Pages = pages, + PageParams = pageParams, + HasNextPage = hasNextPage, + HasPreviousPage = hasPreviousPage, + }; + + /// Creates a state, preserving pages from the last successful fetch. + public static InfiniteQueryState CreateFailure( + Exception error, + IReadOnlyList pages, + IReadOnlyList pageParams, + bool hasNextPage, + bool hasPreviousPage + ) => + new() + { + Status = QueryStatus.Failure, + Error = error, + Pages = pages, + PageParams = pageParams, + HasNextPage = hasNextPage, + HasPreviousPage = hasPreviousPage, + }; +} diff --git a/src/DotNetQuery.Core/Internals/EffectiveInfiniteQueryOptions.cs b/src/DotNetQuery.Core/Internals/EffectiveInfiniteQueryOptions.cs new file mode 100644 index 0000000..838ed9e --- /dev/null +++ b/src/DotNetQuery.Core/Internals/EffectiveInfiniteQueryOptions.cs @@ -0,0 +1,31 @@ +namespace DotNetQuery.Core.Internals; + +internal sealed record EffectiveInfiniteQueryOptions +{ + public required Func> Fetcher { get; init; } + + public required TPageParam InitialPageParam { get; init; } + + public required Func, PageParam> GetNextPageParam { get; init; } + + public required Func< + InfinitePageInfo, + PageParam + >? GetPreviousPageParam { get; init; } + + public required int? MaxPages { get; init; } + + public required TimeSpan StaleTime { get; init; } + + public required TimeSpan CacheTime { get; init; } + + public required IRetryHandler RetryHandler { get; init; } + + public required bool IsEnabled { get; init; } + + public required TimeSpan? RefetchInterval { get; init; } + + public required IEqualityComparer DataComparer { get; init; } + + public required TData? InitialData { get; init; } +} diff --git a/src/DotNetQuery.Core/Internals/EffectiveQueryOptions.cs b/src/DotNetQuery.Core/Internals/EffectiveQueryOptions.cs index 56b417f..6f718d9 100644 --- a/src/DotNetQuery.Core/Internals/EffectiveQueryOptions.cs +++ b/src/DotNetQuery.Core/Internals/EffectiveQueryOptions.cs @@ -1,6 +1,6 @@ namespace DotNetQuery.Core.Internals; -internal record EffectiveQueryOptions +internal sealed record EffectiveQueryOptions { public required Func> Fetcher { get; init; } diff --git a/src/DotNetQuery.Core/Internals/InfiniteQuery.cs b/src/DotNetQuery.Core/Internals/InfiniteQuery.cs new file mode 100644 index 0000000..85b4de2 --- /dev/null +++ b/src/DotNetQuery.Core/Internals/InfiniteQuery.cs @@ -0,0 +1,533 @@ +namespace DotNetQuery.Core.Internals; + +internal sealed class InfiniteQuery : IQuery, IQueryInspector +{ + private enum FetchDirection + { + RefetchAll, + FetchNext, + FetchPrevious, + } + + private readonly QueryKey _key; + private readonly TArgs _args; + private readonly EffectiveInfiniteQueryOptions _options; + private readonly IScheduler _scheduler; + private readonly QueryInstrumentation _instrumentation; + private readonly CancellationTokenSource _cancellationTokenSource = new(); + private readonly BehaviorSubject> _state; + private readonly Subject _command = new(); + private readonly CompositeDisposable _subscriptions = []; + private readonly Lock _syncRoot = new(); + private readonly List _pages = []; + private readonly List _pageParams = []; + private DateTimeOffset? _lastSuccessAt; + private int _subscriberCount; + private bool _isStale; + private bool _disposed; + + public InfiniteQuery( + QueryKey key, + TArgs args, + EffectiveInfiniteQueryOptions options, + IScheduler scheduler, + QueryInstrumentation instrumentation + ) + { + _key = key; + _args = args; + _options = options; + _scheduler = scheduler; + _instrumentation = instrumentation; + if (options.InitialData is { } initialData) + { + _pages.Add(initialData); + _pageParams.Add(options.InitialPageParam); + var (hasNext, hasPrev) = ComputeHasMorePages(_pages, _pageParams); + _state = new BehaviorSubject>( + InfiniteQueryState.CreateSuccess([.. _pages], [.. _pageParams], hasNext, hasPrev) + ); + } + else + { + _state = new BehaviorSubject>( + InfiniteQueryState.CreateIdle() + ); + } + + _subscriptions.Add( + _command.Select(cmd => Observable.FromAsync(ct => ExecuteAsync(cmd, ct))).Switch().Subscribe() + ); + + if (options.RefetchInterval is { } interval) + { + _subscriptions.Add( + Observable.Interval(interval, _scheduler).Subscribe(_ => _command.OnNext(FetchDirection.RefetchAll)) + ); + } + } + + public QueryKey Key => _key; + + public TimeSpan CacheTime => _options.CacheTime; + + public InfiniteQueryState CurrentState => _state.Value; + + public QueryStatus Status => _state.Value.Status; + + public object? CurrentData + { + get + { + lock (_syncRoot) + { + return _pages.Count > 0 ? (object)_pages.AsReadOnly() : null; + } + } + } + + public DateTimeOffset? LastUpdatedAt => _lastSuccessAt; + + public int ObserverCount => _subscriberCount; + + public IObservable StateChanged => _state.Select(_ => Unit.Default); + + public IObservable> State => + Observable.Create>(observer => + { + var subscription = _state.Subscribe(observer); + + lock (_syncRoot) + { + _subscriberCount++; + if (_subscriberCount == 1 && _isStale) + { + _isStale = false; + _command.OnNext(FetchDirection.RefetchAll); + } + } + + return () => + { + subscription.Dispose(); + lock (_syncRoot) + { + _subscriberCount--; + } + }; + }); + + public void FetchNextPage() => _command.OnNext(FetchDirection.FetchNext); + + public void FetchPreviousPage() => _command.OnNext(FetchDirection.FetchPrevious); + + public void Refetch() => _command.OnNext(FetchDirection.RefetchAll); + + public void Invalidate() + { + if (_lastSuccessAt is { } last && _scheduler.Now - last < _options.StaleTime) + { + return; + } + + lock (_syncRoot) + { + if (_subscriberCount > 0) + { + _command.OnNext(FetchDirection.RefetchAll); + } + else + { + _isStale = true; + } + } + } + + public void Cancel() => _cancellationTokenSource.Cancel(); + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _subscriptions.Dispose(); + _cancellationTokenSource.Cancel(); + _command.OnCompleted(); + _command.Dispose(); + _cancellationTokenSource.Dispose(); + _state.OnCompleted(); + _state.Dispose(); + } + + private async Task ExecuteAsync(FetchDirection direction, CancellationToken cancellationToken) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _cancellationTokenSource.Token + ); + var linkedToken = cts.Token; + + if (_disposed) + { + return; + } + + List snapshotPages; + List snapshotParams; + bool snapshotHasNext; + bool snapshotHasPrev; + + lock (_syncRoot) + { + snapshotPages = [.. _pages]; + snapshotParams = [.. _pageParams]; + snapshotHasNext = _state.Value.HasNextPage; + snapshotHasPrev = _state.Value.HasPreviousPage; + } + + // No-op checks before starting telemetry + if (!ShouldFetch(direction, snapshotPages, snapshotParams)) + { + return; + } + + using var activity = QueryTelemetry.ActivitySource.StartActivity(QueryTelemetryTags.ActivityQueryFetch); + activity?.SetTag(QueryTelemetryTags.TagQueryKey, _key.ToString()); + + var stopwatch = Stopwatch.StartNew(); + _instrumentation.RecordFetchStart(_key); + + try + { + switch (direction) + { + case FetchDirection.RefetchAll: + await ExecuteRefetchAllAsync( + snapshotPages, + snapshotParams, + snapshotHasNext, + snapshotHasPrev, + linkedToken + ); + break; + case FetchDirection.FetchNext: + await ExecuteFetchNextAsync( + snapshotPages, + snapshotParams, + snapshotHasNext, + snapshotHasPrev, + linkedToken + ); + break; + case FetchDirection.FetchPrevious: + await ExecuteFetchPreviousAsync( + snapshotPages, + snapshotParams, + snapshotHasNext, + snapshotHasPrev, + linkedToken + ); + break; + } + + _lastSuccessAt = _scheduler.Now; + stopwatch.Stop(); + + activity?.SetStatus(ActivityStatusCode.Ok); + _instrumentation.RecordFetchSuccess(_key, stopwatch.Elapsed.TotalMilliseconds); + } + catch (OperationCanceledException) when (linkedToken.IsCancellationRequested) + { + stopwatch.Stop(); + _instrumentation.RecordFetchCancelled(_key); + + if (!_disposed) + { + _state.OnNext( + snapshotPages.Count > 0 + ? InfiniteQueryState.CreateSuccess( + snapshotPages, + snapshotParams, + snapshotHasNext, + snapshotHasPrev + ) + : InfiniteQueryState.CreateIdle() + ); + } + } + catch (Exception error) + { + stopwatch.Stop(); + + activity?.SetTag(QueryTelemetryTags.TagErrorType, error.GetType().Name); + activity?.SetStatus(ActivityStatusCode.Error, error.Message); + _instrumentation.RecordFetchFailure(_key, stopwatch.Elapsed.TotalMilliseconds, error); + + if (!_disposed) + { + _state.OnNext( + InfiniteQueryState.CreateFailure( + error, + snapshotPages, + snapshotParams, + snapshotHasNext, + snapshotHasPrev + ) + ); + } + } + } + + private bool ShouldFetch(FetchDirection direction, List snapshotPages, List snapshotParams) + { + switch (direction) + { + case FetchDirection.RefetchAll: + return true; + + case FetchDirection.FetchNext: + if (snapshotPages.Count == 0) + { + return false; + } + + return _options + .GetNextPageParam( + new InfinitePageInfo( + snapshotPages[^1], + snapshotPages, + snapshotParams[^1], + snapshotParams + ) + ) + .HasValue; + + case FetchDirection.FetchPrevious: + if (snapshotPages.Count == 0 || _options.GetPreviousPageParam is null) + { + return false; + } + + return _options + .GetPreviousPageParam( + new InfinitePageInfo( + snapshotPages[0], + snapshotPages, + snapshotParams[0], + snapshotParams + ) + ) + .HasValue; + + default: + return false; + } + } + + private async Task ExecuteRefetchAllAsync( + List snapshotPages, + List snapshotParams, + bool snapshotHasNext, + bool snapshotHasPrev, + CancellationToken ct + ) + { + if (_disposed) + { + return; + } + + var paramsToFetch = snapshotParams.Count > 0 ? snapshotParams : [_options.InitialPageParam]; + + _state.OnNext( + InfiniteQueryState.CreateFetching( + snapshotPages, + snapshotParams, + snapshotHasNext, + snapshotHasPrev + ) + ); + + var newPages = new List(paramsToFetch.Count); + var newParams = new List(paramsToFetch.Count); + + foreach (var param in paramsToFetch) + { + ct.ThrowIfCancellationRequested(); + var page = await _options.RetryHandler.ExecuteAsync(tok => _options.Fetcher(_args, param, tok), ct); + newPages.Add(page); + newParams.Add(param); + } + + if (_disposed) + { + return; + } + + var (hasNext, hasPrev) = ComputeHasMorePages(newPages, newParams); + + lock (_syncRoot) + { + _pages.Clear(); + _pages.AddRange(newPages); + _pageParams.Clear(); + _pageParams.AddRange(newParams); + } + + _state.OnNext(InfiniteQueryState.CreateSuccess(newPages, newParams, hasNext, hasPrev)); + } + + private async Task ExecuteFetchNextAsync( + List snapshotPages, + List snapshotParams, + bool snapshotHasNext, + bool snapshotHasPrev, + CancellationToken ct + ) + { + if (_disposed || snapshotPages.Count == 0) + { + return; + } + + var nextParamResult = _options.GetNextPageParam( + new InfinitePageInfo( + snapshotPages[^1], + snapshotPages, + snapshotParams[^1], + snapshotParams + ) + ); + + if (!nextParamResult.HasValue) + { + return; + } + + var nextParam = nextParamResult.Value; + + _state.OnNext( + InfiniteQueryState.CreateFetchingNext( + snapshotPages, + snapshotParams, + snapshotHasNext, + snapshotHasPrev + ) + ); + + var page = await _options.RetryHandler.ExecuteAsync(tok => _options.Fetcher(_args, nextParam, tok), ct); + + if (_disposed) + { + return; + } + + List newPages; + List newParams; + + lock (_syncRoot) + { + _pages.Add(page); + _pageParams.Add(nextParam); + + if (_options.MaxPages is { } maxPages && _pages.Count > maxPages) + { + var removeCount = _pages.Count - maxPages; + _pages.RemoveRange(0, removeCount); + _pageParams.RemoveRange(0, removeCount); + } + + newPages = [.. _pages]; + newParams = [.. _pageParams]; + } + + var (hasNext, hasPrev) = ComputeHasMorePages(newPages, newParams); + _state.OnNext(InfiniteQueryState.CreateSuccess(newPages, newParams, hasNext, hasPrev)); + } + + private async Task ExecuteFetchPreviousAsync( + List snapshotPages, + List snapshotParams, + bool snapshotHasNext, + bool snapshotHasPrev, + CancellationToken ct + ) + { + if (_disposed || snapshotPages.Count == 0 || _options.GetPreviousPageParam is null) + { + return; + } + + var prevParamResult = _options.GetPreviousPageParam( + new InfinitePageInfo(snapshotPages[0], snapshotPages, snapshotParams[0], snapshotParams) + ); + + if (!prevParamResult.HasValue) + { + return; + } + + var prevParam = prevParamResult.Value; + + _state.OnNext( + InfiniteQueryState.CreateFetchingPrevious( + snapshotPages, + snapshotParams, + snapshotHasNext, + snapshotHasPrev + ) + ); + + var page = await _options.RetryHandler.ExecuteAsync(tok => _options.Fetcher(_args, prevParam, tok), ct); + + if (_disposed) + { + return; + } + + List newPages; + List newParams; + + lock (_syncRoot) + { + _pages.Insert(0, page); + _pageParams.Insert(0, prevParam); + + if (_options.MaxPages is { } maxPages && _pages.Count > maxPages) + { + var removeCount = _pages.Count - maxPages; + _pages.RemoveRange(maxPages, removeCount); + _pageParams.RemoveRange(maxPages, removeCount); + } + + newPages = [.. _pages]; + newParams = [.. _pageParams]; + } + + var (hasNext, hasPrev) = ComputeHasMorePages(newPages, newParams); + _state.OnNext(InfiniteQueryState.CreateSuccess(newPages, newParams, hasNext, hasPrev)); + } + + private (bool hasNext, bool hasPrev) ComputeHasMorePages(List pages, List pageParams) + { + if (pages.Count == 0) + { + return (false, false); + } + + var lastInfo = new InfinitePageInfo(pages[^1], pages, pageParams[^1], pageParams); + var hasNext = _options.GetNextPageParam(lastInfo).HasValue; + + var hasPrev = false; + if (_options.GetPreviousPageParam is { } getPrev) + { + var firstInfo = new InfinitePageInfo(pages[0], pages, pageParams[0], pageParams); + hasPrev = getPrev(firstInfo).HasValue; + } + + return (hasNext, hasPrev); + } +} diff --git a/src/DotNetQuery.Core/Internals/InfiniteQueryObserver.cs b/src/DotNetQuery.Core/Internals/InfiniteQueryObserver.cs new file mode 100644 index 0000000..f118e68 --- /dev/null +++ b/src/DotNetQuery.Core/Internals/InfiniteQueryObserver.cs @@ -0,0 +1,148 @@ +namespace DotNetQuery.Core.Internals; + +internal sealed class InfiniteQueryObserver + : IInfiniteQuery, + IQueryInspector +{ + private readonly QueryCache _cache; + private readonly EffectiveInfiniteQueryOptions _options; + private readonly IScheduler _scheduler; + private readonly QueryInstrumentation _instrumentation; + private readonly BehaviorSubject?> _activeQuery = new(null); + private readonly BehaviorSubject _isEnabled; + private readonly Subject _args = new(); + private readonly CompositeDisposable _subscriptions = []; + + private QueryKey _currentKey = QueryKey.Default; + private bool _disposed; + + public InfiniteQueryObserver( + InfiniteQueryOptions options, + QueryClientOptions globalOptions, + QueryCache cache, + IScheduler scheduler, + QueryInstrumentation instrumentation + ) + { + _options = MergeOptions(options, globalOptions); + _cache = cache; + _scheduler = scheduler; + _instrumentation = instrumentation; + _isEnabled = new BehaviorSubject(options.IsEnabled); + + _subscriptions.Add( + _args.Subscribe(args => + { + var key = options.KeyFactory(args); + var candidate = new InfiniteQuery( + key, + args, + _options, + _scheduler, + _instrumentation + ); + var query = _cache.GetOrCreate(key, candidate); + + if (!ReferenceEquals(query, candidate)) + { + candidate.Dispose(); + } + + _currentKey = key; + + if (_isEnabled.Value) + { + query.Invalidate(); + } + + _activeQuery.OnNext(query); + }) + ); + + _subscriptions.Add( + _isEnabled.DistinctUntilChanged().Where(enabled => enabled).Subscribe(_ => _activeQuery.Value?.Invalidate()) + ); + } + + public QueryKey Key => _currentKey; + + public TimeSpan CacheTime => _options.CacheTime; + + public InfiniteQueryState CurrentState => + _activeQuery.Value?.CurrentState ?? InfiniteQueryState.CreateIdle(); + + public QueryStatus Status => CurrentState.Status; + + public object? CurrentData => _activeQuery.Value?.CurrentData; + + public DateTimeOffset? LastUpdatedAt => _activeQuery.Value?.LastUpdatedAt; + + public int ObserverCount => _activeQuery.Value?.ObserverCount ?? 0; + + public IObservable StateChanged => + _activeQuery.Where(query => query is not null).Select(query => query!.StateChanged).Switch(); + + public void SetArgs(TArgs args) => _args.OnNext(args); + + public void SetEnabled(bool enabled) => _isEnabled.OnNext(enabled); + + public void FetchNextPage() => _activeQuery.Value?.FetchNextPage(); + + public void FetchPreviousPage() => _activeQuery.Value?.FetchPreviousPage(); + + public IObservable> State => + _activeQuery.Where(query => query is not null).Select(query => query!.State).Switch(); + + public IObservable> Success => + State.Where(s => s.IsSuccess && !s.IsFetchingNextPage && !s.IsFetchingPreviousPage).Select(s => s.Pages); + + public IObservable Failure => State.Where(s => s.IsFailure).Select(s => s.Error!); + + public IObservable> Settled => + State.Where(s => s.IsFailure || (s.IsSuccess && !s.IsFetchingNextPage && !s.IsFetchingPreviousPage)); + + public void Refetch() => _activeQuery.Value?.Refetch(); + + public void Cancel() => _activeQuery.Value?.Cancel(); + + public void Invalidate() => _activeQuery.Value?.Invalidate(); + + public void Detach() => _cache.Remove(_currentKey); + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _subscriptions.Dispose(); + _activeQuery.OnCompleted(); + _args.OnCompleted(); + _isEnabled.OnCompleted(); + _args.Dispose(); + _isEnabled.Dispose(); + _activeQuery.Dispose(); + } + + public static EffectiveInfiniteQueryOptions MergeOptions( + InfiniteQueryOptions options, + QueryClientOptions globalOptions + ) => + new() + { + Fetcher = options.Fetcher, + InitialPageParam = options.InitialPageParam, + GetNextPageParam = options.GetNextPageParam, + GetPreviousPageParam = options.GetPreviousPageParam, + MaxPages = options.MaxPages, + StaleTime = options.StaleTime ?? globalOptions.StaleTime, + CacheTime = options.CacheTime ?? globalOptions.CacheTime, + RefetchInterval = options.RefetchInterval ?? globalOptions.RefetchInterval, + IsEnabled = options.IsEnabled, + RetryHandler = options.RetryHandler ?? globalOptions.RetryHandler, + DataComparer = options.DataComparer ?? EqualityComparer.Default, + InitialData = options.InitialData, + }; +} diff --git a/src/DotNetQuery.Core/Internals/QueryCache.cs b/src/DotNetQuery.Core/Internals/QueryCache.cs index d95c11b..c020012 100644 --- a/src/DotNetQuery.Core/Internals/QueryCache.cs +++ b/src/DotNetQuery.Core/Internals/QueryCache.cs @@ -15,7 +15,8 @@ internal sealed class QueryCache(IScheduler scheduler, QueryInstrumentation inst private IReadOnlyList Snapshot() => [.. _entries.Values.Cast()]; - public Query GetOrCreate(QueryKey key, Query query) + public TEntry GetOrCreate(QueryKey key, TEntry entry) + where TEntry : class, IQueryInspector { lock (_evictionLock) { @@ -25,12 +26,12 @@ public Query GetOrCreate(QueryKey key, Query)_entries.GetOrAdd(key, query); + var result = (TEntry)_entries.GetOrAdd(key, entry); - if (ReferenceEquals(result, query)) + if (ReferenceEquals(result, entry)) { _instrumentation.RecordCacheMiss(key); - _stateSubscriptions[key] = query.StateChanged.Subscribe(_ => _entriesSubject.OnNext(Snapshot())); + _stateSubscriptions[key] = entry.StateChanged.Subscribe(_ => _entriesSubject.OnNext(Snapshot())); _entriesSubject.OnNext(Snapshot()); } else diff --git a/src/DotNetQuery.Core/Internals/QueryClient.cs b/src/DotNetQuery.Core/Internals/QueryClient.cs index 4b3c886..850e1a7 100644 --- a/src/DotNetQuery.Core/Internals/QueryClient.cs +++ b/src/DotNetQuery.Core/Internals/QueryClient.cs @@ -20,6 +20,17 @@ public QueryClient(QueryClientOptions globalOptions, IScheduler scheduler, Query public IQuery CreateQuery(QueryOptions options) => new QueryObserver(options, _globalOptions, _cache, _scheduler, _instrumentation); + public IInfiniteQuery CreateInfiniteQuery( + InfiniteQueryOptions options + ) => + new InfiniteQueryObserver( + options, + _globalOptions, + _cache, + _scheduler, + _instrumentation + ); + public IMutation CreateMutation(MutationOptions options) { var mutation = new Mutation(options, _globalOptions, _instrumentation); diff --git a/src/DotNetQuery.Core/PageParam.cs b/src/DotNetQuery.Core/PageParam.cs new file mode 100644 index 0000000..ff71dcf --- /dev/null +++ b/src/DotNetQuery.Core/PageParam.cs @@ -0,0 +1,36 @@ +namespace DotNetQuery.Core; + +/// +/// Represents an optional page parameter used in infinite queries. +/// Return from GetNextPageParam or GetPreviousPageParam +/// to signal that no further pages exist in that direction. +/// +/// The type of the page parameter (e.g. int, string, Guid). +public readonly struct PageParam +{ + private PageParam(TValue value, bool hasValue) + { + Value = value; + HasValue = hasValue; + } + + /// The page parameter value. Only meaningful when is true. + public TValue Value { get; } + + /// + /// true when this instance holds a page parameter; false when it represents . + /// + public bool HasValue { get; } + + /// + /// Signals that there are no further pages in this direction. + /// Return this from GetNextPageParam or GetPreviousPageParam to end pagination. + /// + public static PageParam None => default; + + /// Creates a that holds . + public static PageParam Some(TValue value) => new(value, true); + + /// Implicitly converts to a that holds it. + public static implicit operator PageParam(TValue value) => Some(value); +} diff --git a/tests/DotNetQuery.Core.Tests/InfiniteQueryTests.cs b/tests/DotNetQuery.Core.Tests/InfiniteQueryTests.cs new file mode 100644 index 0000000..cd76f50 --- /dev/null +++ b/tests/DotNetQuery.Core.Tests/InfiniteQueryTests.cs @@ -0,0 +1,540 @@ +namespace DotNetQuery.Core.Tests; + +public class InfiniteQueryTests +{ + private readonly TestScheduler _scheduler = new(); + + private static readonly QueryInstrumentation _instrumentation = new(NullLogger.Instance); + + private InfiniteQuery CreateQuery( + Func>? fetcher = null, + Func, PageParam>? getNextPageParam = null, + Func, PageParam>? getPreviousPageParam = null, + int initialPageParam = 0, + int? maxPages = null, + TimeSpan? staleTime = null, + TimeSpan? cacheTime = null, + TimeSpan? refetchInterval = null, + int args = 0, + string? initialData = null + ) + { + var options = new EffectiveInfiniteQueryOptions + { + Fetcher = fetcher ?? ((_, page, _) => Task.FromResult($"page{page}")), + InitialPageParam = initialPageParam, + GetNextPageParam = getNextPageParam ?? (info => info.PageParam + 1), + GetPreviousPageParam = getPreviousPageParam, + MaxPages = maxPages, + StaleTime = staleTime ?? TimeSpan.Zero, + CacheTime = cacheTime ?? TimeSpan.FromMinutes(5), + RefetchInterval = refetchInterval, + RetryHandler = new DefaultRetryHandler(), + IsEnabled = true, + DataComparer = EqualityComparer.Default, + InitialData = initialData, + }; + + return new InfiniteQuery(QueryKey.From("test"), args, options, _scheduler, _instrumentation); + } + + [Test] + public async Task InitialState_IsIdle() + { + using var sut = CreateQuery(); + + using var _ = Assert.Multiple(); + await Assert.That(sut.CurrentState.IsIdle).IsTrue(); + await Assert.That(sut.CurrentState.Pages.Count).IsEqualTo(0); + await Assert.That(sut.CurrentState.HasNextPage).IsFalse(); + } + + [Test] + public async Task Refetch_TransitionsToFetching() + { + using var sut = CreateQuery(); + + var tcs = new TaskCompletionSource>(); + using var sub = sut.State.Where(s => s.IsFetching).Subscribe(s => tcs.TrySetResult(s)); + + sut.Refetch(); + + var state = await tcs.Task; + await Assert.That(state.IsFetching).IsTrue(); + } + + [Test] + public async Task Refetch_FetchesFirstPage() + { + using var sut = CreateQuery(fetcher: (_, page, _) => Task.FromResult($"data:{page}"), initialPageParam: 1); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + + var state = await sut.State.Where(s => s.IsSuccess).FirstAsync(); + using var _ = Assert.Multiple(); + await Assert.That(state.Pages.Count).IsEqualTo(1); + await Assert.That(state.Pages[0]).IsEqualTo("data:1"); + await Assert.That(state.PageParams[0]).IsEqualTo(1); + } + + [Test] + public async Task FetchNextPage_AppendsPage() + { + using var sut = CreateQuery( + fetcher: (_, page, _) => Task.FromResult($"page{page}"), + getNextPageParam: info => info.PageParam + 1, + initialPageParam: 0 + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + sut.FetchNextPage(); + var state = await sut.State.Where(s => s.IsSuccess && s.Pages.Count == 2).FirstAsync(); + + using var _ = Assert.Multiple(); + await Assert.That(state.Pages[0]).IsEqualTo("page0"); + await Assert.That(state.Pages[1]).IsEqualTo("page1"); + await Assert.That(state.PageParams[1]).IsEqualTo(1); + } + + [Test] + public async Task FetchNextPage_HasNextPage_ReflectsGetNextPageParam() + { + using var sut = CreateQuery(getNextPageParam: info => + info.PageParam < 2 ? PageParam.Some(info.PageParam + 1) : PageParam.None + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + sut.FetchNextPage(); + await sut.State.Where(s => s.IsSuccess && s.Pages.Count == 2).FirstAsync(); + + sut.FetchNextPage(); + var state = await sut.State.Where(s => s.IsSuccess && s.Pages.Count == 3).FirstAsync(); + + await Assert.That(state.HasNextPage).IsFalse(); + } + + [Test] + public async Task FetchNextPage_WhenGetNextPageParamReturnsNone_IsNoOp() + { + using var sut = CreateQuery(getNextPageParam: _ => PageParam.None); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + var successState = await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + sut.FetchNextPage(); + await Task.Delay(50); // give time for any state change + + var current = sut.CurrentState; + await Assert.That(current.Pages.Count).IsEqualTo(successState.Pages.Count); + } + + [Test] + public async Task FetchNextPage_WhenNoPages_IsNoOp() + { + using var sut = CreateQuery(); + + var stateCount = 0; + using var sub = sut.State.Subscribe(_ => stateCount++); + var countBefore = stateCount; + + sut.FetchNextPage(); + await Task.Delay(50); + + await Assert.That(stateCount).IsEqualTo(countBefore); + } + + [Test] + public async Task FetchNextPage_EmitsFetchingNextState() + { + var fetchTcs = new TaskCompletionSource(); + + using var sut = CreateQuery( + fetcher: async (_, page, ct) => + { + if (page == 1) + { + return await fetchTcs.Task.WaitAsync(ct); + } + + return $"page{page}"; + } + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + sut.FetchNextPage(); + var fetchingState = await sut.State.Where(s => s.IsFetchingNextPage).FirstAsync(); + + using var _ = Assert.Multiple(); + await Assert.That(fetchingState.IsSuccess).IsTrue(); + await Assert.That(fetchingState.IsFetchingNextPage).IsTrue(); + await Assert.That(fetchingState.Pages.Count).IsEqualTo(1); + + fetchTcs.SetResult("page1"); + } + + [Test] + public async Task FetchPreviousPage_PrependPage() + { + using var sut = CreateQuery( + fetcher: (_, page, _) => Task.FromResult($"page{page}"), + getNextPageParam: _ => PageParam.None, + getPreviousPageParam: info => + info.PageParam > 0 ? PageParam.Some(info.PageParam - 1) : PageParam.None, + initialPageParam: 5 + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + sut.FetchPreviousPage(); + var state = await sut.State.Where(s => s.IsSuccess && s.Pages.Count == 2).FirstAsync(); + + using var _ = Assert.Multiple(); + await Assert.That(state.Pages[0]).IsEqualTo("page4"); + await Assert.That(state.Pages[1]).IsEqualTo("page5"); + await Assert.That(state.PageParams[0]).IsEqualTo(4); + } + + [Test] + public async Task FetchPreviousPage_WhenNoPreviousPageParamConfigured_IsNoOp() + { + using var sut = CreateQuery(getPreviousPageParam: null); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + sut.FetchPreviousPage(); + await Task.Delay(50); + + await Assert.That(sut.CurrentState.Pages.Count).IsEqualTo(1); + } + + [Test] + public async Task MaxPages_FetchNext_TrimsOldestPage() + { + using var sut = CreateQuery(getNextPageParam: info => info.PageParam + 1, maxPages: 2); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + sut.FetchNextPage(); + await sut.State.Where(s => s.IsSuccess && s.Pages.Count == 2).FirstAsync(); + + sut.FetchNextPage(); + var state = await sut.State.Where(s => s.IsSuccess && s.Pages[0] == "page1").FirstAsync(); + + using var _ = Assert.Multiple(); + await Assert.That(state.Pages.Count).IsEqualTo(2); + await Assert.That(state.Pages[0]).IsEqualTo("page1"); + await Assert.That(state.Pages[1]).IsEqualTo("page2"); + } + + [Test] + public async Task MaxPages_FetchPrevious_TrimsNewestPage() + { + using var sut = CreateQuery( + fetcher: (_, page, _) => Task.FromResult($"page{page}"), + getNextPageParam: _ => PageParam.None, + getPreviousPageParam: info => + info.PageParam > 0 ? PageParam.Some(info.PageParam - 1) : PageParam.None, + initialPageParam: 5, + maxPages: 2 + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + sut.FetchPreviousPage(); + await sut.State.Where(s => s.IsSuccess && s.Pages.Count == 2).FirstAsync(); + + sut.FetchPreviousPage(); + var state = await sut.State.Where(s => s.IsSuccess && s.Pages[0] == "page3").FirstAsync(); + + using var _ = Assert.Multiple(); + await Assert.That(state.Pages.Count).IsEqualTo(2); + await Assert.That(state.Pages[0]).IsEqualTo("page3"); + await Assert.That(state.Pages[1]).IsEqualTo("page4"); + } + + [Test] + public async Task Refetch_AfterLoadingMultiplePages_RefetchesAllPages() + { + var fetchLog = new List(); + + using var sut = CreateQuery( + fetcher: (_, page, _) => + { + fetchLog.Add(page); + return Task.FromResult($"page{page}"); + }, + getNextPageParam: info => info.PageParam + 1 + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + sut.FetchNextPage(); + await sut.State.Where(s => s.IsSuccess && s.Pages.Count == 2).FirstAsync(); + + fetchLog.Clear(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess && !s.IsFetching).FirstAsync(); + + using var _ = Assert.Multiple(); + await Assert.That(fetchLog.Count).IsEqualTo(2); + await Assert.That(fetchLog[0]).IsEqualTo(0); + await Assert.That(fetchLog[1]).IsEqualTo(1); + } + + [Test] + public async Task Invalidate_WhenStaleTimeNotExpired_IsNoOp() + { + using var sut = CreateQuery(staleTime: TimeSpan.FromMinutes(5)); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + var stateBeforeInvalidate = sut.CurrentState; + sut.Invalidate(); // stale time not expired + await Task.Delay(50); + + await Assert.That(sut.CurrentState).IsEqualTo(stateBeforeInvalidate); + } + + [Test] + public async Task Fetch_OnException_TransitionsToFailure() + { + var error = new InvalidOperationException("oops"); + using var sut = CreateQuery(fetcher: (_, _, _) => Task.FromException(error)); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + + var state = await sut.State.Where(s => s.IsFailure).FirstAsync(); + using var _ = Assert.Multiple(); + await Assert.That(state.Error).IsEqualTo(error); + } + + [Test] + public async Task Failure_PreservesExistingPages() + { + var failNext = false; + var error = new InvalidOperationException("fetch failed"); + + using var sut = CreateQuery( + fetcher: (_, page, _) => + { + if (failNext) + { + return Task.FromException(error); + } + + return Task.FromResult($"page{page}"); + } + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + failNext = true; + sut.FetchNextPage(); + + var failureState = await sut.State.Where(s => s.IsFailure).FirstAsync(); + using var _ = Assert.Multiple(); + await Assert.That(failureState.Pages.Count).IsEqualTo(1); + await Assert.That(failureState.Pages[0]).IsEqualTo("page0"); + } + + [Test] + public async Task Cancel_DuringInitialLoad_TransitionsToIdle() + { + var fetchTcs = new TaskCompletionSource(); + + using var sut = CreateQuery(fetcher: (_, _, ct) => fetchTcs.Task.WaitAsync(ct)); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsFetching).FirstAsync(); + + sut.Cancel(); + + var state = await sut.State.Where(s => s.IsIdle).FirstAsync(); + await Assert.That(state.Pages.Count).IsEqualTo(0); + } + + [Test] + public async Task Cancel_DuringFetchNext_ReturnsToSuccess() + { + var blockNext = new TaskCompletionSource(); + + using var sut = CreateQuery( + fetcher: (_, page, ct) => + { + if (page == 1) + { + return blockNext.Task.WaitAsync(ct); + } + + return Task.FromResult($"page{page}"); + } + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + sut.FetchNextPage(); + await sut.State.Where(s => s.IsFetchingNextPage).FirstAsync(); + + sut.Cancel(); + + var state = await sut.State.Where(s => s.IsSuccess && !s.IsFetchingNextPage).FirstAsync(); + await Assert.That(state.Pages.Count).IsEqualTo(1); + } + + [Test] + public async Task State_FirstSubscriber_TriggersStaleInvalidation() + { + using var sut = CreateQuery(); + + // Mark stale by calling Invalidate with no subscribers + sut.Invalidate(); + await Assert.That(sut.CurrentState.IsIdle).IsTrue(); + + // First subscriber should trigger the deferred fetch + using var sub = sut.State.Subscribe(); + var state = await sut.State.Where(s => s.IsSuccess).FirstAsync(); + await Assert.That(state.Pages.Count).IsEqualTo(1); + } + + [Test] + public async Task RefetchInterval_TriggersRefetchAll() + { + var fetchCount = 0; + + using var sut = CreateQuery( + fetcher: (_, _, _) => + { + fetchCount++; + return Task.FromResult("data"); + }, + refetchInterval: TimeSpan.FromSeconds(10) + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + await sut.State.Where(s => s.IsSuccess).FirstAsync(); + var countAfterFirstFetch = fetchCount; + + _scheduler.AdvanceBy(TimeSpan.FromSeconds(10).Ticks); + await sut.State.Where(s => s.IsSuccess && !s.IsFetching).FirstAsync(); + + await Assert.That(fetchCount).IsGreaterThan(countAfterFirstFetch); + } + + [Test] + public async Task HasPreviousPage_ReflectsGetPreviousPageParam() + { + using var sut = CreateQuery( + fetcher: (_, page, _) => Task.FromResult($"page{page}"), + getNextPageParam: _ => PageParam.None, + getPreviousPageParam: info => + info.PageParam > 0 ? PageParam.Some(info.PageParam - 1) : PageParam.None, + initialPageParam: 3 + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + var state = await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + await Assert.That(state.HasPreviousPage).IsTrue(); + } + + [Test] + public async Task HasPreviousPage_WhenAtStart_IsFalse() + { + using var sut = CreateQuery( + getNextPageParam: _ => PageParam.None, + getPreviousPageParam: info => + info.PageParam > 0 ? PageParam.Some(info.PageParam - 1) : PageParam.None, + initialPageParam: 0 + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + var state = await sut.State.Where(s => s.IsSuccess).FirstAsync(); + + await Assert.That(state.HasPreviousPage).IsFalse(); + } + + [Test] + public async Task InitialData_StartsInSuccessStateWithFirstPage() + { + using var sut = CreateQuery(initialData: "seed-page", initialPageParam: 1); + + using var _ = Assert.Multiple(); + await Assert.That(sut.CurrentState.IsSuccess).IsTrue(); + await Assert.That(sut.CurrentState.Pages.Count).IsEqualTo(1); + await Assert.That(sut.CurrentState.Pages[0]).IsEqualTo("seed-page"); + await Assert.That(sut.CurrentState.PageParams[0]).IsEqualTo(1); + await Assert.That(sut.CurrentState.HasData).IsTrue(); + } + + [Test] + public async Task InitialData_ComputesHasNextPage() + { + using var sut = CreateQuery( + initialData: "seed-page", + initialPageParam: 1, + getNextPageParam: info => PageParam.Some(info.PageParam + 1) + ); + + await Assert.That(sut.CurrentState.HasNextPage).IsTrue(); + } + + [Test] + public async Task InitialData_HasNextPage_IsFalse_WhenGetNextPageParamReturnsNone() + { + using var sut = CreateQuery( + initialData: "seed-page", + initialPageParam: 1, + getNextPageParam: _ => PageParam.None + ); + + await Assert.That(sut.CurrentState.HasNextPage).IsFalse(); + } + + [Test] + public async Task InitialData_IsReplacedAfterRefetch() + { + using var sut = CreateQuery( + fetcher: (_, page, _) => Task.FromResult($"fetched:{page}"), + initialData: "seed-page", + initialPageParam: 1 + ); + + using var sub = sut.State.Subscribe(); + sut.Refetch(); + + var state = await sut.State.Where(s => s.IsSuccess && s.Pages[0].StartsWith("fetched")).FirstAsync(); + await Assert.That(state.Pages[0]).IsEqualTo("fetched:1"); + } +}