krisk / Fuse

Lightweight fuzzy-search, in JavaScript
https://fusejs.io/
Apache License 2.0
17.76k stars 753 forks source link

Option to exclude nested array elements that don’t contain matches #690

Closed vwkd closed 1 year ago

vwkd commented 1 year ago

Description

Fuse is conveniently capable of nested search into objects with arrays as properties.

[
  {
    "a": [
      {
        "b": ["lorem", "ipsum"]
      },
      {
        "b": ["dolor", "sit"]
      }
    ]
  },
  {
    "a": [
      {
        "b": ["amen", "consectetur"]
      },
      {
        "b": ["adipiscing", "elit"]
      }
    ]
  }
]

Given the key a.b, searching for lorem yields the first element of the root array.

[
  {
    "item": {
      "a": [
        {
          "b": ["lorem", "ipsum"]
        },
        {
          "b": ["dolor", "sit"]
        }
      ]
    },
    "refIndex": 0
  }
]

However, when listing the results of a search it’s sometimes desirable to filter out any elements of any subarrays that do not contain any matches.

[
  {
    "item": {
      "a": [
        {
          "b": ["lorem"]
        }
      ]
    },
    "refIndex": 0
  }
]

Fuse currently can not filter out non-matching elements of subarrays. Also the option includeMatches only gives the matched leaf values instead of the elements of the root array.

[
  {
    "item": {
      "a": [
        {
          "b": ["lorem", "ipsum"]
        },
        {
          "b": ["dolor", "sit"]
        }
      ]
    },
    "refIndex": 0,
    "matches": [
      {
        "indices": [[0, 4]],
        "value": "lorem",
        "key": "a.b",
        "refIndex": 0
      }
    ]
  }
]

See also inactive https://github.com/krisk/Fuse/issues/186 which gained some support.

Describe the solution you'd like

An option includeNonMatchingArrayElements: false that allows to filter out non-matching subarray elements.

Describe alternatives you've considered

Filtering the results manually by comparing them to the matches. However, the general solution for arbitrarily deeply nested objects is non-trivial, StackOverflow only has a non-general solution. Also it duplicates a lot of search-related code in the app which shouldn’t be necessary.

vwkd commented 1 year ago

As a temporary workaround, I'm using the following function to filter Fuse's search results and return new filtered item properties.

It depends on the utilities deepFilter and deepMerge.

It also depends on a fuse_options object with includeMatches: true and keys with an array of string keys.

function filterResults(results) {

  let resultsNew = [];

  for (const result of results) {

    const { item, matches } = result;

    let resultNew = {};

    // note: group multiple matches per key, otherwise would become separate entries in highest ancestor array if processes separately
    for (const key of fuse_options.keys) {

      const matchesForKey = matches.filter(({ key: k }) => k == key);

      if (matchesForKey.length) {
        const valuesForKey = matchesForKey.map(({ value }) => value);

        const resultForKey = deepFilter(item, key.split("."), obj => valuesForKey.some(value => obj === value));

        resultNew = deepMerge(resultNew, resultForKey);
      }
    }

    resultsNew.push(resultNew);
  }

  return resultsNew;
}

It's certainly not ideal but it seems to get the job done.

Also you might need to adapt it slightly depending on your specific results, like if you only want to filter the matches for certain keys.

github-actions[bot] commented 1 year ago

This issue is stale because it has been open 120 days with no activity. Remove stale label or comment or this will be closed in 30 days

CavalcanteLeo commented 1 year ago

hey @vwkd but by using filter, doesn't it lose all power of fuse.js? such as text proximity?

Have you figured out how to solve this using fuse?

Thanks

e-lobo commented 9 months ago

anything here?

briavicenti commented 3 months ago

Our use case would really benefit from this! We're also having to filter ourselves to get this working. @krisk, any chance we can reopen this issue?

briavicenti commented 3 months ago

In case it's helpful to anyone in the interim, here's our solution to filter results of the following shape:

type NestedOption<T> = {
  data: T;
  children: T[];
}

const exampleData = [{
  data: {
    value: "fruit",
    label: "Fruit",
  },
  children: [
    {
      value: "raspberry",
      label: "Black Raspberry",
    },
    {
      value: "orange",
      label: "Blood Orange",
    },
  ],
}]

The filterOptionArrayForSearch param is just a small wrapper around Fuse that searches for the query text via the label key.


const flattenedDataForSearch = options.flatMap(({ data, children }) => [
  data,
  ...children,
]);

const flatMatches = new Set(
  filterOptionArrayForSearch({
    options,
    query,
  })
);

/**
 * If a parent matches the search query, we want to show all of its children. Otherwise,
 * filter the children down to only those that match the search query.
 */
const parentsWithFilteredChildren = options.map(({ data, children }) => ({
  data,
  children: flatMatches.has(data)
    ? children
    : children.filter((child) => flatMatches.has(child)),
}));

/**
 * Filter the options to those that have matching parent data or at least one matching child.
 */
const result = parentsWithFilteredChildren.filter(({ data, children }) => {
  return flatMatches.has(data) || children.length > 0;
});