レコードIDを使った一括取得

にメンテナンス済み

kintone には API を使ってレコード情報を取得する方法が複数用意されています。

私は普段カーソル API を使った方法を好んで使用していましたが、カーソルは同一ドメイン内で同時に 10 個しか作成できないという厳しい制約があります。

この制約に引っかからないよう、別の手段が必要だと考えていた時に見つけたのが、シーク法と呼ばれるレコード ID をもとに一括取得する方法です。

今回はこのレコード ID を使ったレコードの一括取得について考えます。

取得方法について

今回実施する取得方法の流れは、大きく分けて以下の通りです。

  • レコード ID で並び替える
  • レコードを取得する
  • レコードの取得数が取得上限と同様の場合は、最後に取得したレコード ID より小さい(もしくは大きい)レコードを取得する
  • 取得数が上限でない場合は、全レコードを取得したものとして返却

要は、レコード ID 順に少しずつレコードを集めていく方法です。

非常にシンプルで、カーソル API を使用する方法より理解しやすいんじゃないかと思います。

developer network に掲載されているコードがよくわからない

まず、developer network に掲載されているレコード ID を使用した方法を確認したところ、やりたいことは分かりましたが、サンプルコードがよくわかりませんでした。

矢印をクリックするとソースコード全体を確認できます

developer network に掲載されているコード
/*
 * get all records function by using record id sample program
 * Copyright (c) 2019 Cybozu
 *
 * Licensed under the MIT License
 */

/*
 * @param {Object} params
 *   - app {String}: アプリID(省略時は表示中アプリ)
 *   - filterCond {String}: 絞り込み条件
 *   - sortConds {Array}: ソート条件の配列
 *   - fields {Array}: 取得対象フィールドの配列
 * @return {Object} response
 *   - records {Array}: 取得レコードの配列
 */
const getRecords = (_params) => {
  const MAX_READ_LIMIT = 500;

  const params = _params || {};
  const app = params.app || kintone.app.getId();
  const filterCond = params.filterCond;
  const sortConds = params.sortConds || ['$id asc'];
  const fields = params.fields;
  let data = params.data;

  if (!data) {
    data = {
      records: [],
      lastRecordId: 0,
    };
  }

  const conditions = [];
  const limit = MAX_READ_LIMIT;
  if (filterCond) {
    conditions.push(filterCond);
  }

  conditions.push('$id > ' + data.lastRecordId);

  const sortCondsAndLimit = ` order by ${sortConds.join(', ')} limit ${limit}`;
  const query = conditions.join(' and ') + sortCondsAndLimit;
  const body = {
    app: app,
    query: query,
  };

  if (fields && fields.length > 0) {
    // $id で並び替えを行うため、取得フィールドに「$id」フィールドが含まれていなければ追加します
    if (fields.indexOf('$id') <= -1) {
      fields.push('$id');
    }
    body.fields = fields;
  }

  return kintone.api(kintone.api.url('/k/v1/records', true), 'GET', body).then((r) => {
    data.records = data.records.concat(r.records);
    if (r.records.length === limit) {
      // 取得レコードの件数が limit と同じ場合は、未取得のレコードが残っている場合があるので、getRecords を再帰呼び出して、残りのレコードを取得します
      data.lastRecordId = r.records[r.records.length - 1].$id.value;
      return getRecords({
        app: app,
        filterCond: filterCond,
        sortConds: sortConds,
        fields: fields,
        data: data,
      });
    }
    delete data.lastRecordId;
    return data;
  });
};

今回の取得方法が適しているシナリオとして、以下のように紹介されています。

この方法は、次のようなシナリオに適しています。

レコードのソート条件を必要としない場合(レコード ID 順で問題ない場合) レコード ID 順にレコードを取得した後、プログラムのロジックで別のソートができる場合 offset の制限値を考慮した kintone のレコード一括取得について

ただ、サンプルコードには引数にソート順を指定することができてしまいますし、ソート順を指定すると正しく動作しません。

公式のライブラリを確認する

次に、公式に提供されている SDKを確認しました。

レコードの一括取得を行っている部分はここです。

SDKのソースコード
class RecordClient {
  /* ..省略.. */

  public async getAllRecordsWithId<T extends Record>(params: {
    app: AppID;
    fields?: string[];
    condition?: string;
  }): Promise<T[]> {
    const { fields: originalFields, ...rest } = params;
    let fields = originalFields;
    // Append $id if $id doesn't exist in fields
    if (fields && fields.length > 0 && fields.indexOf('$id') === -1) {
      fields = [...fields, '$id'];
    }
    return this.getAllRecordsRecursiveWithId({ ...rest, fields }, '0', []);
  }

  private async getAllRecordsRecursiveWithId<T extends Record>(
    params: {
      app: AppID;
      fields?: string[];
      condition?: string;
    },
    id: string,
    records: T[]
  ): Promise<T[]> {
    const GET_RECORDS_LIMIT = 500;

    const { condition, ...rest } = params;
    const conditionQuery = condition ? `(${condition}) and ` : '';
    const query = `${conditionQuery}$id > ${id} order by $id asc limit ${GET_RECORDS_LIMIT}`;
    const result = await this.getRecords<T>({ ...rest, query });
    const allRecords = records.concat(result.records);
    if (result.records.length < GET_RECORDS_LIMIT) {
      return allRecords;
    }
    const lastRecord = result.records[result.records.length - 1];
    if (lastRecord.$id.type === '__ID__') {
      const lastId = lastRecord.$id.value;
      return this.getAllRecordsRecursiveWithId(params, lastId, allRecords);
    }
    throw new Error(
      'Missing `$id` in `getRecords` response. This error is likely caused by a bug in Kintone REST API Client. Please file an issue.'
    );
  }

  /* ..省略.. */
}

getAllRecordsWithId部分でデータを整えた後、getAllRecordsRecursiveWithIdで再帰的にレコードの一括取得を実施しています。今度はソート条件を指定できません。

@kintone/rest-api-client を使用できる環境であれば、getAllRecordsWithRecordIdを使用すればほとんどのシナリオで解決できそうです。

意味自体は理解できたので、自分なりに 1 から作ってみたいと思います。

サンプルコード

ここまでの情報をふまえて、レコード ID を使ったレコードの一括取得関数を自作しました。

TypeScript

const CHUNK_SIZE = 500;

export const getAllRecordsWithId = async <
  T extends { $id: unknown } & Record<string, unknown>
>(props: {
  app: number | string;
  fields?: (keyof T)[];
  onStep?: (current: T[]) => void;
  condition?: string;
}): Promise<T[]> => {
  const { fields: originalFields = [], condition = '' } = props;

  const fields = [...new Set([...originalFields, '$id'])];

  return getRecursive<T>({ ...props, fields, condition });
};

const getRecursive = async <T>(props: {
  app: number | string;
  fields: (keyof T)[];
  condition: string;
  onStep?: (current: T[]) => void;
  id?: string;
  stored?: T[];
}): Promise<T[]> => {
  const { app, fields, condition, id } = props;

  const newCondition = id ? `${condition ? `${condition} and ` : ''} $id < ${id}` : condition;

  const query = `${newCondition} order by $id desc limit ${CHUNK_SIZE}`;

  const { records } = await kintone.api(kintone.api.url('/k/v1/records', true), 'GET', {
    app,
    fields,
    query,
  });

  const stored = [...(props.stored ?? []), ...records];

  if (props.onStep) {
    props.onStep(stored);
  }

  const lastRecord = stored[stored.length - 1];
  const lastId = lastRecord.$id.value;

  return records.length === CHUNK_SIZE ? getRecursive({ ...props, id: lastId, stored }) : stored;
};

JavaScript

const CHUNK_SIZE = 500;

/**
 * @template T
 * @param props {{ app: number | string; fields?: (keyof T)[]; onStep?: (current: T[]) => void; condition?: string; }}
 * @returns { Promise<T[]> }
 */
export const getAllRecordsWithId = async (props) => {
  const { fields: originalFields = [], condition = '' } = props;

  const fields = [...new Set([...originalFields, '$id'])];

  return getRecursive({ ...props, fields, condition });
};

/**
 * @template T
 * @param props {{ app: number | string; fields: (keyof T)[]; condition: string; onStep?: (current: T[]) => void; id?: string; stored?: T[];}}
 * @returns  { Promise<T[]> }
 */
const getRecursive = async (props) => {
  const { app, fields, condition, id } = props;

  const newCondition = id ? `${condition ? `${condition} and ` : ''} $id < ${id}` : condition;

  const query = `${newCondition} order by $id desc limit ${CHUNK_SIZE}`;

  const { records } = await kintone.api(kintone.api.url('/k/v1/records', true), 'GET', {
    app,
    fields,
    query,
  });

  const stored = [...(props.stored ?? []), ...records];

  if (props.onStep) {
    props.onStep(stored);
  }

  const lastRecord = stored[stored.length - 1];
  const lastId = lastRecord.$id.value;

  return records.length === CHUNK_SIZE ? getRecursive({ ...props, id: lastId, stored }) : stored;
};

ポイント

SDK に倣って関数を 2 つに分けましたが、これはどちらでもよいと思います。

引数

引数についてですが、ポータビリティを担保するためにアプリ ID を必須にしています。

未指定の場合はkintone.getId()を実行するように設定することも可能ですが、予期せぬエラーにつながることもあるため今回は必須としました。

また、今回やりたかったのはonStepというコールバック関数を引数に加えることです。

これによって、全てのデータを取得しきる前にも、随時最新のレコード情報について処理を実行したい場合にも対応できるようになります。

取得順

サンプルではレコード ID の昇順にデータを取得していましたが、古いデータより新しいデータの方がより早く知りたいケースが多いんじゃないかと思ったので、レコード ID の降順で取得しています。

シーク法の記事を読む限り大丈夫なはず…です。

#kintone #JavaScript #TypeScript