[Keystone] 仮想フィールド

仮想フィールド

Keystone では、フィールドを持つリストという形式でデータモデルを定義することができます。ほとんどのリストには、データベースに保存されるテキストや整数などのスカラーフィールドがあります。
また、クエリ時に計算される読み取り専用のフィールドを持つと便利な場合もあります。Keystone では、仮想フィールドタイプを使用してこれを行うことができます。
仮想フィールドは、GraphQL API を拡張するための強力な手段を提供します。このガイドでは、仮想フィールドを追加するための構文を紹介し、簡単な例から複雑な例に進んでいく方法を示します。

Hello world

「Example」というリストを作成し、そこに「hello」という仮想フィールドを作成します。

import { config, createSchema, graphql, list } from '@keystone-6/core';
import { virtual } from '@keystone-6/core/fields';

export default config({
  lists: {
    Example: list({
      fields: {
        hello: virtual({
          field: graphql.field({
            type: graphql.String,
            resolve() {
              return "Hello, world!";
            },
          }),
        }),
      },
    }),
  },
});

これで、GraphQL クエリを実行し、「Example」アイテムの「hello」フィールドをリクエストすることができます。

{
  example(where: { id: "1" }) {
    id
    hello
  }
}

これにより、以下のようなレスポンスが得られます。

{ example: { id: "1", hello: "Hello, world! } }

「hello」の値はresolve関数から生成され、文字列「Hello, world!」を返します。

GraphQL API

仮想フィールドは、@keystone-6/core からの graphql エクスポートで提供される関数を使用して構成されます。この API は、Keystone GraphQL スキーマに型安全な拡張を作成するために必要なインターフェースを提供します。graphql API は、@graphql-ts/schema パッケージに基づいています。
仮想フィールドは、graphql.field() オブジェクトと呼ばれる設定オプションを受け入れます。
例では、graphql.field() に2つの必須オプションを渡しました。オプション type: graphql.String は、仮想フィールドの GraphQL タイプを指定し、resolve() {…} は、このフィールドがクエリされたときに実行される GraphQL リゾルバを定義します。
graphql API は、Int、Float、String、Boolean、ID といった組み込みの GraphQL スカラータイプのサポートを提供し、Keystone カスタムスカラーの Upload と JSON にも対応しています。

リゾルバの引数

「resolve関数」は、より高度な仮想フィールドを作成するために引数を受け取ります。引数は(item、args、context、info)です。item 引数は、クエリされているリストアイテムを表す内部アイテムです。args 引数は、クエリ内のフィールド自体に渡された引数を表します。context 引数は、KeystoneContext オブジェクトです。info 引数には、現在のクエリに関連するフィールド固有の情報とスキーマの詳細が含まれます。
item 引数と context 引数を使用して、Keystone システム内のデータをクエリすることができます。たとえば、Author と Post のリストがあるブログがある場合、Post リストに authorName フィールドを持たせることが便利な場合があります。これは、関連する著者をクエリし、その名前を返す仮想フィールドで行うことができます。

export default config({
  lists: {
    Post: list({
      fields: {
        content: text(),
        author: relationship({ ref: 'Author', many: false }),
        authorName: virtual({
          field: graphql.field({
            type: graphql.String,
            async resolve(item, args, context) {
              const { author } = await context.query.Post.findOne({
                where: { id: item.id.toString() },
                query: 'author { name }',
              });
              return author && author.name;
            },
          }),
        }),
      },
    }),
    Author: list({
      fields: {
        name: text({ validation: { isRequired: true } }),
      },
    }),
  },
});

GraphQLの引数

ブログの例を続けると、ホームページに表示するために、各ブログ投稿の抜粋を抽出したい場合があります。各投稿の完全な Post.content フィールドをクエリし、それをクライアント側でスライスすることもできますが、GraphQL API から必要なスライスだけを取得できればより良いでしょう。
これを行うには、長さ引数を受け取り、リゾルバ関数の一部として.slice() 操作を実行する仮想フィールドを使用することができます。これにより、フロントエンドが抜粋のサイズを制御し、バックエンドが実際の作業を行うことができます。args オプションを使用して、サポートする GraphQL フィールド引数を定義します。

export default config({
  lists: {
    Post: list({
      fields: {
        content: text(),
        excerpt: virtual({
          field: graphql.field({
            type: graphql.String,
            args: {
              length: graphql.arg({
                type: graphql.nonNull(graphql.Int),
                defaultValue: 200
              }),
            },
            resolve(item, { length }) {
              if (!item.content) {
                return null;
              }
              const content = item.content as string;
              if (content.length <= length) {
                return content;
              } else {
                return content.slice(0, length - 3) + '...';
              }
            },
          }),
          ui: { query: '(length: 500)' },
        }),
      },
    }),
  },
});

これにより、次のGraphQLタイプが生成されます。

type Post {
  id: ID!
  content: String
  excerpt(length: Int! = 200): String
}

これで、クライアント側でオーバーフェッチしないですべての抜粋を取得するために、次のクエリを実行できます。

{
  posts {
    id
    excerpt(length: 100)
  }
}

フィールド定義を渡すだけでなく、ui: { query: ‘(length: 500)’ } も渡しています。これは、Admin UI でフィールドを表示する際に使用する値であり、デフォルト値の 200 とは異なる長さを設定するために使用されます。フィールドで defaultValue を指定していなかった場合、Admin UI はこれを指定しなければこのフィールドをクエリできないため、ui.query 引数が必要になります。

GraphQL オブジェクト

上記の例では、スカラーの String 値を返しました。仮想フィールドは、GraphQL オブジェクトを返すように構成することもできます。
ブログの例では、投稿に含まれる単語数、文数、および段落数など、いくつかの統計情報を提供したい場合があります。graphql.object() 関数を使用して、PostCounts という名前の GraphQL タイプを設定して、このデータを表します。

export default config({
  lists: {
    Post: list({
      fields: {
        content: text(),
        counts: virtual({
          field: graphql.field({
            type: graphql.object<{
              words: number;
              sentences: number;
              paragraphs: number;
            }>()({
              name: 'PostCounts',
              fields: {
                words: graphql.field({ type: graphql.Int }),
                sentences: graphql.field({ type: graphql.Int }),
                paragraphs: graphql.field({ type: graphql.Int }),
              },
            }),
            resolve(item: any) {
              const content = item.content || '';
              return {
                words: content.split(' ').length,
                sentences: content.split('.').length,
                paragraphs: content.split('\n\n').length,
              };
            },
          }),
          ui: { query: '{ words sentences paragraphs }' },
        }),
      },
    }),
  },
});

この例は TypeScript で書かれているため、PostCounts タイプが期待するルート値のタイプを指定する必要があります。このタイプは、resolve 関数の戻り値のタイプに対応する必要があります。
仮想フィールドがオブジェクトタイプであるため、オプション ui.query に値を提供する必要もあります。このフラグメントは、Keystone Admin UI に、このフィールドのアイテムページで表示する値を示します。

自己参照オブジェクト

GraphQL の型には、しばしば自己参照が含まれることがあります。その場合、TypeScript で許可するためには、明示的なタイプアノテーションとして graphql.ObjectType<Source> を持つ必要があります。また、フィールドをオブジェクトを返す関数にする必要があります。

type PersonSource = { name: string; friends: PersonSource[] };

const Person: graphql.ObjectType<PersonSource> = graphql.object<PersonSource>()({
  name: "Person",
  fields: () => ({
    name: graphql.field({ type: graphql.String }),
    friends: graphql.field({ type: graphql.list(Person) }),
  }),
});

KeystoneJSが提供する型定義

Keystone の組み込み GraphQL タイプを返す仮想フィールドを持ちたい場合、自作の GraphQL オブジェクトを返す代わりに、各 Author の latestPost を Post オブジェクトとして返したい場合があります。
これを実現するには、field オプションとして graphql.field ({…}) を渡す代わりに、lists => graphql.field ({…}) という関数を渡します。 lists 引数には、すべての Keystone リストのタイプ情報が含まれています。この場合、Post リストの出力タイプを出力する必要があるため、type:lists.Post.types.output を指定します。

export const lists = {
  Post: list({
    fields: {
      title: text(),
      content: text(),
      publishDate: timestamp(),
      author: relationship({ ref: 'Author.posts', many: false }),
    },
  }),
  Author: list({
    fields: {
      name: text({ validation: { isRequired: true } }),
      email: text({ isIndexed: 'unique', validation: { isRequired: true } }),
      posts: relationship({ ref: 'Post.author', many: true }),
      latestPost: virtual({
        field: lists =>
          graphql.field({
            type: lists.Post.types.output,
            async resolve(item, args, context) {
              const { posts } = await context.query.Author.findOne({
                where: { id: item.id.toString() },
                query: `posts(
                    orderBy: { publishDate: desc }
                    take: 1
                  ) { id }`,
              });
              if (posts.length > 0) {
                return context.db.Post.findOne({
                  where: { id: posts[0].id }
                });
              }
            },
          }),
        ui: { query: '{ title publishDate }' },
      }),
    },
  }),
};

再び、この仮想フィールドで表示する Post のどのフィールドを Admin UI で表示するかを指定するために、ui.query を指定する必要があります。

仮想フィールドを扱う

仮想フィールドは、GraphQL API を拡張するための強力な手段を提供しますが、使用する際にはいくつかの注意点があります。
仮想フィールドは、フィールドが要求されるたびにそのリゾルバを実行します。単純な計算の場合は問題ありませんが、複雑な計算の場合はパフォーマンスの問題が発生することがあります。この場合、メモ化してクエリごとの再計算を避けることを検討することができます。別の方法は、スカラーフィールドを使用し、フックを使用してアイテムが更新されるたびに値を設定することです。
もう一つの主な注意点は、仮想フィールドにフィルターを適用することができないことです。各アイテムは値を動的に計算するため、データベースに格納されているわけではありません。フィルタリングが必要な場合は、事前に計算されたスカラーフィールドを使用するのが最適な解決策です。