Zoho CRMのCOQLとは、SQLに似た書き方でCRMのレコードを検索できるクエリ言語です。ウィジェット(CRMの画面の中に自作のHTMLとJavaScriptの画面を組み込む仕組み)からも呼び出せますが、当社の確認では、REST API v8の公式ドキュメントとは違う制約を受けました。

ウィジェットを使うと、標準のインポート機能では難しい「取り込む前に既存データと照らし合わせて、人が確かめてから登録する」という流れを、CRMの中で完結させられます。

当社は、西日本の食品卸(中小企業)の支援で、販売実績のExcelを月ごとにCRMへ取り込むウィジェットを作りました。Excelに載っている得意先を、CRMの既存の取引先と名寄せ(同じ相手かどうかを照合して1つにまとめること)してから、販売実績を登録する仕組みです。

この記事では、その実装で実際に起きた2つの問題と対処を、動く形のコードでまとめます。ウィジェットの作り方そのものは「Zoho CRMに「類似案件レコメンド」を自作する|Embedded Appウィジェット実装」で紹介しています。

  • COQL(Zoho CRMのSQLに似た検索言語)で全件を取ろうとして、件数上限のエラーで止まった
  • 取引先が0件の環境で、COQLの応答が空になり、JSONの解析エラーで画面が止まった

前提:ウィジェットから取引先をどう取得するか

ウィジェットでは、Zohoが提供するJavaScriptのSDK(Embedded App SDK)を読み込み、ZOHO.CRM.API.coql() でCOQLを実行できます。認証はCRMにログインしている利用者の権限で行われるため、ウィジェット側でトークンを管理する必要はありません。

名寄せでは、Excelに出てくる得意先ごとに、CRMの取引先を次の順で探しました。

  1. 一意キー(取引先ごとに重複しない値。今回は「帳簿コード+元の得意先コード」を連結したもの)で一致するか
  2. 一意キーで見つからなければ、正規化した取引先名で一致するか
  3. それでも決まらなければ、利用者に「既存の取引先を選ぶ」か「新規作成する」かを選んでもらう

この照合のために、ウィジェットを開いた時点でCRMの取引先を取得します。ここで2つの問題に当たりました。

落とし穴1:ウィジェットのCOQLは何件まで取れる?1回200件の上限

起きたこと

Zoho CRM API v8の公式ドキュメント(COQLの概要と制限のページ)には、LIMIT 句の既定値は200件、最大値は2,000件と書かれています。これを前提に、1回で多めに取得しようとしました。

ところが、ウィジェットから ZOHO.CRM.API.coql() を実行すると、201件以上を指定した時点で LIMIT_EXCEEDED が返り、取得できませんでした。サンドボックス(本番と切り離した検証用の環境)で読み取り専用のCOQLを試したところ、200件までは正常、201件以上でこのエラーになりました。

原因の見立て

当社が確認した範囲では、ウィジェットSDKのCOQLはAPI v8ではなく、v2と同じ制約を受ける経路で動いているように見えました。API v2の公式ドキュメント(COQLの制限のページ)には「1回のAPI呼び出しで取得できるのは最大200件」と書かれており、挙動と一致します。

同じ経路では、ほかにも次の違いがありました。

項目 API v2の公式ドキュメント API v8の公式ドキュメント ウィジェットSDKでの扱い(当社の確認)
1回の取得件数 200件まで 2,000件まで 200件まで
in 条件の値 50値まで 100値まで 50値までで設計
ページ送りで取れる累計 同じ条件で10,000件まで 同じ条件で100,000件まで v2側の値で設計
COUNT などの集計・GROUP BY — 使える 使えなかった(エラーになった)

なお、API v8の公式ドキュメントにも、1回の取得件数について2,000件と200件の両方の記載があります。実際の上限は、お使いの環境で201件の取得を試して確かめてください。

集計と GROUP BY は、ウィジェットから実行したときに「select column should be given in group by clause」というエラーが出ました。v8の書き方がそのまま通らない例です。

ウィジェットからv8の上限で取得したい場合は、CRMの「接続(Connection)」を作り、ZOHO.CRM.CONNECTION.invoke() 経由でREST API v8を呼ぶ方法があります。ただし接続の作成と共有は管理者の作業です。全員の画面で必ず動かしたい処理は、SDKの直接呼び出し(200件)を基準に組むほうが安全です。

対処:200件に固定してページを送る

1回の取得件数を200件に固定し、応答の info.more_records が true の間だけオフセットを進めます。

落とし穴2:「Unexpected end of JSON input」はなぜ出る?0件時の空応答(204)

取引先が0件の環境では空の応答が返り、そのまま解析すると解析エラーになるため、空の応答を0件として扱う分岐を入れる直し方を示した図

起きたこと

検証用のサンドボックスには、取り込み前なので取引先が1件もありませんでした。この状態でウィジェットを開くと、取引先の取得で「Unexpected end of JSON input」というエラーが出て、画面が止まりました。

原因

条件に合うレコードが0件のとき、Zoho CRMは本文なしの応答を返しました。HTTPの204(No Content。成功したが返す中身がない、という意味)です。Zoho CRM APIの公式ドキュメントのステータスコード一覧にも、204は「リクエストに対して返す内容がない」と記載されています。

SDKはこの空の本文をJSONとして読もうとし、解析エラーにして失敗(Promiseの reject)を返していました。つまり「0件という正常な結果」が「エラー」に見えていたわけです。

対処:0件だけを空配列に変換し、ほかのエラーは止める

ここでやってはいけないのは、COQLの失敗をすべて「0件」とみなすことです。権限不足や通信の失敗まで0件として扱うと、本当は既存の取引先があるのに「見つからない」と判断し、新規作成で重複登録を生みます。

そこで、204か、空本文によるJSON解析エラーかを判定する関数を用意し、それ以外はそのまま投げ直します。

実装コード

ここまでの対処をまとめたコードです。ウィジェットの widget.html で Embedded App SDK を読み込み、ZOHO.embeddedApp.init() が完了した後に呼び出す前提です。項目名は一般的な名前に置き換えています(Account_Key__c は一意キー用のカスタム項目の例です)。

// coql-client.js
// ウィジェットSDKの COQL は 1 回 200 件までとして扱う
const COQL_PAGE_SIZE = 200;
// in 条件に入れる値の数(API v2 の上限 50 に合わせる)
const COQL_IN_VALUE_LIMIT = 50;
// 同じ条件でページ送りできる累計(API v2 の公式値 10,000 件)
const COQL_MAX_TOTAL = 10000;

// 0件の応答(204、または空本文の JSON 解析エラー)かどうか
export function isEmptyCoqlResponse(error) {
  const status = Number(error?.status || error?.statusCode || error?.response?.status || 0);
  if (status === 204) return true;
  return error?.name === "SyntaxError"
    && /unexpected end of json input/i.test(String(error?.message || ""));
}

function pageData(response) {
  if (Array.isArray(response?.data)) return response.data;
  return [];
}

// 条件に合うレコードを 200 件ずつ全件取得する
export async function coqlAll(fields, moduleName, where) {
  const records = [];
  let offset = 0;
  while (true) {
    const query = `select ${fields.join(", ")} from ${moduleName} where ${where} limit ${offset}, ${COQL_PAGE_SIZE}`;
    let response;
    try {
      response = await ZOHO.CRM.API.coql({ select_query: query });
    } catch (error) {
      if (isEmptyCoqlResponse(error)) break; // 0件は正常終了
      throw error;                            // 権限・通信などはそのまま止める
    }
    if (Number(response?.status || 0) === 204) break;

    const page = pageData(response);
    records.push(...page);
    if (!response?.info?.more_records) break;
    if (page.length === 0) {
      throw new Error("次のページがあると返されましたが、データが空でした。時間をおいて再実行してください。");
    }
    offset += COQL_PAGE_SIZE;
    if (offset >= COQL_MAX_TOTAL) {
      throw new Error("取得件数が上限に達しました。日付などで条件を分けてください。");
    }
  }
  return records;
}

// 一意キーの一覧から取引先を検索する(50値ずつに分ける)
export async function fetchAccountsByKeys(keys) {
  const safeKeys = [...new Set(keys)].filter((key) => /^[A-Za-z0-9_-]+$/.test(key));
  const result = [];
  for (let i = 0; i < safeKeys.length; i += COQL_IN_VALUE_LIMIT) {
    const values = safeKeys.slice(i, i + COQL_IN_VALUE_LIMIT).map((key) => `'${key}'`).join(", ");
    result.push(...await coqlAll(
      ["id", "Account_Name", "Account_Key__c"],
      "Accounts",
      `(Account_Key__c in (${values}))`,
    ));
  }
  return result;
}

ポイントは3つです。

  1. limit offset, 200 の形でページを送り、more_records が false になったら終わる
  2. catch の中で0件だけを抜け、それ以外は throw する
  3. in 条件に入れる値は形式を検証し、50値ずつに分ける(引用符を含む値などをそのままCOQLへ渡さないため)

なお、OR条件を a = 'x' or a = 'y' or a = 'z' のように括弧なしで3つ以上並べると、構文エラーになる場合があります。公式ドキュメントでも、WHEREの条件が3つ以上のときは括弧で正しく囲むよう案内されています。複数の値で探すときは、OR を並べるより in にまとめるほうが確実です。

名寄せの照合ロジック

取得した取引先と、Excelの得意先を照らし合わせる部分です。全角・半角や空白の揺れをそろえてから比べます。

// account-matcher.js
export function normalizeName(value) {
  return String(value || "")
    .normalize("NFKC")       // 全角英数・半角カナなどをそろえる
    .replace(/\s+/g, "")     // 空白を除く
    .replace(/株式会社|\(株\)|㈱/g, "")
    .toLowerCase();
}

export function matchAccounts(sources, crmAccounts) {
  const byKey = new Map();
  const byName = new Map();
  for (const record of crmAccounts) {
    if (record.Account_Key__c) {
      const list = byKey.get(record.Account_Key__c) || [];
      byKey.set(record.Account_Key__c, [...list, record]);
    }
    const name = normalizeName(record.Account_Name);
    if (name) byName.set(name, [...(byName.get(name) || []), record]);
  }

  return sources.map((source) => {
    const exact = byKey.get(source.key) || [];
    if (exact.length === 1) return { ...source, matchType: "key", candidates: exact, decision: { action: "existing", id: exact[0].id } };
    if (exact.length > 1) return { ...source, matchType: "duplicate-key", candidates: exact, decision: null };

    const named = byName.get(normalizeName(source.name)) || [];
    if (named.length === 1) return { ...source, matchType: "name", candidates: named, decision: { action: "existing", id: named[0].id } };
    // 候補が複数、または0件のときは自動で決めない
    return { ...source, matchType: named.length > 1 ? "ambiguous" : "unmatched", candidates: named, decision: null };
  });
}

decision が null の行が1つでも残っていれば、登録ボタンを押せないようにします。候補が複数ある取引先を自動で1件に決めると、別の取引先に販売実績が付いてしまい、後から付け替えが必要になります。名寄せは「迷ったら止める」を原則にしました。重複の判定キーの決め方は「CRMの重複チェックは「自動マージしない」設計にする」でも扱っています。

0件の扱いをテストで固定する

204の問題は、データが0件の環境でしか起きません。本番で一度データが入ると再現しにくくなるため、テストで挙動を固定しておきます。当社はVitestで次のように書きました。

// coql-client.test.js
import { describe, expect, it, vi, beforeEach } from "vitest";
import { coqlAll, isEmptyCoqlResponse } from "./coql-client.js";

describe("COQL の0件と実エラーの区別", () => {
  beforeEach(() => {
    globalThis.ZOHO = { CRM: { API: { coql: vi.fn() } } };
  });

  it("空本文の JSON 解析エラーは0件として扱う", async () => {
    ZOHO.CRM.API.coql.mockRejectedValueOnce(new SyntaxError("Unexpected end of JSON input"));
    await expect(coqlAll(["id"], "Accounts", "Account_Name is not null")).resolves.toEqual([]);
    expect(isEmptyCoqlResponse({ status: 204 })).toBe(true);
  });

  it("それ以外のエラーは止める", async () => {
    ZOHO.CRM.API.coql.mockRejectedValueOnce(new Error("API unavailable"));
    await expect(coqlAll(["id"], "Accounts", "Account_Name is not null")).rejects.toThrow("API unavailable");
  });

  it("more_records の間は 200 件ずつページを送る", async () => {
    const page = Array.from({ length: 200 }, (_, i) => ({ id: String(i) }));
    ZOHO.CRM.API.coql
      .mockResolvedValueOnce({ data: page, info: { more_records: true } })
      .mockResolvedValueOnce({ data: [{ id: "200" }], info: { more_records: false } });
    const records = await coqlAll(["id"], "Accounts", "Account_Name is not null");
    expect(records).toHaveLength(201);
    expect(ZOHO.CRM.API.coql.mock.calls[1][0].select_query).toContain("limit 200, 200");
  });
});

エラーは「起きた画面」に出す

もう1つ学んだのは、エラーの見せ方です。最初の実装では、エラーの詳細を次の画面にだけ置いていたため、利用者がいる画面には「処理に失敗しました」という一般的な文言しか出ませんでした。

改善後は、エラーが起きた画面に理由と復旧の操作(例:「権限を管理者に確認してから再読み込みしてください」)を残し、あわせてZoho標準のアラート(使えない場合はブラウザ標準のアラート)も出すようにしました。0件を正常として扱う以上、それ以外のエラーは確実に利用者の目に入る必要があります。

まとめ:ウィジェットでCOQLを使うときの確認リスト

  1. 1回の取得件数は200件に固定する(v8の2,000件をそのまま使わない)
  2. info.more_records でページを送り、空ページや上限超えは明示的に止める
  3. 0件の応答(204・空本文のJSON解析エラー)だけを空配列にし、ほかのエラーは画面に出す
  4. in 条件は50値ずつに分け、OR は括弧なしで3つ以上並べない
  5. 集計や GROUP BY が必要なら、接続(Connection)経由でv8を呼ぶ
  6. 名寄せの候補が複数あるときは自動で決めず、利用者の選択を待つ
  7. 0件の環境でのテストを用意し、本番データが入った後も挙動を固定しておく

ウィジェットの異常系テストの組み方は「Zoho CRMのウィジェットで作る受注書入力画面のテスト項目」にまとめています。

Zohoの制限値は、APIのバージョンや呼び出し経路で変わります。上の値は公式ドキュメントと、当社が検証環境で確かめた時点の結果に基づくものです。実装前に、ご自身の環境で200件・201件、0件のケースを一度試すことをおすすめします。

ウィジェットの開発・改修のご相談は、Zoho CRMカスタマイズ・設定代行サービスでお受けしています。

よくある質問

ウィジェットのCOQLで200件を超えるデータは取れないのですか?

1回の呼び出しで取れる件数が200件までという意味です。limit 句のオフセットを200件ずつ進め、info.more_records が false になるまで繰り返せば、続きを取得できます。ページ送りで取れる累計件数にも上限があるため、件数が多い場合は日付などで条件を分けます。

COQLで0件のときに「Unexpected end of JSON input」が必ず出ますか?

環境とSDKの版によって、204として返る場合と、SDK内部のJSON解析エラーとして返る場合がありました。どちらも『0件』として扱えるよう、両方を判定する関数を用意しておくのが安全です。

in 条件にはいくつまで値を入れられますか?

公式ドキュメントでは、API v2のCOQLは50値まで、API v8は100値までと案内されています。ウィジェットSDKからの呼び出しは、当社の確認ではv2と同じ制約を受けたため、50値ずつに分けて検索しています。

名寄せで候補が複数見つかったときはどうすればよいですか?

自動で1件を選ばず、処理を止めて利用者に既存取引先の選択か新規作成かを決めてもらいます。誤って別の取引先に紐づけると、後から販売実績などの付け替えが必要になるためです。