# Default query function

> Queries that are nothing but a key, fetched by one function registered as a default for a key prefix, and a mutation that gets its function the same way.

None of the queries on this screen has a `queryFn`. One function, registered
with `setQueryDefaults` for every key under `['api', …]`, reads the request
path out of the key and fetches it, so a query is its key plus a `select` that
parses the JSON. A mutation carrying only a `mutationKey` gets its function
from `setMutationDefaults` in the same way. Reach for this when an app talks
to one REST backend whose paths map cleanly onto keys, say a device list, a
device's detail and its event log, and writing the same fetch for each of
them would only repeat the path.

Live demo: [Default query function](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/default-query-function), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/default_query_function)). A query function derived from the key, set once as a default.

## What to try

- Read the *Defaults on the client* card: while the screen is open it reports
  `default queryFn=set` and `default mutationFn=set`.
- Watch the three cards fill: the posts list, post #1 and its comments, each
  fetched once through the default (`fetches=1` in the `posts`, `post-1` and
  `comments-1` strips).
- Press *Fetch a missing post*. The key `['api', '/posts/999']` goes through
  the same default, the backend answers 404, and the card shows its *Post not
  found* message at once: that query sets `retry: RetryPolicy.never`.
- Press *Create a todo*. The mutation has nothing but a key; the default
  `mutationFn` posts the todo and the card shows the new todo's id and text.

## The code

The defaults: the query function takes the path from the key's second part
and returns the JSON untyped, because one function serves every key under the
prefix; the mutation function is registered under the mutation's key.

[`examples/showcase/lib/features/default_query_function/default_query_function_screen.dart`, lines 54–59](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/default_query_function/default_query_function_screen.dart#L54-L59):

```dart
QueryDefaults apiDefaults(ShowcaseApi api) => QueryDefaults(
      queryFn: (context) => api.getJson(
        context.queryKey.parts[1]! as String,
        signal: context.signal,
      ),
    );
```

[`examples/showcase/lib/features/default_query_function/default_query_function_screen.dart`, lines 63–65](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/default_query_function/default_query_function_screen.dart#L63-L65):

```dart
MutationDefaults createTodoDefaults(ShowcaseApi api) => MutationDefaults(
      mutationFn: (variables) => api.createTodo(variables! as String),
    );
```

The queries carry a key and a `select` and nothing else. The selectors are
top-level functions, so options built on every build compare equal.

[`examples/showcase/lib/features/default_query_function/default_query_function_screen.dart`, lines 72–101](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/default_query_function/default_query_function_screen.dart#L72-L101):

```dart
QuerySelectOptions<Object?, List<Post>> postsQuery() =>
    QuerySelectOptions<Object?, List<Post>>(
      queryKey: apiKey('/posts'),
      select: _parsePosts,
    );

QuerySelectOptions<Object?, Post> postQuery(int id) =>
    QuerySelectOptions<Object?, Post>(
      queryKey: apiKey('/posts/$id'),
      select: _parsePost,
    );

QuerySelectOptions<Object?, List<Comment>> commentsQuery(int postId) =>
    QuerySelectOptions<Object?, List<Comment>>(
      queryKey: apiKey('/posts/$postId/comments'),
      select: _parseComments,
    );

/// A key whose path has no post behind it. Every other option is still the
/// query's own: what the backend's 404 means is settled here, once, rather
/// than after the default backoff.
QuerySelectOptions<Object?, Post> missingPostQuery() =>
    QuerySelectOptions<Object?, Post>(
      queryKey: apiKey('/posts/999'),
      select: _parsePost,
      retry: RetryPolicy.never,
    );

MutationOptions<Todo, String, void> createTodoMutation() =>
    MutationOptions<Todo, String, void>(mutationKey: createTodoKey);
```

The screen registers the defaults in `initState` and blanks them in
`dispose`, so no other screen of the showcase sees them; in an app you would
register them once, next to where the client is created. It reads the
queries with `QueryMixin`'s `watchSelectQuery` and `watchMutation`.

[`examples/showcase/lib/features/default_query_function/default_query_function_screen.dart`, lines 128–158](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/default_query_function/default_query_function_screen.dart#L128-L158):

```dart
void initState() {
  super.initState();
  // Plain reads, not dependencies: a State may not depend on an inherited
  // widget before `initState` has completed, and neither the client nor
  // the api changes underneath a screen.
  _client = QueryClientProvider.read(context);
  final api = context.getInheritedWidgetOfExactType<ShowcaseScope>()!.api;
  _client.setQueryDefaults(apiPrefix, apiDefaults(api));
  _client.setMutationDefaults(createTodoKey, createTodoDefaults(api));
}

@override
void dispose() {
  // Blanked rather than removed — the client has no "unset" — which comes
  // to the same: the next query under the prefix finds no `queryFn`.
  _client.setQueryDefaults(apiPrefix, const QueryDefaults());
  _client.setMutationDefaults(createTodoKey, const MutationDefaults());
  super.dispose();
}

@override
Widget build(BuildContext context) {
  final posts = watchSelectQuery<Object?, List<Post>>(postsQuery());
  final post = watchSelectQuery<Object?, Post>(postQuery(1));
  final comments = watchSelectQuery<Object?, List<Comment>>(commentsQuery(1));
  // Read only once asked for: the mixin releases a key a build stops
  // reading, and creates the observer the first time one reads it.
  final missing = _fetchMissing
      ? watchSelectQuery<Object?, Post>(missingPostQuery())
      : null;
  final create = watchMutation<Todo, String, void>(createTodoMutation());
```

<details>
<summary>The whole screen</summary>

[`examples/showcase/lib/features/default_query_function/default_query_function_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/default_query_function/default_query_function_screen.dart):

```dart
/// Upstream's `default-query-function` example: no query on this screen
/// carries a `queryFn`. One function, registered once as a default for every
/// key under `['api', …]`, reads the request path out of the key and fetches
/// it, so a query is nothing but its key. The mutation twin,
/// `setMutationDefaults`, hands a mutation with only a `mutationKey` its
/// function the same way.
///
/// Upstream sets the function client-wide; here it is a per-key default on
/// the app's shared client, registered in `initState` and blanked again in
/// `dispose`, so no other screen sees it.
///
/// Proofs (widget tests in `test/features/default_query_function_test.dart`,
/// end-to-end in `e2e/tests/default_query_function.spec.ts`): three keyed
/// queries fetch exactly one request each and show parsed data; the defaults
/// panel reports `default queryFn=set`; a mutation with only a key posts once
/// and shows the new todo's id; leaving the screen blanks the defaults, after
/// which a query on the same key fails with `MissingQueryFunctionError`
/// instead of fetching; a key whose path has no post behind it shows the
/// backend's 404 message.
library;

import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';

import '../../shared/api.dart';
import '../../shared/chrome.dart';
import '../../shared/debug_strip.dart';
import '../../shared/feature.dart';
import '../../shared/feature_scaffold.dart';
import '../../shared/models.dart';
import '../../shared/scope.dart';

const Feature defaultQueryFunctionFeature = Feature(
  id: 'default-query-function',
  title: 'Default query function',
  summary: 'A query function derived from the key, set once as a default.',
  upstream: 'default-query-function',
);

/// The prefix the default query function is registered under.
QueryKey get apiPrefix => QueryKey(const <Object?>['api']);

/// The key for a GET of [path]. The key is the request: `['api', '/posts']`
/// is fetched by the default as `GET /api/posts`.
QueryKey apiKey(String path) => QueryKey(<Object?>['api', path]);

/// The key of the one mutation on this screen, and of its default.
QueryKey get createTodoKey =>
    QueryKey(const <Object?>['api', 'todos', 'create']);

/// The default: the path is the key's second part, and the JSON comes back
/// untyped — one function serves every key under the prefix, so it cannot
/// know the type; each query's `select` does.
QueryDefaults apiDefaults(ShowcaseApi api) => QueryDefaults(
      queryFn: (context) => api.getJson(
        context.queryKey.parts[1]! as String,
        signal: context.signal,
      ),
    );

/// The mutation twin: `createTodo` under the mutation's key, erased the same
/// way, so the mutation itself carries nothing but the key.
MutationDefaults createTodoDefaults(ShowcaseApi api) => MutationDefaults(
      mutationFn: (variables) => api.createTodo(variables! as String),
    );

// The queries. None takes the api: they have no function to close over.
// The selectors are top-level functions rather than closures, so the options
// built on every build compare equal and the parsed list is kept, not
// re-parsed into a fresh one per rebuild.

QuerySelectOptions<Object?, List<Post>> postsQuery() =>
    QuerySelectOptions<Object?, List<Post>>(
      queryKey: apiKey('/posts'),
      select: _parsePosts,
    );

QuerySelectOptions<Object?, Post> postQuery(int id) =>
    QuerySelectOptions<Object?, Post>(
      queryKey: apiKey('/posts/$id'),
      select: _parsePost,
    );

QuerySelectOptions<Object?, List<Comment>> commentsQuery(int postId) =>
    QuerySelectOptions<Object?, List<Comment>>(
      queryKey: apiKey('/posts/$postId/comments'),
      select: _parseComments,
    );

/// A key whose path has no post behind it. Every other option is still the
/// query's own: what the backend's 404 means is settled here, once, rather
/// than after the default backoff.
QuerySelectOptions<Object?, Post> missingPostQuery() =>
    QuerySelectOptions<Object?, Post>(
      queryKey: apiKey('/posts/999'),
      select: _parsePost,
      retry: RetryPolicy.never,
    );

MutationOptions<Todo, String, void> createTodoMutation() =>
    MutationOptions<Todo, String, void>(mutationKey: createTodoKey);

List<Post> _parsePosts(Object? json) => (json! as List<Object?>)
    .map((item) => Post.fromJson(item! as Map<String, Object?>))
    .toList();

Post _parsePost(Object? json) => Post.fromJson(json! as Map<String, Object?>);

List<Comment> _parseComments(Object? json) => (json! as List<Object?>)
    .map((item) => Comment.fromJson(item! as Map<String, Object?>))
    .toList();

class DefaultQueryFunctionScreen extends StatefulWidget {
  const DefaultQueryFunctionScreen({super.key});

  @override
  State<DefaultQueryFunctionScreen> createState() =>
      _DefaultQueryFunctionScreenState();
}

class _DefaultQueryFunctionScreenState extends State<DefaultQueryFunctionScreen>
    with QueryMixin {
  /// Kept from `initState` for `dispose`, which may not look anything up.
  late final QueryClient _client;
  bool _fetchMissing = false;

  @override
  void initState() {
    super.initState();
    // Plain reads, not dependencies: a State may not depend on an inherited
    // widget before `initState` has completed, and neither the client nor
    // the api changes underneath a screen.
    _client = QueryClientProvider.read(context);
    final api = context.getInheritedWidgetOfExactType<ShowcaseScope>()!.api;
    _client.setQueryDefaults(apiPrefix, apiDefaults(api));
    _client.setMutationDefaults(createTodoKey, createTodoDefaults(api));
  }

  @override
  void dispose() {
    // Blanked rather than removed — the client has no "unset" — which comes
    // to the same: the next query under the prefix finds no `queryFn`.
    _client.setQueryDefaults(apiPrefix, const QueryDefaults());
    _client.setMutationDefaults(createTodoKey, const MutationDefaults());
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final posts = watchSelectQuery<Object?, List<Post>>(postsQuery());
    final post = watchSelectQuery<Object?, Post>(postQuery(1));
    final comments = watchSelectQuery<Object?, List<Comment>>(commentsQuery(1));
    // Read only once asked for: the mixin releases a key a build stops
    // reading, and creates the observer the first time one reads it.
    final missing = _fetchMissing
        ? watchSelectQuery<Object?, Post>(missingPostQuery())
        : null;
    final create = watchMutation<Todo, String, void>(createTodoMutation());

    final client = queryClient;
    final queryDefault = client.getQueryDefaults(apiKey('/posts'))?.queryFn;
    final mutationDefault =
        client.getMutationDefaults(createTodoKey)?.mutationFn;

    return FeatureScaffold(
      feature: defaultQueryFunctionFeature,
      children: <Widget>[
        const Padding(
          padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          child: Notice(
            'The key is the request: none of these queries has a queryFn, '
            "and the default registered for ['api', …] fetches the path it "
            'finds in the key.',
          ),
        ),
        SectionCard(
          title: 'Defaults on the client',
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                'Asked for ${apiKey('/posts').debugString} and '
                '${createTodoKey.debugString}:',
              ),
              const SizedBox(height: 4),
              Text('default queryFn=${_setOrNone(queryDefault)}'),
              Text('default mutationFn=${_setOrNone(mutationDefault)}'),
            ],
          ),
        ),
        SectionCard(
          title: 'Posts',
          trailing: _fetchingPill(posts),
          child: _view<List<Post>>(
            posts,
            (data) => Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                for (final item in data.take(5))
                  Text('#${item.id} ${item.title}'),
                if (data.length > 5) Text('… and ${data.length - 5} more'),
                const SizedBox(height: 4),
                Text('posts=${data.length}'),
              ],
            ),
          ),
        ),
        QueryDebugStrip(queryKey: apiKey('/posts'), label: 'posts'),
        SectionCard(
          title: 'Post #1',
          trailing: _fetchingPill(post),
          child: _view<Post>(
            post,
            (data) => Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  data.title,
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                const SizedBox(height: 8),
                Text(data.body),
              ],
            ),
          ),
        ),
        QueryDebugStrip(queryKey: apiKey('/posts/1'), label: 'post-1'),
        SectionCard(
          title: 'Comments on post #1',
          trailing: _fetchingPill(comments),
          child: _view<List<Comment>>(
            comments,
            (data) => Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                for (final comment in data)
                  _CommentRow(comment.author, comment.text),
                const SizedBox(height: 4),
                Text('comments=${data.length}'),
              ],
            ),
          ),
        ),
        QueryDebugStrip(
          queryKey: apiKey('/posts/1/comments'),
          label: 'comments-1',
        ),
        SectionCard(
          title: 'A key with no post behind it',
          trailing: Tooltip(
            message: 'Fetch a missing post',
            child: OutlinedButton(
              onPressed: _fetchMissing
                  ? null
                  : () => setState(() => _fetchMissing = true),
              child: const Text('Fetch a missing post'),
            ),
          ),
          child: missing == null
              ? const Text(
                  "['api', '/posts/999'] goes through the same default; "
                  'the backend decides what it answers.',
                )
              : _view<Post>(missing, (data) => Text(data.title)),
        ),
        QueryDebugStrip(queryKey: apiKey('/posts/999'), label: 'post-999'),
        SectionCard(
          title: 'A mutation with only a key',
          trailing: Tooltip(
            message: 'Create a todo',
            child: FilledButton.tonal(
              onPressed: create.value.isPending
                  ? null
                  : () => create.mutate('Written by the default mutationFn'),
              child: const Text('Create a todo'),
            ),
          ),
          child: switch (create.value) {
            MutationIdle() => const Text(
                'The mutation carries its key and nothing else; its '
                'function is the default registered for that key.',
              ),
            MutationPending() => const Text('creating…'),
            MutationSuccess(:final data) => Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text('new todo id=${data.id}'),
                  Text(data.text),
                ],
              ),
            MutationError(:final error) => Notice('$error', error: true),
          },
        ),
      ],
    );
  }

  static String _setOrNone(Object? function) =>
      function == null ? 'none' : 'set';

  static Widget? _fetchingPill(QueryResult<Object?> result) =>
      result.isFetching && !result.isPending ? const Pill('refreshing') : null;

  /// One query's states; the data goes through [body].
  static Widget _view<T>(QueryResult<T> result, Widget Function(T data) body) =>
      switch (result) {
        QueryPending() => const Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              SkeletonBox(height: 20, width: 240),
              SizedBox(height: 8),
              SkeletonBox(),
            ],
          ),
        QueryError(:final error, staleData: null) =>
          Notice('$error', error: true),
        QuerySuccess(:final data) ||
        QueryError(staleData: final T data) =>
          body(data),
      };
}

/// One comment: its author in a fixed-width column, its text beside it.
///
/// It lives here, beside its one caller, rather than in `lib/shared/`: a
/// module in `shared/` is something more than one feature calls.
class _CommentRow extends StatelessWidget {
  const _CommentRow(this.label, this.value);

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) => Padding(
        padding: const EdgeInsets.symmetric(vertical: 2),
        child: Row(
          children: <Widget>[
            SizedBox(
              width: 140,
              child: Text(
                label,
                style: Theme.of(context).textTheme.labelLarge,
              ),
            ),
            Expanded(child: Text(value)),
          ],
        ),
      );
}
```

</details>

## Related

- Guides: [Default query function](https://dualmeta-gmbh.github.io/query_kit/docs/guides/default-query-function.md), [Query keys](https://dualmeta-gmbh.github.io/query_kit/docs/guides/query-keys.md)
- Upstream: TanStack's React [`default-query-function`](https://github.com/TanStack/query/tree/main/examples/react/default-query-function) example
- Tested by `test/features/default_query_function_test.dart` (widget) and `e2e/tests/default_query_function.spec.ts` (browser)
- [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/default_query_function)
