diff --git a/src/adapter/search-request-adapter/__tests__/facets-distribution-assigns.tests.ts b/src/adapter/search-request-adapter/__tests__/facets-distribution-assigns.tests.ts deleted file mode 100644 index 7fa929d18..000000000 --- a/src/adapter/search-request-adapter/__tests__/facets-distribution-assigns.tests.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { addMissingFacets } from '../filters' - -test('One field in cache present in distribution', () => { - const returnedDistribution = addMissingFacets( - { genre: ['comedy'] }, - { genre: { comedy: 1 } } - ) - - expect(returnedDistribution).toMatchObject({ genre: { comedy: 1 } }) -}) - -test('One field in cache not present in distribution', () => { - const returnedDistribution = addMissingFacets({ genre: ['comedy'] }, {}) - - expect(returnedDistribution).toMatchObject({ genre: { comedy: 0 } }) -}) - -test('two field in cache only one present in distribution', () => { - const returnedDistribution = addMissingFacets( - { genre: ['comedy'], title: ['hamlet'] }, - { genre: { comedy: 12 } } - ) - - expect(returnedDistribution).toMatchObject({ - genre: { comedy: 12 }, - title: { hamlet: 0 }, - }) -}) - -test('two field in cache w/ different facet name none present in distribution', () => { - const returnedDistribution = addMissingFacets( - { genre: ['comedy'], title: ['hamlet'] }, - {} - ) - - expect(returnedDistribution).toMatchObject({ - genre: { comedy: 0 }, - title: { hamlet: 0 }, - }) -}) - -test('two field in cache w/ different facet name both present in distribution', () => { - const returnedDistribution = addMissingFacets( - { genre: ['comedy'], title: ['hamlet'] }, - { genre: { comedy: 12 }, title: { hamlet: 1 } } - ) - - expect(returnedDistribution).toMatchObject({ - genre: { comedy: 12 }, - title: { hamlet: 1 }, - }) -}) - -test('Three field in cache w/ different facet name two present in distribution', () => { - const returnedDistribution = addMissingFacets( - { genre: ['comedy', 'horror'], title: ['hamlet'] }, - { genre: { comedy: 12 }, title: { hamlet: 1 } } - ) - - expect(returnedDistribution).toMatchObject({ - genre: { comedy: 12, horror: 0 }, - title: { hamlet: 1 }, - }) -}) - -test('Cache is undefined and facets distribution is not', () => { - const returnedDistribution = addMissingFacets(undefined, { - genre: { comedy: 12 }, - }) - - expect(returnedDistribution).toMatchObject({ genre: { comedy: 12 } }) -}) - -test('Cache is empty object and facets distribution is not', () => { - const returnedDistribution = addMissingFacets({}, { genre: { comedy: 12 } }) - - expect(returnedDistribution).toMatchObject({ genre: { comedy: 12 } }) -}) - -test('Cache is empty object and facets distribution empty object', () => { - const returnedDistribution = addMissingFacets({}, {}) - - expect(returnedDistribution).toMatchObject({}) -}) - -test('Cache is undefined and facets distribution empty object', () => { - const returnedDistribution = addMissingFacets(undefined, {}) - - expect(returnedDistribution).toMatchObject({}) -}) - -test('Cache is undefined and facets distribution is undefined', () => { - const returnedDistribution = addMissingFacets(undefined, undefined) - - expect(returnedDistribution).toMatchObject({}) -}) diff --git a/src/adapter/search-request-adapter/__tests__/filter-cache.tests.ts b/src/adapter/search-request-adapter/__tests__/filter-cache.tests.ts deleted file mode 100644 index 6a59fb5d3..000000000 --- a/src/adapter/search-request-adapter/__tests__/filter-cache.tests.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { extractFacets } from '../filters' - -const facetCacheData = [ - { - filters: [], - expectedCache: {}, - cacheTestTitle: 'Empty filters should return empty filter cache', - }, - { - filters: 'genre=comedy', - expectedCache: { genre: ['comedy'] }, - cacheTestTitle: 'Filters with one filters should return correctly', - }, - { - filters: ['genre=comedy'], - expectedCache: { genre: ['comedy'] }, - cacheTestTitle: - 'Filters with one filter in an array should return correctly', - }, - { - filters: ['genre=comedy', 'title=hamlet'], - expectedCache: { genre: ['comedy'], title: ['hamlet'] }, - cacheTestTitle: - 'Filters with two filters w/ same filterName in an array should return correctly', - }, - { - filters: ['genre=comedy', 'title=hamlet', 'genre=horror'], - expectedCache: { genre: ['comedy', 'horror'], title: ['hamlet'] }, - cacheTestTitle: - 'Filters with different filterNames in an array should return correctly', - }, - { - filters: ['genre=comedy', 'title=hamlet', ['genre=horror']], - expectedCache: { genre: ['comedy', 'horror'], title: ['hamlet'] }, - cacheTestTitle: - 'Filters with one nested filter in an array should return correctly', - }, - { - filters: [['genre=comedy'], 'title=hamlet', ['genre=horror']], - expectedCache: { genre: ['comedy', 'horror'], title: ['hamlet'] }, - cacheTestTitle: - 'Filters with two nested filter w/ same facet name in an array should return correctly', - }, - { - filters: [['genre=comedy', 'title=hamlet'], ['genre=horror']], - expectedCache: { genre: ['comedy', 'horror'], title: ['hamlet'] }, - cacheTestTitle: - 'Filters with only nested filters in an array should return correctly', - }, - { - filters: [[], ['genre=horror']], - expectedCache: { genre: ['horror'] }, - cacheTestTitle: - 'Filters with one nested filter and one empty nest should return correctly', - }, -] - -describe.each(facetCacheData)( - 'Facet cache tests', - ({ filters, expectedCache, cacheTestTitle }) => { - it(cacheTestTitle, () => { - const cache = extractFacets( - // @ts-ignore ignore to avoid having to add all the searchContext - { keepZeroFacets: false, defaultFacetDistribution: {} }, - { filter: filters } - ) - - expect(cache).toEqual(expectedCache) - }) - } -) diff --git a/src/adapter/search-request-adapter/__tests__/search-params.tests.ts b/src/adapter/search-request-adapter/__tests__/search-params.tests.ts index b4c3de7aa..b042cdfa5 100644 --- a/src/adapter/search-request-adapter/__tests__/search-params.tests.ts +++ b/src/adapter/search-request-adapter/__tests__/search-params.tests.ts @@ -1,10 +1,9 @@ import { adaptSearchParams } from '../search-params-adapter' -import { MatchingStrategies } from '../../../types' +import { MatchingStrategies, SearchContext } from '../../../types' -const DEFAULT_CONTEXT = { +const DEFAULT_CONTEXT: SearchContext = { indexUid: 'test', pagination: { page: 0, hitsPerPage: 6, finite: false }, - defaultFacetDistribution: {}, placeholderSearch: true, keepZeroFacets: false, } diff --git a/src/adapter/search-request-adapter/filters.ts b/src/adapter/search-request-adapter/filters.ts deleted file mode 100644 index 1039db58e..000000000 --- a/src/adapter/search-request-adapter/filters.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - Filter, - ParsedFilter, - FacetDistribution, - FacetsCache, - MeiliSearchParams, - SearchContext, -} from '../../types' -import { removeUndefined } from '../../utils' - -/** - * @param {string} filter - */ -const adaptFilterSyntax = (filter: string) => { - const matches = filter.match(/([^=]*)="?([^\\"]*)"?$/) - if (matches) { - const [_, filterName, value] = matches - return [{ filterName, value }] - } - return [] -} - -/** - * @param {Filter} filters? - * @returns {Array} - */ -function extractFilters(filters?: Filter): Array { - if (typeof filters === 'string') { - return adaptFilterSyntax(filters) - } else if (Array.isArray(filters)) { - return filters - .map((nestedFilter) => { - if (Array.isArray(nestedFilter)) { - return nestedFilter.map((filter) => adaptFilterSyntax(filter)) - } - return adaptFilterSyntax(nestedFilter) - }) - .flat(2) - } - return [] -} - -/** - * @param {Filter} filters? - * @returns {FacetsCache} - */ -export function getFacetsFromFilter(filters?: Filter): FacetsCache { - const extractedFilters = extractFilters(filters) - const cleanFilters = removeUndefined(extractedFilters) - return cleanFilters.reduce( - (cache, parsedFilter: ParsedFilter) => { - const { filterName, value } = parsedFilter - const prevFields = cache[filterName] || [] - cache = { - ...cache, - [filterName]: [...prevFields, value], - } - return cache - }, - {} as FacetsCache - ) -} - -function getFacetsFromDefaultDistribution( - facetDistribution: FacetDistribution -): FacetsCache { - return Object.keys(facetDistribution).reduce((cache: any, facet) => { - const facetValues = Object.keys(facetDistribution[facet]) - return { - ...cache, - [facet]: facetValues, - } - }, {}) -} - -/** - * @param {Filter} filters? - * @returns {FacetsCache} - */ -export function extractFacets( - searchContext: SearchContext, - searchParams: MeiliSearchParams -): FacetsCache { - if (searchContext.keepZeroFacets) { - return getFacetsFromDefaultDistribution( - searchContext.defaultFacetDistribution - ) - } else { - return getFacetsFromFilter(searchParams?.filter) - } -} - -/** - * Assign missing filters to facetDistribution. - * All facets passed as filter should appear in the facetDistribution. - * If not present, the facet is added with 0 as value. - * - * - * @param {FacetsCache} cache? - * @param {FacetDistribution} distribution? - * @returns {FacetDistribution} - */ -export function addMissingFacets( - cachedFacets?: FacetsCache, - distribution?: FacetDistribution -): FacetDistribution { - distribution = distribution || {} - - // If cachedFacets contains something - if (cachedFacets && Object.keys(cachedFacets).length > 0) { - // for all filters in cached filters - for (const cachedFacet in cachedFacets) { - // if facet does not exist on returned distribution, add an empty object - if (!distribution[cachedFacet]) distribution[cachedFacet] = {} - // for all fields in every filter - for (const cachedField of cachedFacets[cachedFacet]) { - // if the field is not present in the returned distribution - // set it at 0 - if (!Object.keys(distribution[cachedFacet]).includes(cachedField)) { - // add 0 value - distribution[cachedFacet][cachedField] = 0 - } - } - } - } - - return distribution -} diff --git a/src/adapter/search-request-adapter/search-resolver.ts b/src/adapter/search-request-adapter/search-resolver.ts index 08ba66837..84fd6b5df 100644 --- a/src/adapter/search-request-adapter/search-resolver.ts +++ b/src/adapter/search-request-adapter/search-resolver.ts @@ -5,7 +5,6 @@ import { SearchCacheInterface, MeiliSearchParams, } from '../../types' -import { addMissingFacets, extractFacets } from './filters' /** * @param {ResponseCacher} cache @@ -42,16 +41,6 @@ export function SearchResolver( .index(searchContext.indexUid) .search(searchContext.query, searchParams) - if (searchContext.keepZeroFacets) { - const cachedFacets = extractFacets(searchContext, searchParams) - - // Add missing facets back into facetDistribution - searchResponse.facetDistribution = addMissingFacets( - cachedFacets, - searchResponse.facetDistribution - ) - } - // Cache response cache.setEntry(key, searchResponse) diff --git a/src/adapter/search-response-adapter/__tests__/facet-distribution.tests.ts b/src/adapter/search-response-adapter/__tests__/facet-distribution.tests.ts new file mode 100644 index 000000000..9f0d97a0d --- /dev/null +++ b/src/adapter/search-response-adapter/__tests__/facet-distribution.tests.ts @@ -0,0 +1,139 @@ +import { adaptFacetDistribution } from '../../search-response-adapter/facet-distribution-adapter' + +const initialFacetDistribution = { + movie: { + genre: { comedy: 3, horror: 2, drama: 4 }, + releaseYear: { '1990': 10, '2001': 30 }, + }, +} + +describe('Facet distribution unit tests', () => { + test('Fill facet values on one undefined facet', () => { + const returnedDistribution = adaptFacetDistribution( + true, + undefined, + initialFacetDistribution.movie, + { genre: { comedy: 3 } } + ) + + expect(returnedDistribution).toEqual({}) + }) + + test('Fill facet values on one facet in string format', () => { + const returnedDistribution = adaptFacetDistribution( + true, + 'genre', + initialFacetDistribution.movie, + { genre: { comedy: 3 } } + ) + + expect(returnedDistribution).toEqual({ + genre: { comedy: 3, horror: 0, drama: 0 }, + }) + }) + + test('Fill facet values on one facet in array format', () => { + const returnedDistribution = adaptFacetDistribution( + true, + ['genre'], + initialFacetDistribution.movie, + { genre: { comedy: 3 } } + ) + + expect(returnedDistribution).toEqual({ + genre: { comedy: 3, horror: 0, drama: 0 }, + }) + }) + + test('Do not Fill facet values as keepZeroFacets is false', () => { + const returnedDistribution = adaptFacetDistribution( + false, + ['genre'], + initialFacetDistribution.movie, + { genre: { comedy: 3 } } + ) + + expect(returnedDistribution).toEqual({ + genre: { comedy: 3 }, + }) + }) + + test('Fill facet values on empty object distribution', () => { + const returnedDistribution = adaptFacetDistribution( + true, + 'genre', + initialFacetDistribution.movie, + {} + ) + + expect(returnedDistribution).toEqual({ + genre: { comedy: 0, horror: 0, drama: 0 }, + }) + }) + + test('Fill facet values on undefined distribution', () => { + const returnedDistribution = adaptFacetDistribution( + true, + 'genre', + initialFacetDistribution.movie, + undefined + ) + + expect(returnedDistribution).toEqual({ + genre: { comedy: 0, horror: 0, drama: 0 }, + }) + }) + + test('Fill facet values on empty initial distribution and empty distribution', () => { + const returnedDistribution = adaptFacetDistribution( + true, + 'genre', + {}, + undefined + ) + + expect(returnedDistribution).toEqual({}) + }) + + test('Fill facet values on two facets with only one with results', () => { + const returnedDistribution = adaptFacetDistribution( + true, + ['genre', 'releaseYear'], + initialFacetDistribution.movie, + { genre: { comedy: 3 } } + ) + + expect(returnedDistribution).toEqual({ + genre: { comedy: 3, horror: 0, drama: 0 }, + releaseYear: { '1990': 0, '2001': 0 }, + }) + }) + + test('Fill facet values on two facets', () => { + const returnedDistribution = adaptFacetDistribution( + true, + ['genre', 'releaseYear'], + initialFacetDistribution.movie, + { genre: { comedy: 3 }, releaseYear: { '1990': 1 } } + ) + + expect(returnedDistribution).toEqual({ + genre: { comedy: 3, horror: 0, drama: 0 }, + releaseYear: { '1990': 1, '2001': 0 }, + }) + }) + + test('Fill facet values on two empty facets', () => { + const returnedDistribution = adaptFacetDistribution( + true, + ['genre', 'releaseYear'], + initialFacetDistribution.movie, + {} + ) + + expect(returnedDistribution).toEqual({ + genre: { comedy: 0, horror: 0, drama: 0 }, + releaseYear: { '1990': 0, '2001': 0 }, + }) + }) +}) diff --git a/src/adapter/search-response-adapter/facet-distribution-adapter.ts b/src/adapter/search-response-adapter/facet-distribution-adapter.ts new file mode 100644 index 000000000..1aa656327 --- /dev/null +++ b/src/adapter/search-response-adapter/facet-distribution-adapter.ts @@ -0,0 +1,60 @@ +import { FacetDistribution, SearchContext } from '../../types' + +function getFacetNames( + facets: SearchContext['facets'] | string +): Readonly { + if (!facets) return [] + else if (typeof facets === 'string') return [facets] + return facets +} + +// Fills the missing facetValue in the current facet distribution if `keepZeroFacet` is true +// using the initial facet distribution. Ex: +// +// Initial distribution: { genres: { horror: 10, comedy: 4 } } +// Current distribution: { genres: { horror: 3 }} +// Returned distribution: { genres: { horror: 3, comedy: 0 }} +function fillMissingFacetValues( + facets: SearchContext['facets'] | string, + initialFacetDistribution: FacetDistribution, + facetDistribution: FacetDistribution +): FacetDistribution { + const facetNames = getFacetNames(facets) + const filledDistribution: FacetDistribution = {} + + for (const facet of facetNames) { + for (const facetValue in initialFacetDistribution[facet]) { + if (!filledDistribution[facet]) { + // initialize sub object + filledDistribution[facet] = facetDistribution[facet] || {} + } + if (!filledDistribution[facet][facetValue]) { + filledDistribution[facet][facetValue] = 0 + } else { + filledDistribution[facet][facetValue] = + facetDistribution[facet][facetValue] + } + } + } + + return filledDistribution +} + +function adaptFacetDistribution( + keepZeroFacets: boolean, + facets: SearchContext['facets'] | string, + initialFacetDistribution: FacetDistribution, + facetDistribution: FacetDistribution | undefined +) { + if (keepZeroFacets) { + facetDistribution = facetDistribution || {} + return fillMissingFacetValues( + facets, + initialFacetDistribution, + facetDistribution + ) + } + return facetDistribution +} + +export { adaptFacetDistribution } diff --git a/src/adapter/search-response-adapter/search-response-adapter.ts b/src/adapter/search-response-adapter/search-response-adapter.ts index 6fef2aeff..3dc72829d 100644 --- a/src/adapter/search-response-adapter/search-response-adapter.ts +++ b/src/adapter/search-response-adapter/search-response-adapter.ts @@ -2,10 +2,12 @@ import type { SearchContext, MeiliSearchResponse, AlgoliaSearchResponse, + FacetDistribution, } from '../../types' import { adaptHits } from './hits-adapter' import { adaptTotalHits } from './total-hits-adapter' import { adaptPaginationParameters } from './pagination-adapter' +import { adaptFacetDistribution } from './facet-distribution-adapter' /** * Adapt search response from Meilisearch @@ -17,10 +19,17 @@ import { adaptPaginationParameters } from './pagination-adapter' */ export function adaptSearchResponse( searchResponse: MeiliSearchResponse>, - searchContext: SearchContext -): { results: Array> } { + searchContext: SearchContext, + initialFacetDistribution: FacetDistribution +): AlgoliaSearchResponse { const searchResponseOptionals: Record = {} - const { processingTimeMs, query, facetDistribution: facets } = searchResponse + const { + processingTimeMs, + query, + facetDistribution: responseFacetDistribution, + } = searchResponse + + const { keepZeroFacets, facets } = searchContext const { hitsPerPage, page, nbPages } = adaptPaginationParameters( searchResponse, @@ -30,12 +39,19 @@ export function adaptSearchResponse( const hits = adaptHits(searchResponse, searchContext) const nbHits = adaptTotalHits(searchResponse) + const facetDistribution = adaptFacetDistribution( + keepZeroFacets, + facets, + initialFacetDistribution, + responseFacetDistribution + ) + // Create response object compliant with InstantSearch const adaptedSearchResponse = { index: searchContext.indexUid, hitsPerPage, page, - facets, + facets: facetDistribution, nbPages, nbHits, processingTimeMS: processingTimeMs, @@ -45,7 +61,5 @@ export function adaptSearchResponse( exhaustiveNbHits: false, ...searchResponseOptionals, } - return { - results: [adaptedSearchResponse], - } + return adaptedSearchResponse } diff --git a/src/cache/index.ts b/src/cache/index.ts index 44c630b4d..35ae21213 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,2 +1,2 @@ export * from './search-cache' -export * from './first-facets-distribution' +export * from './init-facets-distribution' diff --git a/src/cache/first-facets-distribution.ts b/src/cache/init-facets-distribution.ts similarity index 53% rename from src/cache/first-facets-distribution.ts rename to src/cache/init-facets-distribution.ts index 3af0e29d4..c9024c7bd 100644 --- a/src/cache/first-facets-distribution.ts +++ b/src/cache/init-facets-distribution.ts @@ -1,7 +1,7 @@ import { FacetDistribution, SearchContext } from '../types' import { MeiliParamsCreator } from '../adapter' -export async function cacheFirstFacetDistribution( +async function getIndexFacetDistribution( searchResolver: any, searchContext: SearchContext ): Promise { @@ -23,3 +23,20 @@ export async function cacheFirstFacetDistribution( ) return searchResponse.facetDistribution || {} } + +export async function initFacetDistribution( + searchResolver: any, + searchContext: SearchContext, + initialFacetDistribution: Record +): Promise> { + // Fetch the initial facets distribution of an Index + // Used to show the facets when `placeholderSearch` is set to true + // Used to fill the missing facet values when `keepZeroFacets` is set to true + if (!initialFacetDistribution[searchContext.indexUid]) { + initialFacetDistribution[ + searchContext.indexUid + ] = await getIndexFacetDistribution(searchResolver, searchContext) + } + + return initialFacetDistribution +} diff --git a/src/client/instant-meilisearch-client.ts b/src/client/instant-meilisearch-client.ts index 47c647e67..06d6bf8f4 100644 --- a/src/client/instant-meilisearch-client.ts +++ b/src/client/instant-meilisearch-client.ts @@ -13,7 +13,7 @@ import { SearchResolver, } from '../adapter' import { createSearchContext } from '../contexts' -import { SearchCache, cacheFirstFacetDistribution } from '../cache/' +import { SearchCache, initFacetDistribution } from '../cache/' import { constructClientAgents } from './agents' import { validateInstantMeiliSearchParams } from '../utils' @@ -55,7 +55,7 @@ export function instantMeiliSearch( // create search resolver with included cache const searchResolver = SearchResolver(meilisearchClient, searchCache) - let defaultFacetDistribution: FacetDistribution + let initialFacetDistribution: Record = {} return { clearCache: () => searchCache.clearCache(), @@ -76,23 +76,17 @@ export function instantMeiliSearch( for (const searchRequest of requests) { const searchContext: SearchContext = createSearchContext( searchRequest, - instantMeiliSearchOptions, - defaultFacetDistribution + instantMeiliSearchOptions ) // Adapt search request to Meilisearch compliant search request const adaptedSearchRequest = adaptSearchParams(searchContext) - // Cache first facets distribution of the instantMeilisearch instance - // Needed to add in the facetDistribution the fields that were not returned - // When the user sets `keepZeroFacets` to true. - if (defaultFacetDistribution === undefined) { - defaultFacetDistribution = await cacheFirstFacetDistribution( - searchResolver, - searchContext - ) - searchContext.defaultFacetDistribution = defaultFacetDistribution - } + initialFacetDistribution = await initFacetDistribution( + searchResolver, + searchContext, + initialFacetDistribution + ) // Search response from Meilisearch const searchResponse = await searchResolver.searchResponse( @@ -103,10 +97,11 @@ export function instantMeiliSearch( // Adapt the Meilisearch response to a compliant instantsearch.js response const adaptedSearchResponse = adaptSearchResponse( searchResponse, - searchContext + searchContext, + initialFacetDistribution[searchRequest.indexName] ) - searchResponses.results.push(adaptedSearchResponse.results[0]) + searchResponses.results.push(adaptedSearchResponse) } return searchResponses diff --git a/src/contexts/search-context.ts b/src/contexts/search-context.ts index 08548d4bf..310c93d41 100644 --- a/src/contexts/search-context.ts +++ b/src/contexts/search-context.ts @@ -2,7 +2,6 @@ import { InstantMeiliSearchOptions, AlgoliaMultipleQueriesQuery, SearchContext, - FacetDistribution, } from '../types' import { createPaginationState } from './pagination-context' @@ -14,8 +13,7 @@ import { createPaginationState } from './pagination-context' */ export function createSearchContext( searchRequest: AlgoliaMultipleQueriesQuery, - options: InstantMeiliSearchOptions, - defaultFacetDistribution: FacetDistribution + options: InstantMeiliSearchOptions ): SearchContext { // Split index name and possible sorting rules const [indexUid, ...sortByArray] = searchRequest.indexName.split(':') @@ -33,7 +31,6 @@ export function createSearchContext( sort: sortByArray.join(':') || '', indexUid, pagination: paginationState, - defaultFacetDistribution: defaultFacetDistribution || {}, placeholderSearch: options.placeholderSearch !== false, // true by default keepZeroFacets: !!options.keepZeroFacets, // false by default } diff --git a/src/types/types.ts b/src/types/types.ts index 2d3a4fb4f..b314da832 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,7 +1,4 @@ -import type { - SearchResponse as MeiliSearchResponse, - FacetDistribution, -} from 'meilisearch' +import type { SearchResponse as MeiliSearchResponse } from 'meilisearch' import type { SearchClient } from 'instantsearch.js' import type { MultipleQueriesQuery as AlgoliaMultipleQueriesQuery } from '@algolia/client-search' @@ -18,15 +15,6 @@ export type { export type InstantSearchParams = AlgoliaMultipleQueriesQuery['params'] -export type FacetsCache = { - [category: string]: string[] -} - -export type ParsedFilter = { - filterName: string - value: string -} - export const enum MatchingStrategies { ALL = 'all', LAST = 'last', @@ -73,9 +61,10 @@ export type InstantSearchPagination = { nbPages: number } +export type Facets = string | string[] | undefined + export type SearchContext = Omit & InstantSearchParams & { - defaultFacetDistribution: FacetDistribution pagination: PaginationState indexUid: string placeholderSearch: boolean diff --git a/tests/disjunctive-facet-search.tests.ts b/tests/disjunctive-facet-search.tests.ts new file mode 100644 index 000000000..ddde27ab2 --- /dev/null +++ b/tests/disjunctive-facet-search.tests.ts @@ -0,0 +1,542 @@ +import { instantMeiliSearch } from '../src' +import { Movies, meilisearchClient } from './assets/utils' +import movies from './assets/movies.json' +import games from './assets/games.json' + +// TODO: re-read for review + +describe('Keep zero facets tests', () => { + beforeAll(async () => { + const moviesIndex = meilisearchClient.index('movies') + const gamesIndex = meilisearchClient.index('games') + + await moviesIndex.delete() + await gamesIndex.delete() + + await moviesIndex.updateSettings({ + filterableAttributes: ['genres', 'color', 'platforms'], + }) + await gamesIndex.updateSettings({ + filterableAttributes: ['genres', 'color', 'platforms'], + }) + + await moviesIndex.addDocuments(movies) + const response = await gamesIndex.addDocuments(games) + + await meilisearchClient.waitForTask(response.taskUid) + }) + + test('searching on one index with facet filtering', async () => { + const customClient = instantMeiliSearch( + 'http://localhost:7700', + 'masterKey', + { + keepZeroFacets: false, + } + ) + + const response = await customClient.search([ + { + indexName: 'movies', + params: { + facetFilters: [['genres:Fantasy', 'genres:Action'], ['color:green']], + facets: ['genres', 'color', 'platforms'], + }, + }, + { + indexName: 'movies', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'genres', + facetFilters: [['color:green']], + }, + }, + { + indexName: 'movies', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'color', + facetFilters: [['genres:Fantasy', 'genres:Action']], + }, + }, + ]) + + const moviesMainRes = response.results[0] + const moviesColorRes = response.results[1] + const moviesGenresRes = response.results[2] + + expect(moviesMainRes.hits.length).toBe(1) + expect(moviesMainRes.facets).toEqual({ + color: { + green: 1, + }, + genres: { + Action: 1, + Adventure: 1, + }, + platforms: { + MacOS: 1, + }, + }) + expect(moviesGenresRes.facets).toEqual({ + color: { + green: 1, + red: 1, + }, + }) + expect(moviesColorRes.facets).toEqual({ + genres: { + Adventure: 1, + Action: 1, + }, + }) + }) + + test('searching on two indexes with facet filtering', async () => { + const customClient = instantMeiliSearch( + 'http://localhost:7700', + 'masterKey', + { + keepZeroFacets: false, + } + ) + + const response = await customClient.search([ + { + indexName: 'movies', + params: { + facetFilters: [['genres:Action'], ['color:green']], + facets: ['genres', 'color', 'platforms'], + }, + }, + { + indexName: 'movies', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'color', + facetFilters: [['genres:Action']], + }, + }, + { + indexName: 'movies', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'genres', + facetFilters: [['color:green']], + }, + }, + { + indexName: 'games', + params: { + facets: ['genres', 'color', 'platforms'], + facetFilters: [ + ['genres:Fantasy'], + ['color:red'], + ['platforms:Linux'], + ], + }, + }, + { + indexName: 'games', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'genres', + facetFilters: [['color:red'], ['platforms:Linux']], + }, + }, + { + indexName: 'games', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'color', + facetFilters: [['genres:Fantasy'], ['platforms:Linux']], + }, + }, + { + indexName: 'games', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'platforms', + facetFilters: [['genres:Fantasy'], ['color:red']], + }, + }, + ]) + + const moviesMainRes = response.results[0] + const moviesGenresRes = response.results[1] + const moviesColorRes = response.results[2] + + expect(moviesMainRes.facets).toEqual({ + genres: { + Action: 1, + Adventure: 1, + }, + color: { + green: 1, + }, + platforms: { + MacOS: 1, + }, + }) + expect(moviesGenresRes.facets).toEqual({ + color: { + green: 1, + red: 1, + }, + }) + expect(moviesColorRes.facets).toEqual({ + genres: { + Adventure: 1, + Action: 1, + }, + }) + + const gamesMainRes = response.results[3] + const gamesGenresRes = response.results[4] + const gamesColorRes = response.results[5] + const gamesPlatformRes = response.results[6] + + expect(gamesMainRes.facets).toEqual({ + genres: { + Fantasy: 1, + Drama: 1, + }, + color: { + red: 1, + }, + platforms: { + MacOS: 1, + Windows: 1, + Linux: 1, + }, + }) + expect(gamesGenresRes.facets).toEqual({ + genres: { + Adventure: 1, + Drama: 3, + Fantasy: 1, + Romance: 1, + 'Science Fiction': 2, + }, + }) + expect(gamesColorRes.facets).toEqual({ + color: { + green: 1, + red: 1, + yellow: 1, + }, + }) + expect(gamesPlatformRes.facets).toEqual({ + platforms: { + Linux: 1, + MacOS: 1, + Windows: 1, + }, + }) + }) + + test('searching on two indexes with facet filtering and keep zero facets', async () => { + const customClient = instantMeiliSearch( + 'http://localhost:7700', + 'masterKey', + { + keepZeroFacets: true, + } + ) + + const response = await customClient.search([ + { + indexName: 'movies', + params: { + facetFilters: [['genres:Action'], ['color:green']], + facets: ['genres', 'color', 'platforms'], + }, + }, + { + indexName: 'movies', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'color', + facetFilters: [['genres:Action']], + }, + }, + { + indexName: 'movies', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'genres', + facetFilters: [['color:green']], + }, + }, + { + indexName: 'games', + params: { + facets: ['genres', 'color', 'platforms'], + facetFilters: [ + ['genres:Fantasy'], + ['color:red'], + ['platforms:Linux'], + ], + }, + }, + { + indexName: 'games', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'genres', + facetFilters: [['color:red'], ['platforms:Linux']], + }, + }, + { + indexName: 'games', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'color', + facetFilters: [['genres:Fantasy'], ['platforms:Linux']], + }, + }, + { + indexName: 'games', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'platforms', + facetFilters: [['genres:Fantasy'], ['color:red']], + }, + }, + ]) + + const moviesMainRes = response.results[0] + const moviesGenresRes = response.results[1] + const moviesColorRes = response.results[2] + + expect(moviesMainRes.facets).toEqual({ + genres: { + Action: 1, + Adventure: 1, + Drama: 0, + Fantasy: 0, + Romance: 0, + 'Science Fiction': 0, + }, + color: { + green: 1, + blue: 0, + red: 0, + }, + platforms: { + Linux: 0, + Windows: 0, + MacOS: 1, + }, + }) + expect(moviesGenresRes.facets).toEqual({ + color: { + green: 1, + red: 1, + blue: 0, + }, + }) + expect(moviesColorRes.facets).toEqual({ + genres: { + Adventure: 1, + Action: 1, + Drama: 0, + Fantasy: 0, + Romance: 0, + 'Science Fiction': 0, + }, + }) + + const gamesMainRes = response.results[3] + const gamesGenresRes = response.results[4] + const gamesColorRes = response.results[5] + const gamesPlatformRes = response.results[6] + + expect(gamesMainRes.facets).toEqual({ + genres: { + Fantasy: 1, + Drama: 1, + Adventure: 0, + Action: 0, + Romance: 0, + 'Science Fiction': 0, + }, + color: { + red: 1, + blue: 0, + green: 0, + yellow: 0, + }, + platforms: { + MacOS: 1, + Windows: 1, + Linux: 1, + }, + }) + expect(gamesGenresRes.facets).toEqual({ + genres: { + Action: 0, + Adventure: 1, + Drama: 3, + Fantasy: 1, + Romance: 1, + 'Science Fiction': 2, + }, + }) + expect(gamesColorRes.facets).toEqual({ + color: { + green: 1, + red: 1, + yellow: 1, + blue: 0, + }, + }) + expect(gamesPlatformRes.facets).toEqual({ + platforms: { + Linux: 1, + MacOS: 1, + Windows: 1, + }, + }) + }) + + test('searching on an index with facet filtering with some and operators', async () => { + const customClient = instantMeiliSearch( + 'http://localhost:7700', + 'masterKey', + { + keepZeroFacets: false, + } + ) + + const response = await customClient.search([ + { + indexName: 'movies', + params: { + facetFilters: ['color:green', ['genres:Action']], + facets: ['genres', 'color', 'platforms'], + }, + }, + { + indexName: 'movies', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'genres', + facetFilters: ['color:green'], + }, + }, + ]) + + const moviesMainRes = response.results[0] + const moviesGenresRes = response.results[1] + + expect(moviesMainRes.facets).toEqual({ + genres: { + Action: 1, + Adventure: 1, + }, + color: { + green: 1, + }, + platforms: { + MacOS: 1, + }, + }) + expect(moviesGenresRes.facets).toEqual({ + genres: { + Action: 1, + Adventure: 1, + }, + }) + }) + + test('searching on an index with facet filtering with some and operators with keep zero facets', async () => { + const customClient = instantMeiliSearch( + 'http://localhost:7700', + 'masterKey', + { + keepZeroFacets: true, + } + ) + + const response = await customClient.search([ + { + indexName: 'movies', + params: { + facetFilters: ['color:green', ['genres:Action']], + facets: ['genres', 'color', 'platforms'], + }, + }, + { + indexName: 'movies', + params: { + page: 0, + hitsPerPage: 1, + // @ts-ignore considered a read-only type in instantsearch + facets: 'genres', + facetFilters: ['color:green'], + }, + }, + ]) + + const moviesMainRes = response.results[0] + const moviesGenresRes = response.results[1] + + expect(moviesMainRes.facets).toEqual({ + genres: { + Action: 1, + Adventure: 1, + Drama: 0, + Fantasy: 0, + Romance: 0, + 'Science Fiction': 0, + }, + color: { + green: 1, + blue: 0, + red: 0, + }, + platforms: { + Linux: 0, + Windows: 0, + MacOS: 1, + }, + }) + expect(moviesGenresRes.facets).toEqual({ + genres: { + Action: 1, + Adventure: 1, + Drama: 0, + Fantasy: 0, + Romance: 0, + 'Science Fiction': 0, + }, + }) + }) +}) diff --git a/tests/env/react/src/App.js b/tests/env/react/src/App.js index cef98d011..efd234b20 100644 --- a/tests/env/react/src/App.js +++ b/tests/env/react/src/App.js @@ -2,6 +2,7 @@ import React from 'react' import { Routes, Route, Outlet } from 'react-router-dom' import SingleIndex from './components/SingleIndex' import MultiIndex from './components/MultiIndex' +import SingleMovieIndex from './components/SingleMovieIndex' import './App.css' @@ -11,6 +12,7 @@ const App = () => { } /> } /> + } /> diff --git a/tests/env/react/src/components/MultiIndex.js b/tests/env/react/src/components/MultiIndex.js index 5da3b349a..61b9d0458 100644 --- a/tests/env/react/src/components/MultiIndex.js +++ b/tests/env/react/src/components/MultiIndex.js @@ -6,14 +6,16 @@ import { Highlight, RefinementList, Index, - Hits, + InfiniteHits, Pagination, + Hits, } from 'react-instantsearch-dom' import { instantMeiliSearch } from '../../../../../src/index' const searchClient = instantMeiliSearch('http://localhost:7700', 'masterKey', { primaryKey: 'id', finitePagination: true, + keepZeroFacets: true, }) const Hit = ({ hit }) => { @@ -40,7 +42,7 @@ const MultiIndex = () => (

Genres

Color

- +

Platforms

@@ -62,9 +64,8 @@ const MultiIndex = () => (
- +
- diff --git a/tests/env/react/src/components/SingleMovieIndex.js b/tests/env/react/src/components/SingleMovieIndex.js new file mode 100644 index 000000000..119110d3f --- /dev/null +++ b/tests/env/react/src/components/SingleMovieIndex.js @@ -0,0 +1,62 @@ +import 'instantsearch.css/themes/algolia-min.css' +import React from 'react' +import { + InstantSearch, + InfiniteHits, + SearchBox, + Stats, + Highlight, + ClearRefinements, + RefinementList, +} from 'react-instantsearch-dom' +import { instantMeiliSearch } from '../../../../../src/index' + +const searchClient = instantMeiliSearch('http://localhost:7700', 'masterKey', { + primaryKey: 'id', + keepZeroFacets: true, +}) + +const SingleIndex = () => ( +
+

Meilisearch + React InstantSearch

+

Search in movies

+ + + +
+ +

Genres

+ +

Players

+ +

Platforms

+ +
+
+ + +
+
+
+) + +const Hit = ({ hit }) => { + return ( +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ) +} + +export default SingleIndex