Query planning

How the plugin turns a GraphQL query into drizzle queries

This page describes how the plugin turns a GraphQL query into drizzle queries, which is worth knowing when a schema issues more queries than you expect.

How fields get their data

A field either reads its data from a row that has already been loaded, or runs a query of its own.

A field's select is planned into the query of the nearest ancestor that runs one: a t.drizzleField, a t.relation, a connection, or a fallback query. The field then reads what it needs off the loaded row, without a query of its own. A field runs its own query when its resolve queries drizzle directly, and when the plugin issues a fallback query for a relation that is missing from the row.

A field can do both. A t.drizzleField, or any other field with a select, nested under one of those ancestors has its select planned into the parent's row, and still runs its own query when it resolves.

A field-level select is merged into the same query as its siblings and the type-level selection, rather than being kept separate for that field. Two selections of the same relation share a place in that query only when their arguments (where, orderBy, limit, ...) match. When they differ, the first one planned wins, and the other is loaded with a query of its own. A type-level select is planned before any field's selection, no matter where they appear in the document, and fields are planned in the order they are selected.

Compare two orderings of one relation

A client may need both the newest and oldest published posts on one author page:

query CompareOrderings {
  author(id: 1) {
    newest: posts {
      title
    }
    oldest: posts(oldestFirst: true) {
      title
    }
  }
}

The published-posts field translates those arguments into different database orderings. The two fields cannot reuse the same loaded relation because their orderings differ. The additional ordering is loaded through a fallback query.

In this example, selecting one ordering executes one SQL statement; selecting both executes two. The number of GraphQL field resolvers does not determine the number of database queries.

posts: t.relation('posts', {
  args: { oldestFirst: t.arg.boolean() },
  query: (args) => ({
    where: { published: true },
    orderBy: {
      createdAt: args.oldestFirst ? 'asc' : 'desc',
      id: args.oldestFirst ? 'asc' : 'desc',
    },
  }),
}),

Nested selections

A select function receives nestedSelection, which plans the selection beneath the field for the field's own type. It returns the query config for that table, or a many config for a list field. Any keys you pass in are kept as they were given, so columns passed to nestedSelection will still narrow the parent shape:

builder.drizzleObject('users', {
  name: 'User',
  fields: (t) => ({
    previewPosts: t.field({
      type: ['posts'],
      select: (args, ctx, nestedSelection) => {
        return {
          // what the query selects on the posts, limited to one
          with: {
            posts: nestedSelection({
              where: { published: true },
              orderBy: { id: 'asc' },
              limit: 1,
            }),
          },
        };
      },
      resolve: (user) => user.posts,
    }),
  }),
});

nestedSelection(query, path, type) takes two more arguments:

  • path: a list of field names, for selecting a field nested under the field's type. A segment can also be written as { name, type } to pin the implementation the field must be found under.
  • type: a member of an interface or union, to read the selection as that type.

The nestedSelection function also carries the path and segments of the field being planned, which are described under Relation queries.

Async selections

When a selection depends on asynchronous request data, enable AsyncSelections: true and await nested plans. Async selections covers the builder configuration, supported callbacks, helper options, and errors caused by unsettled promises.

Conflicting selections between variants

When a query selects two variants of one table for the same row, either with a fragment on each under one field, or through a t.variant field, the plugin will throw a PothosValidationError if their select options ask for the same relation with different arguments:

PothosValidationError: Type-level selections of Viewer and Admin conflict on relation "posts".
Move the relation arguments to a field-level select on one of the types.

The same applies to two variants defining the same extras key with different functions (... conflict on extra "lowercaseName"). Both variants describe one row, so their select options are merged into a single query.

To fix this, move the relation with its arguments, or the extra, into the select of the field that needs it on one of the variants. A field-level selection that conflicts with what the row already holds falls back to a query for that field, rather than failing the request.