0
0

JsonPathを使ってハマった件

Posted at

何にハマったか

JavaScriptでJsonPathを使ったテストを実行しても問題ないのに、同じコードをNext.jsのアプリで実行するとエラーが出る。

node search-query-parser+JsonPath.js
→問題なし

npm run devで実行すると、
Advanced search error: Error: Expected comma at character 34
のエラーが出ることをDevToolのTerminalで確認。
ブラウザのDevToolのデバッガーでたどっていくと、エラーメッセージは、jsonpath-plusのindex-browser-esm.jsでJsonPathのチェックを実行する関数で発生しており、JsonPathのクエリ文字列に"{"が含まれていることが原因だとわかった。

原因は使っていたJsonPath-plusのライブラリの設定にあった。

JsonPathとは

Jsonのクエリ言語。
ネストした複雑なJsonからでもデータを抽出することができる。2007年以降長く使われていたが、2024年にIETFによって標準として発表された。

In 2024, the IETF published a standard for JSONPath as RFC 9535

JsonPathのライブラリ

JavaScriptなら下記2つが候補になりそう。
今回は☆は少ないけれど、最近更新されているJsonPath-plusを利用。

JsonPath-plusのeval設定

今回ハマったのはここ。

eval:native → JavaScriptが解釈されてからJsonPathが実行される
eval:safe → 文字列としてJsonPathが実行されるため、スクリプトでエラーが出る

const result = JSONPath(path, json, eval:'native');

でエラーは解消する。

eval (default: "safe") - Script evaluation method. safe: In browser, it will use a minimal scripting engine which doesn't use eval or Function and satisfies Content Security Policy. In NodeJS, it has no effect and is equivalent to native as scripting is safe there. native: uses the native scripting capabilities. i.e. unsafe eval or Function in browser and vm.Script in nodejs. false: Disable JavaScript evaluation expressions and throw exceptions when these expressions are attempted. callback [ (code, context) => value]: A custom implementation which is called with code and context as arguments to return the evaluated value. class: A class which is created with code as constructor argument and code is evaluated by calling runInNewContext with context. ``
(Copilotによる翻訳)
eval (デフォルト: "safe") - スクリプト評価メソッド。
safe: ブラウザでは、evalやFunctionを使用せず、コンテンツセキュリティポリシーを満たす最小限のスクリプトエンジンを使用します。NodeJSでは、ネイティブと同等であり、安全なスクリプト実行が可能です。
native: ネイティブのスクリプト機能を使用します。つまり、ブラウザではevalやFunctionを使用し、NodeJSではvm.Scriptを使用します。
false: JavaScript評価式を無効にし、これらの式が試みられた場合に例外をスローします。
callback [(code, context) => value]: codeとcontextを引数として呼び出され、評価された値を返すカスタム実装です。
class: codeをコンストラクタ引数として作成されたクラスで、contextを使用してrunInNewContextを呼び出すことでコードが評価されます。

JsonPathのデモサイト

今回ハマったサンプル

Json

recipes.json
[
        {
            "id": 1,
            "name": "マティーニ",
            "ingredients": [
                {
                    "ingredient_id": 101,
                    "name": "ジン",
                    "amount": "60ml",
                    "type": "スピリッツ"
                },
                {
                    "ingredient_id": 102,
                    "name": "ドライベルモット",
                    "amount": "10ml",
                    "type": "ワイン"
                }
            ],
            "alcohol_content": 0,
            "instructions": "氷と材料をミキシンググラスで混ぜ、カクテルグラスに注ぐ。",
            "category": "クラシック",
            "image": "/images/recipes/550e8400-e29b-41d4-a716-446655440000.jpg"
        },
        {
            "id": 2,
            "name": "グラスホッパー",
            "ingredients": [
                {
                    "ingredient_id": 103,
                    "name": "クリーム",
                    "amount": "30ml",
                    "type": "ノンアルコール"
                },
                {
                    "ingredient_id": 104,
                    "name": "メンソールリキュール",
                    "amount": "30ml",
                    "type": "スピリッツ"
                },
                {
                    "ingredient_id": 105,
                    "name": "カカオリキュール",
                    "amount": "30ml",
                    "type": "スピリッツ"
                }
            ],
            "alcohol_content": 0,
            "instructions": "全ての材料をシェイカーに入れ、よく振ってカクテルグラスに注ぐ。",
            "category": "アフターディナー",
            "image": "/images/recipes/6ba7b810-9dad-11d1-80b4-00c04fd430c8.jpg"
        },
        {
            "id": 3,
            "name": "マティーニ・グラスホッパー",
            "ingredients": [
                {
                    "ingredient_id": 1,
                    "name": "マティーニ",
                    "amount": "30ml",
                    "type": "cocktail"
                },
                {
                    "ingredient_id": 2,
                    "name": "グラスホッパー",
                    "amount": "30ml",
                    "type": "cocktail"
                }
            ],
            "alcohol_content": 0,
            "instructions": "マティーニとグラスホッパーを等量混ぜ、カクテルグラスに注ぐ。",
            "category": "クリエイティブ",
            "image": ""
        }
 ]

JsonPath

$[?((@.name.match(/ホッパー$/i)) && !@.ingredients.some(function($i){return (Object.values($i).some(function(val) { return val.toString() == "クリーム" || val.toString().match(/ベルモット$/i) }))}))]
これは、function($si){ の"{"がクエリ解析エラーになった。

$[?(!@.ingredients.some($i => Object.values($i).some(val => (val.toString() == "クリーム" || val.toString().match(/ベルモット$/i)) )))]
こうすると =>の>がクエリ解析エラーとなった。

いずれも下記のJavaScriptを
node search-query-parser+JsonPath.js
で実行すると発生しないので、JsonPath-plusを使うときは必ず、
JsonPath-plus
https://jsonpath-plus.github.io/JSONPath/demo/
で確認した方が良いです。

Javacript

search-query-parser+JsonPath.js
import searchQuery from 'search-query-parser';
import { JSONPath } from 'jsonpath-plus';

/*
//デフォルトだと「-ingredients:(クリーム OR *ティーニ) name:ホッパー」の()が処理できない
function parseQuery(query) {
  const options = {
    keywords: ['ingredients','name', 'ingredients.name', 'ingredients.type', 'ingredients.amount', 'category'],
    ranges: ['ingredients.alcohol_content'],
    alwaysArray: ['ingredients','name', 'ingredients.name', 'ingredients.type', 'ingredients.amount', 'category']
  };

  return searchQuery.parse(query, options);
}
*/



function parseQuery(query) {
  const options = {
    keywords: ['ingredients', 'name', 'ingredients.name', 'ingredients.type', 'ingredients.amount', "instructions", 'category'],
    ranges: ['alcohol_content'],
    alwaysArray: true,
    offsets: false
  };

  const parsed = searchQuery.parse(query, options);
  //console.log("parsed:", parsed)
  // カスタム処理を追加
  if (parsed.offsets) {
    parsed.offsets.forEach(offset => {
      if (offset.keyword === 'ingredients' && offset.value.includes(' OR ')) {
        offset.value = offset.value.replace(/^\(|\)$/g, '').split(' OR ').map(v => v.trim());
      }
    });
  }

  return parsed;
}

function buildJsonPathQuery(parsed) {
  let conditions = [];

  function buildCondition(key, values, isExclude = false) {
    //console.log("values in func:",values)
    let fieldConditions = []

    if (key != "alcohl_contents"){

    }else{
      //大小比較の処理

    }

    //ネストしている場合で、キーの指定がない場合と、ネストしていない場合で異なる

    if (key == 'ingredients') {

      fieldConditions = values.map(value => {
        let matchExpression;
        if (value.startsWith('*') && value.endsWith('*')) {
          matchExpression = `val.toString().match(/${value.slice(1, -1)}/i)`;
        } else if (value.startsWith('*')) {
          matchExpression = `val.toString().match(/${value.slice(1)}$/i)`;
        } else if (value.endsWith('*')) {
          matchExpression = `val.toString().match(/^${value.slice(0, -1)}/i)`;
        } else {
          matchExpression = `val.toString() == "${value}"`;
        }
        return matchExpression;
      });
          //valusesは配列なので、keyに対して複数の値がある場合はorで結合する
      const condition = fieldConditions.join(' || ');

      return isExclude
          // Next.jsで実行すると、jsonpath-plusのindex-browser-ems.jsのjsepパーサーで{}がエラーになる
          //? `!@.${key}.some(function($i){return (Object.values($i).some(function(val) { return (${condition}) }))})`
          //: `@.${key}.some(function($i){return (Object.values($i).some(function(val) { return (${condition}) }))})`;

          // Next.jsで実行すると、jsonpath-plusのindex-browser-ems.jsのjsepパーサーで => がエラーになる
          ? `!@.${key}.some($i => Object.values($i).some(val => (${condition}) ))`
          : `@.${key}.some($i => Object.values($i).some(val => (${condition}) ))`;

    } else if  (key.startsWith('ingredients.')) {
      const subKey = key.split('.')[1];

      fieldConditions = values.map(value => {
        let matchExpression;
        if (value.startsWith('*') && value.endsWith('*')) {
          matchExpression = `$i.${subKey}.match(/${value.slice(1, -1)}/i)`;
        } else if (value.startsWith('*')) {
          matchExpression = `$i.${subKey}.match(/${value.slice(1)}$/i)`;
        } else if (value.endsWith('*')) {
          matchExpression = `$i.${subKey}.match(/^${value.slice(0, -1)}/i)`;
        } else {
          matchExpression = `$i.${subKey} == "${value}"`;
        }
        return matchExpression;
      });
          //valusesは配列なので、keyに対して複数の値がある場合はorで結合する
      const condition = fieldConditions.join(' || ');
      return isExclude
        ? `!@.ingredients.some(function($i){ return (${condition}) })`
        : `@.ingredients.some(function($i){ return (${condition}) })`;
    } else {
      fieldConditions = values.map(value => {
        let matchExpression;
        if (value.startsWith('*') && value.endsWith('*')) {
          matchExpression = `@.${key}.match(/${value.slice(1, -1)}/i)`;
        } else if (value.startsWith('*')) {
          matchExpression = `@.${key}.match(/${value.slice(1)}$/i)`;
        } else if (value.endsWith('*')) {
          matchExpression = `@.${key}.match(/^${value.slice(0, -1)}/i)`;
        } else {
          matchExpression = `@.${key} == "${value}"`;
        }
        return matchExpression;
      });
          //valusesは配列なので、keyに対して複数の値がある場合はorで結合する
      const condition = fieldConditions.join(' || ');

      return isExclude
        ? `!(${condition})`
        : `(${condition})`;
    }
  }

  // Handle include conditions
  Object.entries(parsed).forEach(([key, values]) => {
    console.log("key:",key)
    console.log("values:",values, Array.isArray(values))
    let isExclude = false
    if (key == 'exclude') {
      if(Object.keys(values).length !=0){
        isExclude = true
        key = Object.keys(values)[0];
        values = values[key];
        console.log("key:",key)
        console.log("values:",values)
        conditions.push(`${buildCondition(key, values, isExclude)}`)
      }
    }else{
      conditions.push(`${buildCondition(key, values, isExclude)}`)
    }
  });

  return conditions.length > 0 ? `$[?(${conditions.join(' && ')})]` : '$[*]';
}

function searchRecipes(recipes, query) {
  const parsed = parseQuery(query);
  const jsonPathQuery = buildJsonPathQuery(parsed);
  console.log('JsonPath Query:', jsonPathQuery);
  return JSONPath({ path: jsonPathQuery, json: recipes });
}

// テスト実行
const recipes = [
  {
    "id": 1,
    "name": "マティーニ",
    "ingredients": [
      {"ingredient_id": 101, "name": "ジン", "amount": "60ml", "type": "スピリッツ"},
      {"ingredient_id": 102, "name": "ドライベルモット", "amount": "10ml", "type": "ワイン"}
    ],
    "instructions": "氷と材料をミキシンググラスで混ぜ、カクテルグラスに注ぐ。",
    "category": "クラシック",
    "image": "/images/recipes/550e8400-e29b-41d4-a716-446655440000.jpg"
  },
  {
    "id": 2,
    "name": "グラスホッパー",
    "ingredients": [
      {"ingredient_id": 103, "name": "クリーム", "amount": "30ml", "type": "ノンアルコール"},
      {"ingredient_id": 104, "name": "メンソールリキュール", "amount": "30ml", "type": "スピリッツ"},
      {"ingredient_id": 105, "name": "カカオリキュール", "amount": "30ml", "type": "スピリッツ"}
    ],
    "instructions": "全ての材料をシェイカーに入れ、よく振ってカクテルグラスに注ぐ。",
    "category": "アフターディナー",
    "image": "/images/recipes/6ba7b810-9dad-11d1-80b4-00c04fd430c8.jpg"
  },
  {
    "id": 3,
    "name": "マティーニ・グラスホッパー",
    "ingredients": [
      {"ingredient_id": 1, "name": "マティーニ", "amount": "30ml", "type": "cocktail"},
      {"ingredient_id": 2, "name": "グラスホッパー", "amount": "30ml", "type": "cocktail"}
    ],
    "instructions": "マティーニとグラスホッパーを等量混ぜ、カクテルグラスに注ぐ。",
    "category": "クリエイティブ",
    "image": ""
  }
];

//const query = '-ingredients:クリーム,*ベルモット name:*ホッパー ingredients.type:cocktail'; //$[?((@.name.match(/ホッパー$/i)) && @.ingredients.some(function($i){ return $i.type == "cocktail"; }) && !@.ingredients.some(function($i){return (Object.values($i).some(function(val) { return val.toString() == "クリーム" || val.toString().match(/ベルモット$/i); }))}))]
//const query = 'ingredients:クリーム'; //$[?(@.ingredients.some(function($i){return (Object.values($i).some(function(val) { return val.toString() == "クリーム"; }))}))]
//const query = 'ingredients:クリ*,*ベルモット'; //$[?(@.ingredients.some(function($i){return (Object.values($i).some(function(val) { return val.toString().match(/^クリ/i) || val.toString().match(/ベルモット$/i); }))}))]
//const query = '-ingredients:クリーム,*ベルモット'; //$[?(!@.ingredients.some(function($i){return (Object.values($i).some(function(val) { return val.toString() == "クリーム" || val.toString().match(/ベルモット$/i); }))}))]
//const query = 'name:*ホッパー,マティーニ'; //$[?((@.name.match(/ホッパー$/i) || @.name == "マティーニ"))]
//const query = 'ingredients.type:cocktail'; //$[?(@.ingredients.some(function($i){return $i.type == "cocktail"}))]
//const query = 'ingredients.type:cocktail,スピリッツ'; //$[?(@.ingredients.some(function($i){ return $i.type == "cocktail" || $i.type == "スピリッツ"; }))]
//const query = '-ingredients.type:スピリッツ'; //$[?(!@.ingredients.some(function($i){ return $i.type == "スピリッツ"; }))]
//const query = '-name:グラスホッパー,マティーニ'; //$[?(!(@.name == "グラスホッパー" || @.name == "マティーニ"))]
const query = '-ingredients:クリーム,*ベルモット name:*ホッパー'
console.log('Query:', query);
console.log('Parsed Query:', parseQuery(query));
console.log('Search Results:', searchRecipes(recipes, query));
console.log('Search Results id List:', searchRecipes(recipes, query).map(recipe => recipe.id));

JsonPathを使おう

今回はWebアプリでデータベースを検索したくて色んなものを試しましたが、
レシピのJsonデータベースをシンプルに検索できるのがJsonPathを用いた方法でした。
検索文は人が入力しますが、ユーザーの入力文をクエリに変換するのが困難です。
そこで、パーサーであるsearch-query-parserが役立ちます。
Keyの値に対してAND/ORを指定できるのが理想でしたが、search-query-parserはkeyに対してカンマ区切りしか受け付けないので、keyに対するtextはOR検索、複数条件はAND検索として、ユーザーが使い分けることである程度の複雑な条件は実現できました。
JsonPathを使うと、NOT(A or B)がNOT A and NOT Bとして正しく処理されるのがありがたいです(FlexSearchではNOT指定ができなかったので)。

パーサー

  • search-query-parser: Webアプリケーションやデータベースの検索機能の実装に直接使用できます。一般的な検索構文を簡単に解析できます。
    レシピ検索のような特定のドメインの検索機能を実装する場合、search-query-parserが最も直接的に使用できる可能性が高いです。ただし、より複雑な検索構文や高度なカスタマイズが必要な場合は、PEG.jsを使用して独自のパーサーを作成することも検討できます。
    Clause 3.5 Sonnetによる解説

その他のJson検索方法

  • FlexSearch
    https://github.com/nextapps-de/flexsearch
    フロントエンドでも使える最速のサーチエンジン。
    → Keyを指定した検索も、階層構造をもったキーの検索も可能。Jsonの検索には階層構造の指定が必要(2階層まで?)。フィールド内のOR、AND検索、フィールド間のAND、OR検索はできるがNOT検索はできない。これはあとから実装すればよいはず。

  • lowdb+lodash
    また試してないですがフロントエンドで使える高速DBということで、良さそうに思っています。
    https://www.npmjs.com/package/lowdb
    https://lodash.com/

検索ライブラリ参考資料

Top 6 JavaScript Search Libraries
https://byby.dev/js-search-libraries

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0